import * as fs from 'fs'; import Module from 'module'; import * as os from 'os'; import * as path from 'path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; type ExecCliMock = ( binaryPath: string | null, args: string[], options?: { encoding?: BufferEncoding; timeout?: number; env?: NodeJS.ProcessEnv | Record; } ) => Promise<{ stdout: string; stderr: string }>; type ResolveInteractiveShellEnvMock = (options?: unknown) => Promise; const hoisted = vi.hoisted(() => ({ electronState: { isPackaged: false, version: '9.9.9-test', }, execCliMock: vi.fn(async () => ({ stdout: JSON.stringify({ execPath: '/mock/node', version: '24.16.0' }), stderr: '', })), cachedShellEnv: null as NodeJS.ProcessEnv | null, resolveInteractiveShellEnvMock: vi.fn( async () => ({}) as NodeJS.ProcessEnv ), })); let mockHomeDir = ''; type ModuleLoad = (request: string, parent: NodeModule | undefined, isMain: boolean) => unknown; const moduleInternal = Module as unknown as { _load: ModuleLoad }; const originalModuleLoad = moduleInternal._load; vi.mock('@main/utils/childProcess', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, execCli: hoisted.execCliMock, }; }); vi.mock('@main/utils/pathDecoder', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getHomeDir: () => mockHomeDir || actual.getHomeDir(), }; }); vi.mock('@main/utils/shellEnv', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getCachedShellEnv: () => hoisted.cachedShellEnv, resolveInteractiveShellEnv: hoisted.resolveInteractiveShellEnvMock, }; }); import { clearResolvedNodePathForTests, resolveAgentTeamsMcpLaunchSpec, TeamMcpConfigBuilder, } from '@main/services/team/TeamMcpConfigBuilder'; import { setAppDataBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder'; function nodeRuntimeProbeStdout(execPath: string, version = '24.16.0'): string { return JSON.stringify({ execPath, version }); } describe('TeamMcpConfigBuilder', () => { const createdPaths: string[] = []; const createdDirs: string[] = []; let tempAppData: string; let originalResourcesPath: string | undefined; let originalControlUrl: string | undefined; function setPackagedMode(isPackaged: boolean, version = '9.9.9-test'): void { hoisted.electronState.isPackaged = isPackaged; hoisted.electronState.version = version; } function setResourcesPath(resourcesPath: string | undefined): void { Object.defineProperty(process, 'resourcesPath', { value: resourcesPath, configurable: true, writable: true, }); } function createPackagedServerBundle(baseDir: string, body = '// packaged server'): string { const dir = path.join(baseDir, 'mcp-server'); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'index.js'), body); fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'agent-teams-mcp' })); return dir; } function readGeneratedServer( configPath: string ): | { command?: string; args?: string[]; enabled?: boolean; env?: Record } | undefined { const raw = fs.readFileSync(configPath, 'utf8'); const parsed = JSON.parse(raw) as { mcpServers?: Record< string, { command?: string; args?: string[]; enabled?: boolean; env?: Record } >; }; return parsed.mcpServers?.['agent-teams']; } function expectNodeEntry( server: { command?: string; args?: string[] } | undefined, entry: string ): void { expect(server?.args).toEqual([entry]); expect(server?.command).toMatch(/(^node(?:-\d+)?$|[\\/]node(?:-\d+)?(?:\.exe)?$)/); } function expectNodeTsxSourceEntry( server: { command?: string; args?: string[] } | undefined, tsxCli: string, sourceEntry: string ): void { expect(server?.args).toEqual([tsxCli, sourceEntry]); expect(server?.command).toMatch(/(^node(?:-\d+)?$|[\\/]node(?:-\d+)?(?:\.exe)?$)/); } function getBuiltWorkspaceEntry(): string { return path.join(process.cwd(), 'mcp-server', 'dist', 'index.js'); } function getSourceWorkspaceEntry(): string { return path.join(process.cwd(), 'mcp-server', 'src', 'index.ts'); } function getWorkspaceTsxPackageJson(): string { return path.join(process.cwd(), 'mcp-server', 'node_modules', 'tsx', 'package.json'); } function getWorkspaceTsxCli(): string { return path.join(process.cwd(), 'mcp-server', 'node_modules', 'tsx', 'dist', 'cli.mjs'); } function mockPathExists(existingPaths: string[], options: { strict?: boolean } = {}): void { const originalAccess = fs.promises.access.bind(fs.promises); vi.spyOn(fs.promises, 'access').mockImplementation(async (targetPath, mode) => { const normalizedPath = typeof targetPath === 'string' ? targetPath : Buffer.isBuffer(targetPath) ? targetPath.toString() : `${targetPath}`; if (existingPaths.includes(normalizedPath)) { return; } if (options.strict) { const error = new Error( `ENOENT: no such file or directory, access '${normalizedPath}'` ) as NodeJS.ErrnoException; error.code = 'ENOENT'; throw error; } await originalAccess(targetPath, mode); }); } function mockSourceWorkspaceEntryAvailable(): { sourceEntry: string; tsxPackageJson: string; tsxCli: string; builtEntry: string; } { const sourceEntry = getSourceWorkspaceEntry(); const tsxPackageJson = getWorkspaceTsxPackageJson(); const tsxCli = getWorkspaceTsxCli(); const builtEntry = getBuiltWorkspaceEntry(); mockPathExists([sourceEntry, tsxPackageJson, tsxCli, builtEntry], { strict: true }); return { sourceEntry, tsxPackageJson, tsxCli, builtEntry }; } function mockBuiltWorkspaceEntryAvailable(): string { const builtEntry = getBuiltWorkspaceEntry(); mockPathExists([builtEntry], { strict: true }); return builtEntry; } beforeEach(() => { clearResolvedNodePathForTests(); originalResourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath; originalControlUrl = process.env.CLAUDE_TEAM_CONTROL_URL; tempAppData = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-appdata-')); createdDirs.push(tempAppData); moduleInternal._load = ((request, parent, isMain) => { if (request === 'electron') { return { app: { get isPackaged() { return hoisted.electronState.isPackaged; }, getVersion: () => hoisted.electronState.version, getPath: () => '/mock/electron-user-data', }, }; } return originalModuleLoad(request, parent, isMain); }) as ModuleLoad; setAppDataBasePath(tempAppData); setPackagedMode(false); setResourcesPath(undefined); hoisted.execCliMock.mockClear(); hoisted.execCliMock.mockResolvedValue({ stdout: nodeRuntimeProbeStdout('/mock/node'), stderr: '', }); hoisted.cachedShellEnv = null; hoisted.resolveInteractiveShellEnvMock.mockClear(); hoisted.resolveInteractiveShellEnvMock.mockResolvedValue({}); }); afterEach(() => { setAppDataBasePath(null); setClaudeBasePathOverride(null); setPackagedMode(false); setResourcesPath(originalResourcesPath); if (originalControlUrl === undefined) { delete process.env.CLAUDE_TEAM_CONTROL_URL; } else { process.env.CLAUDE_TEAM_CONTROL_URL = originalControlUrl; } moduleInternal._load = originalModuleLoad; vi.restoreAllMocks(); for (const filePath of createdPaths.splice(0)) { try { fs.rmSync(filePath, { force: true }); } catch { // ignore cleanup issues in temp dir } } for (const dirPath of createdDirs.splice(0)) { try { fs.rmSync(dirPath, { recursive: true, force: true }); } catch { // ignore cleanup issues in temp dir } } mockHomeDir = ''; }); // ── Config storage ── it('writes config to userData/mcp-configs/, not the system default tmp', async () => { const builder = new TeamMcpConfigBuilder(); const configPath = await builder.writeConfigFile(); createdPaths.push(configPath); const expectedDir = path.join(tempAppData, 'mcp-configs'); expect(configPath.startsWith(expectedDir)).toBe(true); // Config must NOT be in the old hardcoded location expect(configPath).not.toContain('claude-team-mcp'); }); it('config filename contains pid, timestamp, and uuid', async () => { const builder = new TeamMcpConfigBuilder(); const configPath = await builder.writeConfigFile(); createdPaths.push(configPath); const filename = path.basename(configPath); expect(filename).toMatch(new RegExp(`^agent-teams-mcp-${process.pid}-\\d+-[0-9a-f-]+\\.json$`)); }); it('prefers the source workspace MCP entry in dev mode when available', async () => { const { sourceEntry, tsxCli } = mockSourceWorkspaceEntryAvailable(); const builder = new TeamMcpConfigBuilder(); const configPath = await builder.writeConfigFile(); createdPaths.push(configPath); const raw = fs.readFileSync(configPath, 'utf8'); const parsed = JSON.parse(raw) as { mcpServers?: Record; }; const server = parsed.mcpServers?.['agent-teams']; expectNodeTsxSourceEntry(server, tsxCli, sourceEntry); }); it('pins the MCP controller to the active Claude base path', async () => { const claudeDir = path.join(tempAppData, 'custom-claude-root'); setClaudeBasePathOverride(claudeDir); mockSourceWorkspaceEntryAvailable(); const builder = new TeamMcpConfigBuilder(); const configPath = await builder.writeConfigFile(); createdPaths.push(configPath); const server = readGeneratedServer(configPath); expect(server?.env?.AGENT_TEAMS_MCP_CLAUDE_DIR).toBe(claudeDir); }); it('falls back to the built workspace MCP entry when source execution is unavailable', async () => { const builtEntry = mockBuiltWorkspaceEntryAvailable(); const builder = new TeamMcpConfigBuilder(); const configPath = await builder.writeConfigFile(); createdPaths.push(configPath); const raw = fs.readFileSync(configPath, 'utf8'); const parsed = JSON.parse(raw) as { mcpServers?: Record; }; const server = parsed.mcpServers?.['agent-teams']; expectNodeEntry(server, builtEntry); }); it('uses the shared CLI helper for the Node.js runtime resolver', async () => { mockBuiltWorkspaceEntryAvailable(); const builder = new TeamMcpConfigBuilder(); const configPath = await builder.writeConfigFile(); createdPaths.push(configPath); expect(readGeneratedServer(configPath)?.command).toBe('/mock/node'); expect(hoisted.execCliMock).toHaveBeenCalledWith( 'node', ['-e', expect.stringContaining('process.versions.node')], expect.objectContaining({ encoding: 'utf-8', timeout: 5000, env: expect.objectContaining({ PATH: expect.any(String) }), }) ); expect(hoisted.resolveInteractiveShellEnvMock).not.toHaveBeenCalled(); }); it('resolves packaged MCP Node through cached shell PATH without spawning shell', async () => { setPackagedMode(true, '2.0.0'); const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-')); createdDirs.push(resourcesDir); createPackagedServerBundle(resourcesDir, '// packaged server'); setResourcesPath(resourcesDir); hoisted.cachedShellEnv = { PATH: ['/mock-shell-node-bin', '/usr/bin'].join(path.delimiter), HOME: '/Users/tester', }; hoisted.resolveInteractiveShellEnvMock.mockResolvedValue(hoisted.cachedShellEnv); hoisted.execCliMock.mockImplementationOnce(async () => { throw new Error('Electron-as-Node unavailable'); }); hoisted.execCliMock.mockImplementationOnce(async (command, _args, options) => { expect(command).toBe('node'); const env = options?.env as NodeJS.ProcessEnv | undefined; expect(env?.PATH?.split(path.delimiter)[0]).toBe('/mock-shell-node-bin'); return { stdout: nodeRuntimeProbeStdout('/mock-shell-node-bin/node'), stderr: '' }; }); const builder = new TeamMcpConfigBuilder(); const configPath = await builder.writeConfigFile(); createdPaths.push(configPath); expect(readGeneratedServer(configPath)?.command).toBe('/mock-shell-node-bin/node'); expect(readGeneratedServer(configPath)?.command).not.toBe('node'); expect(hoisted.resolveInteractiveShellEnvMock).not.toHaveBeenCalled(); }); it.each(['linux', 'darwin', 'win32'] as const)( 'uses the packaged Electron Node runtime for %s packaged MCP launches', async (platform) => { const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); const execPathDescriptor = Object.getOwnPropertyDescriptor(process, 'execPath'); const electronBinary = platform === 'win32' ? 'C:\\Program Files\\Agent Teams AI\\agent-teams-ai.exe' : '/opt/Agent Teams AI/agent-teams-ai'; setPackagedMode(true, '3.0.0'); const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-')); createdDirs.push(resourcesDir); createPackagedServerBundle(resourcesDir, '// packaged server'); setResourcesPath(resourcesDir); hoisted.execCliMock.mockResolvedValue({ stdout: nodeRuntimeProbeStdout(electronBinary, '24.15.0'), stderr: '', }); Object.defineProperty(process, 'platform', { value: platform, configurable: true, }); Object.defineProperty(process, 'execPath', { value: electronBinary, configurable: true, writable: true, }); try { const launchSpec = await resolveAgentTeamsMcpLaunchSpec(); const builder = new TeamMcpConfigBuilder(); const configPath = await builder.writeConfigFile(); createdPaths.push(configPath); const server = readGeneratedServer(configPath); const expectedEntry = path.join(tempAppData, 'mcp-server', '3.0.0', 'index.js'); expect(launchSpec).toEqual({ command: electronBinary, args: [expectedEntry], env: { ELECTRON_RUN_AS_NODE: '1' }, }); expect(server?.command).toBe(electronBinary); expect(server?.args).toEqual([expectedEntry]); expect(server?.env?.ELECTRON_RUN_AS_NODE).toBe('1'); expect(hoisted.execCliMock).toHaveBeenCalledTimes(1); expect(hoisted.execCliMock).toHaveBeenCalledWith( electronBinary, ['-e', expect.stringContaining('process.versions.node')], expect.objectContaining({ env: expect.objectContaining({ ELECTRON_RUN_AS_NODE: '1' }), }) ); } finally { if (platformDescriptor) { Object.defineProperty(process, 'platform', platformDescriptor); } if (execPathDescriptor) { Object.defineProperty(process, 'execPath', execPathDescriptor); } } } ); it('falls back to strict shell env lookup when fast Node lookup cannot resolve Node', async () => { mockBuiltWorkspaceEntryAvailable(); const previousNodeBinary = process.env.NODE_BINARY; const previousNpmNodeExecPath = process.env.npm_node_execpath; delete process.env.NODE_BINARY; delete process.env.npm_node_execpath; hoisted.resolveInteractiveShellEnvMock.mockResolvedValue({ PATH: ['/strict-shell-node-bin', '/usr/bin'].join(path.delimiter), HOME: '/Users/tester', }); hoisted.execCliMock.mockImplementation(async (command, _args, options) => { const env = options?.env as NodeJS.ProcessEnv | undefined; if (env?.PATH?.split(path.delimiter)[0] === '/strict-shell-node-bin') { expect(command).toBe('node'); return { stdout: nodeRuntimeProbeStdout('/strict-shell-node-bin/node'), stderr: '' }; } throw new Error(`spawn ${command} ENOENT`); }); try { const builder = new TeamMcpConfigBuilder(); const configPath = await builder.writeConfigFile(); createdPaths.push(configPath); expect(readGeneratedServer(configPath)?.command).toBe('/strict-shell-node-bin/node'); expect(hoisted.resolveInteractiveShellEnvMock).toHaveBeenCalledWith( expect.objectContaining({ source: 'mcp-node-runtime' }) ); } finally { if (previousNodeBinary === undefined) { delete process.env.NODE_BINARY; } else { process.env.NODE_BINARY = previousNodeBinary; } if (previousNpmNodeExecPath === undefined) { delete process.env.npm_node_execpath; } else { process.env.npm_node_execpath = previousNpmNodeExecPath; } } }); it('prefers strict shell env lookup over fast Node lookup from a minimal GUI PATH', async () => { mockBuiltWorkspaceEntryAvailable(); const previousPath = process.env.PATH; process.env.PATH = ['/usr/bin', '/bin', '/usr/sbin', '/sbin'].join(path.delimiter); hoisted.resolveInteractiveShellEnvMock.mockResolvedValue({ PATH: ['/strict-shell-node-bin', '/usr/bin'].join(path.delimiter), HOME: '/Users/tester', }); hoisted.execCliMock.mockImplementation(async (command, _args, options) => { const env = options?.env as NodeJS.ProcessEnv | undefined; if (env?.PATH?.split(path.delimiter)[0] === '/strict-shell-node-bin') { expect(command).toBe('node'); return { stdout: nodeRuntimeProbeStdout('/strict-shell-node-bin/node'), stderr: '' }; } return { stdout: nodeRuntimeProbeStdout('/fast/node'), stderr: '' }; }); try { const builder = new TeamMcpConfigBuilder(); const configPath = await builder.writeConfigFile(); createdPaths.push(configPath); expect(readGeneratedServer(configPath)?.command).toBe('/strict-shell-node-bin/node'); expect(hoisted.resolveInteractiveShellEnvMock).toHaveBeenCalledWith( expect.objectContaining({ source: 'mcp-node-runtime' }) ); } finally { if (previousPath === undefined) { delete process.env.PATH; } else { process.env.PATH = previousPath; } } }); it('falls back to strict shell env lookup when the fast Node runtime is too old', async () => { mockBuiltWorkspaceEntryAvailable(); const previousNodeBinary = process.env.NODE_BINARY; const previousNpmNodeExecPath = process.env.npm_node_execpath; const previousPath = process.env.PATH; delete process.env.NODE_BINARY; delete process.env.npm_node_execpath; process.env.PATH = ['/usr/bin', '/bin', '/usr/sbin', '/sbin'].join(path.delimiter); hoisted.resolveInteractiveShellEnvMock.mockResolvedValue({ PATH: ['/strict-shell-node-bin', '/usr/bin'].join(path.delimiter), HOME: '/Users/tester', }); hoisted.execCliMock.mockImplementation(async (command, _args, options) => { const env = options?.env as NodeJS.ProcessEnv | undefined; if (env?.PATH?.split(path.delimiter)[0] === '/strict-shell-node-bin') { expect(command).toBe('node'); return { stdout: nodeRuntimeProbeStdout('/strict-shell-node-bin/node', '24.16.0'), stderr: '', }; } return { stdout: nodeRuntimeProbeStdout('/usr/bin/node', '22.21.1'), stderr: '' }; }); try { const builder = new TeamMcpConfigBuilder(); const configPath = await builder.writeConfigFile(); createdPaths.push(configPath); expect(readGeneratedServer(configPath)?.command).toBe('/strict-shell-node-bin/node'); expect(hoisted.resolveInteractiveShellEnvMock).toHaveBeenCalledWith( expect.objectContaining({ source: 'mcp-node-runtime' }) ); } finally { if (previousNodeBinary === undefined) { delete process.env.NODE_BINARY; } else { process.env.NODE_BINARY = previousNodeBinary; } if (previousNpmNodeExecPath === undefined) { delete process.env.npm_node_execpath; } else { process.env.npm_node_execpath = previousNpmNodeExecPath; } if (previousPath === undefined) { delete process.env.PATH; } else { process.env.PATH = previousPath; } } }); it('falls back to strict shell env lookup when fast Node lookup reports an empty path', async () => { mockBuiltWorkspaceEntryAvailable(); hoisted.resolveInteractiveShellEnvMock.mockResolvedValue({ PATH: ['/strict-shell-node-bin', '/usr/bin'].join(path.delimiter), HOME: '/Users/tester', }); let returnedEmptyPath = false; hoisted.execCliMock.mockImplementation(async (command, _args, options) => { const env = options?.env as NodeJS.ProcessEnv | undefined; if (env?.PATH?.split(path.delimiter)[0] === '/strict-shell-node-bin') { expect(command).toBe('node'); return { stdout: nodeRuntimeProbeStdout('/strict-shell-node-bin/node'), stderr: '' }; } if (!returnedEmptyPath) { returnedEmptyPath = true; return { stdout: ' ', stderr: '' }; } throw new Error(`spawn ${command} ENOENT`); }); const builder = new TeamMcpConfigBuilder(); const configPath = await builder.writeConfigFile(); createdPaths.push(configPath); expect(readGeneratedServer(configPath)?.command).toBe('/strict-shell-node-bin/node'); expect(hoisted.resolveInteractiveShellEnvMock).toHaveBeenCalledWith( expect.objectContaining({ source: 'mcp-node-runtime' }) ); }); it('prefers an explicit NODE_BINARY over PATH-based node lookup', async () => { mockBuiltWorkspaceEntryAvailable(); const previousNodeBinary = process.env.NODE_BINARY; process.env.NODE_BINARY = '/explicit/node'; hoisted.execCliMock.mockImplementationOnce(async (command) => { expect(command).toBe('/explicit/node'); return { stdout: nodeRuntimeProbeStdout('/explicit/node'), stderr: '' }; }); try { const builder = new TeamMcpConfigBuilder(); const configPath = await builder.writeConfigFile(); createdPaths.push(configPath); expect(readGeneratedServer(configPath)?.command).toBe('/explicit/node'); expect(hoisted.resolveInteractiveShellEnvMock).not.toHaveBeenCalled(); } finally { if (previousNodeBinary === undefined) { delete process.env.NODE_BINARY; } else { process.env.NODE_BINARY = previousNodeBinary; } } }); it('fails fast when Node cannot be resolved instead of emitting a broken bare node command', async () => { mockBuiltWorkspaceEntryAvailable(); hoisted.execCliMock.mockRejectedValue(new Error('spawn node ENOENT')); const builder = new TeamMcpConfigBuilder(); await expect(builder.writeConfigFile()).rejects.toThrow( 'Node.js runtime for Agent Teams MCP was not found' ); }); it('keeps generated team MCP config minimal and does not inline top-level user MCP', async () => { const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-')); const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-project-')); createdDirs.push(homeDir, projectDir); mockHomeDir = homeDir; fs.writeFileSync( path.join(homeDir, '.claude.json'), JSON.stringify( { mcpServers: { globalOnly: { type: 'http', url: 'https://global.example.com/mcp' }, duplicateServer: { type: 'http', url: 'https://global.example.com/duplicate' }, }, }, null, 2 ) ); fs.writeFileSync( path.join(projectDir, '.mcp.json'), JSON.stringify( { mcpServers: { projectOnly: { command: 'node', args: ['project-server.js'] }, duplicateServer: { command: 'node', args: ['project-override.js'] }, }, }, null, 2 ) ); const builder = new TeamMcpConfigBuilder(); const configPath = await builder.writeConfigFile(projectDir); createdPaths.push(configPath); const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')) as { mcpServers: Record< string, { command?: string; args?: string[]; type?: string; url?: string } >; }; expect(Object.keys(parsed.mcpServers)).toEqual(['agent-teams']); expect(parsed.mcpServers.globalOnly).toBeUndefined(); expect(parsed.mcpServers.duplicateServer).toBeUndefined(); }); it('writes Agent Teams MCP only member config even when user and project MCP exist', async () => { const { sourceEntry, tsxCli } = mockSourceWorkspaceEntryAvailable(); const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-')); const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-project-')); const claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-claude-root-')); createdDirs.push(homeDir, projectDir, claudeRoot); mockHomeDir = homeDir; setClaudeBasePathOverride(claudeRoot); fs.writeFileSync( path.join(homeDir, '.claude.json'), JSON.stringify( { mcpServers: { 'brave-real-browser': { command: 'node', args: ['brave-real-browser.js'], }, context7: { type: 'http', url: 'https://context7.example.com/mcp', }, 'agent-teams': { command: 'node', args: ['user-shadow-agent-teams.js'], enabled: false, }, }, projects: { [projectDir]: { mcpServers: { 'chrome-devtools': { command: 'node', args: ['chrome-devtools.js'], }, }, }, }, }, null, 2 ) ); fs.writeFileSync( path.join(projectDir, '.mcp.json'), JSON.stringify( { mcpServers: { tavily: { command: 'node', args: ['tavily.js'], }, }, }, null, 2 ) ); const builder = new TeamMcpConfigBuilder(); const configPath = await builder.writeConfigFile(projectDir, { mode: 'appOnly' }); createdPaths.push(configPath); const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')) as { mcpServers: Record< string, { command?: string; args?: string[]; enabled?: boolean; env?: Record } >; }; expect(Object.keys(parsed.mcpServers)).toEqual(['agent-teams']); expectNodeTsxSourceEntry(parsed.mcpServers['agent-teams'], tsxCli, sourceEntry); expect(parsed.mcpServers['agent-teams']).toMatchObject({ enabled: true, env: { AGENT_TEAMS_MCP_CLAUDE_DIR: claudeRoot, }, }); expect(parsed.mcpServers['brave-real-browser']).toBeUndefined(); expect(parsed.mcpServers.context7).toBeUndefined(); expect(parsed.mcpServers['chrome-devtools']).toBeUndefined(); expect(parsed.mcpServers.tavily).toBeUndefined(); }); it('does not inline project MCP config to preserve native Claude precedence', async () => { const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-')); const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-project-')); createdDirs.push(homeDir, projectDir); mockHomeDir = homeDir; fs.writeFileSync( path.join(homeDir, '.claude.json'), JSON.stringify({ mcpServers: {} }, null, 2) ); fs.writeFileSync( path.join(projectDir, '.mcp.json'), JSON.stringify( { mcpServers: { projectOnly: { command: 'node', args: ['project-server.js'] }, }, }, null, 2 ) ); const builder = new TeamMcpConfigBuilder(); const configPath = await builder.writeConfigFile(projectDir); createdPaths.push(configPath); const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')) as { mcpServers: Record; }; expect(parsed.mcpServers.projectOnly).toBeUndefined(); expect(Object.keys(parsed.mcpServers)).toEqual(['agent-teams']); }); it('inlines allowlisted MCP servers for strict member policies with Claude precedence', async () => { const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-')); const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-project-')); createdDirs.push(homeDir, projectDir); mockHomeDir = homeDir; fs.writeFileSync( path.join(homeDir, '.claude.json'), JSON.stringify( { mcpServers: { github: { type: 'http', url: 'https://user.example.com/mcp' }, sentry: { command: 'node', args: ['sentry.js'] }, }, projects: { [projectDir]: { mcpServers: { github: { type: 'http', url: 'https://local.example.com/mcp' }, }, }, }, }, null, 2 ) ); fs.writeFileSync( path.join(projectDir, '.mcp.json'), JSON.stringify( { mcpServers: { github: { type: 'http', url: 'https://project.example.com/mcp' }, linear: { type: 'http', url: 'https://linear.example.com/mcp' }, }, }, null, 2 ) ); const builder = new TeamMcpConfigBuilder(); const configPath = await builder.writeConfigFile(projectDir, { mode: 'strictAllowlist', serverNames: ['GitHub', 'LINEAR'], }); createdPaths.push(configPath); const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')) as { mcpServers: Record< string, { command?: string; args?: string[]; type?: string; url?: string } >; }; expect(Object.keys(parsed.mcpServers).sort()).toEqual(['agent-teams', 'github', 'linear']); expect(parsed.mcpServers.github).toEqual({ type: 'http', url: 'https://local.example.com/mcp', }); expect(parsed.mcpServers.linear).toEqual({ type: 'http', url: 'https://linear.example.com/mcp', }); expect(parsed.mcpServers.sentry).toBeUndefined(); }); it('generated agent-teams server ignores same-named user MCP entry', async () => { const { sourceEntry, tsxCli } = mockSourceWorkspaceEntryAvailable(); const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-')); createdDirs.push(homeDir); mockHomeDir = homeDir; fs.writeFileSync( path.join(homeDir, '.claude.json'), JSON.stringify( { mcpServers: { 'agent-teams': { command: 'node', args: ['user-server.js'] }, }, }, null, 2 ) ); const builder = new TeamMcpConfigBuilder(); const configPath = await builder.writeConfigFile(); createdPaths.push(configPath); const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')) as { mcpServers: Record; }; expectNodeTsxSourceEntry(parsed.mcpServers['agent-teams'], tsxCli, sourceEntry); expect(parsed.mcpServers['agent-teams']?.enabled).toBe(true); }); it('forces generated agent-teams MCP even when user, project, local, or allowlist settings shadow it', async () => { const { sourceEntry, tsxCli } = mockSourceWorkspaceEntryAvailable(); const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-')); const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-project-')); createdDirs.push(homeDir, projectDir); mockHomeDir = homeDir; fs.writeFileSync( path.join(homeDir, '.claude.json'), JSON.stringify( { mcpServers: { 'agent-teams': { command: 'node', args: ['user-shadow.js'], enabled: false }, }, projects: { [projectDir]: { mcpServers: { 'agent-teams': { command: 'node', args: ['local-shadow.js'], enabled: false, }, }, }, }, }, null, 2 ) ); fs.writeFileSync( path.join(projectDir, '.mcp.json'), JSON.stringify( { mcpServers: { 'agent-teams': { command: 'node', args: ['project-shadow.js'], enabled: false }, }, }, null, 2 ) ); const builder = new TeamMcpConfigBuilder(); const configPath = await builder.writeConfigFile(projectDir, { mode: 'strictAllowlist', serverNames: ['agent-teams'], }); createdPaths.push(configPath); const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')) as { mcpServers: Record; }; expect(Object.keys(parsed.mcpServers)).toEqual(['agent-teams']); expectNodeTsxSourceEntry(parsed.mcpServers['agent-teams'], tsxCli, sourceEntry); expect(parsed.mcpServers['agent-teams']?.enabled).toBe(true); }); it('forces the generated agent-teams MCP server on regardless of user, local, or project settings', async () => { const { sourceEntry, tsxCli } = mockSourceWorkspaceEntryAvailable(); const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-')); const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-project-')); createdDirs.push(homeDir, projectDir); mockHomeDir = homeDir; fs.writeFileSync( path.join(homeDir, '.claude.json'), JSON.stringify( { mcpServers: { 'agent-teams': { command: 'node', args: ['user-disabled.js'], enabled: false }, }, projects: { [projectDir]: { mcpServers: { 'agent-teams': { command: 'node', args: ['local-disabled.js'], enabled: false }, }, }, }, }, null, 2 ) ); fs.writeFileSync( path.join(projectDir, '.mcp.json'), JSON.stringify( { mcpServers: { 'agent-teams': { command: 'node', args: ['project-disabled.js'], enabled: false }, }, }, null, 2 ) ); const builder = new TeamMcpConfigBuilder(); const configPath = await builder.writeConfigFile(projectDir); createdPaths.push(configPath); const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')) as { mcpServers: Record; }; expect(Object.keys(parsed.mcpServers)).toEqual(['agent-teams']); expect(parsed.mcpServers['agent-teams']?.enabled).toBe(true); expectNodeTsxSourceEntry(parsed.mcpServers['agent-teams'], tsxCli, sourceEntry); }); it('passes the configured Claude root to the MCP server', async () => { const claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-claude-root-')); createdDirs.push(claudeRoot); setClaudeBasePathOverride(claudeRoot); const builder = new TeamMcpConfigBuilder(); const configPath = await builder.writeConfigFile(); createdPaths.push(configPath); expect(readGeneratedServer(configPath)?.env).toMatchObject({ AGENT_TEAMS_MCP_CLAUDE_DIR: claudeRoot, }); }); it('passes the published control API URL to the MCP server', async () => { process.env.CLAUDE_TEAM_CONTROL_URL = 'http://127.0.0.1:43123'; const builder = new TeamMcpConfigBuilder(); const configPath = await builder.writeConfigFile(); createdPaths.push(configPath); expect(readGeneratedServer(configPath)?.env).toMatchObject({ CLAUDE_TEAM_CONTROL_URL: 'http://127.0.0.1:43123', }); }); it('allows an explicit control API URL when no MCP policy is provided', async () => { const builder = new TeamMcpConfigBuilder(); const configPath = await builder.writeConfigFile(undefined, { controlApiBaseUrl: 'http://127.0.0.1:43124', }); createdPaths.push(configPath); expect(readGeneratedServer(configPath)?.env).toMatchObject({ CLAUDE_TEAM_CONTROL_URL: 'http://127.0.0.1:43124', }); }); it('ignores malformed user MCP file', async () => { const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-')); const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-project-')); createdDirs.push(homeDir, projectDir); mockHomeDir = homeDir; fs.writeFileSync(path.join(homeDir, '.claude.json'), '{ invalid json'); const builder = new TeamMcpConfigBuilder(); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); let configPath = ''; try { configPath = await builder.writeConfigFile(projectDir); } finally { warnSpy.mockRestore(); } createdPaths.push(configPath); const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')) as { mcpServers: Record; }; expect(Object.keys(parsed.mcpServers)).toEqual(['agent-teams']); }); // ── Cleanup: removeConfigFile ── it('removeConfigFile deletes the file', async () => { const builder = new TeamMcpConfigBuilder(); const configPath = await builder.writeConfigFile(); expect(fs.existsSync(configPath)).toBe(true); await builder.removeConfigFile(configPath); expect(fs.existsSync(configPath)).toBe(false); }); it('removeConfigFile ignores ENOENT', async () => { const builder = new TeamMcpConfigBuilder(); const bogusPath = path.join(tempAppData, 'nonexistent.json'); // Should not throw await builder.removeConfigFile(bogusPath); }); it('removeConfigFile defers Windows locked temp config cleanup without warning', async () => { const builder = new TeamMcpConfigBuilder(); const configPath = path.join( tempAppData, 'mcp-configs', `agent-teams-mcp-${process.pid}-locked.json` ); const originalUnlink = fs.promises.unlink.bind(fs.promises); const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockImplementation(async (targetPath) => { if (targetPath === configPath) { const error = new Error('EPERM: operation not permitted, unlink') as NodeJS.ErrnoException; error.code = 'EPERM'; throw error; } await originalUnlink(targetPath); }); await builder.removeConfigFile(configPath); expect(unlinkSpy).toHaveBeenCalledTimes(4); }); // ── Cleanup: gcOwnConfigs ── it('gcOwnConfigs removes only files owned by current pid', async () => { const configDir = path.join(tempAppData, 'mcp-configs'); fs.mkdirSync(configDir, { recursive: true }); const ownFile = path.join(configDir, `agent-teams-mcp-${process.pid}-12345-abc.json`); const otherFile = path.join(configDir, `agent-teams-mcp-99999-12345-xyz.json`); fs.writeFileSync(ownFile, '{}'); fs.writeFileSync(otherFile, '{}'); const builder = new TeamMcpConfigBuilder(); await builder.gcOwnConfigs(); expect(fs.existsSync(ownFile)).toBe(false); expect(fs.existsSync(otherFile)).toBe(true); }); // ── Cleanup: gcStaleConfigs ── it('gcStaleConfigs removes files older than TTL', async () => { const configDir = path.join(tempAppData, 'mcp-configs'); fs.mkdirSync(configDir, { recursive: true }); const oldFile = path.join(configDir, `agent-teams-mcp-999-1-old.json`); fs.writeFileSync(oldFile, '{}'); // Set mtime to 2 days ago const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); fs.utimesSync(oldFile, twoDaysAgo, twoDaysAgo); const freshFile = path.join(configDir, `agent-teams-mcp-999-2-fresh.json`); fs.writeFileSync(freshFile, '{}'); const builder = new TeamMcpConfigBuilder(); await builder.gcStaleConfigs(24 * 60 * 60 * 1000); // 24h TTL expect(fs.existsSync(oldFile)).toBe(false); expect(fs.existsSync(freshFile)).toBe(true); }); it('gcStaleConfigs does not remove fresh files', async () => { const configDir = path.join(tempAppData, 'mcp-configs'); fs.mkdirSync(configDir, { recursive: true }); const freshFile = path.join(configDir, `agent-teams-mcp-1-1234-abc.json`); fs.writeFileSync(freshFile, '{}'); const builder = new TeamMcpConfigBuilder(); await builder.gcStaleConfigs(24 * 60 * 60 * 1000); expect(fs.existsSync(freshFile)).toBe(true); }); it('gcStaleConfigs handles empty or missing directory gracefully', async () => { const builder = new TeamMcpConfigBuilder(); // Should not throw when directory doesn't exist await builder.gcStaleConfigs(); }); // ── Packaged copy / fallback ── it('packaged mode reuses an existing valid stable copy', async () => { setPackagedMode(true, '1.2.3'); setResourcesPath(tempAppData); const stableDir = path.join(tempAppData, 'mcp-server', '1.2.3'); fs.mkdirSync(stableDir, { recursive: true }); fs.writeFileSync(path.join(stableDir, 'index.js'), '// stable copy'); fs.writeFileSync(path.join(stableDir, 'package.json'), JSON.stringify({ name: 'stable' })); const builder = new TeamMcpConfigBuilder(); const configPath = await builder.writeConfigFile(); createdPaths.push(configPath); expectNodeEntry(readGeneratedServer(configPath), path.join(stableDir, 'index.js')); }); it('packaged mode copies the MCP server from resourcesPath into userData', async () => { setPackagedMode(true, '2.0.0'); const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-')); createdDirs.push(resourcesDir); createPackagedServerBundle(resourcesDir, '// copied server'); setResourcesPath(resourcesDir); const builder = new TeamMcpConfigBuilder(); const configPath = await builder.writeConfigFile(); createdPaths.push(configPath); const stableDir = path.join(tempAppData, 'mcp-server', '2.0.0'); expect(fs.existsSync(path.join(stableDir, 'index.js'))).toBe(true); expect(fs.existsSync(path.join(stableDir, 'package.json'))).toBe(true); expectNodeEntry(readGeneratedServer(configPath), path.join(stableDir, 'index.js')); }); it('packaged mode heals a partial stable copy and rebuilds it from resourcesPath', async () => { setPackagedMode(true, '3.0.0'); const stableDir = path.join(tempAppData, 'mcp-server', '3.0.0'); fs.mkdirSync(stableDir, { recursive: true }); fs.writeFileSync(path.join(stableDir, 'index.js'), '// partial copy only'); const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-')); createdDirs.push(resourcesDir); createPackagedServerBundle(resourcesDir, '// healed server'); setResourcesPath(resourcesDir); const builder = new TeamMcpConfigBuilder(); const configPath = await builder.writeConfigFile(); createdPaths.push(configPath); expect(fs.readFileSync(path.join(stableDir, 'index.js'), 'utf8')).toContain('healed server'); expect(fs.existsSync(path.join(stableDir, 'package.json'))).toBe(true); expect(readGeneratedServer(configPath)?.args).toEqual([path.join(stableDir, 'index.js')]); }); it('packaged mode falls back to resourcesPath when stable copy creation fails', async () => { setPackagedMode(true, '4.0.0'); const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-')); createdDirs.push(resourcesDir); createPackagedServerBundle(resourcesDir, '// fallback server'); setResourcesPath(resourcesDir); vi.spyOn(fs.promises, 'copyFile').mockRejectedValueOnce(new Error('copy failed')); const builder = new TeamMcpConfigBuilder(); const configPath = await builder.writeConfigFile(); createdPaths.push(configPath); expectNodeEntry( readGeneratedServer(configPath), path.join(resourcesDir, 'mcp-server', 'index.js') ); }); it('packaged mode uses the winner stable copy when atomic rename loses the race', async () => { setPackagedMode(true, '5.0.0'); const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-')); createdDirs.push(resourcesDir); createPackagedServerBundle(resourcesDir, '// race source'); setResourcesPath(resourcesDir); const stableDir = path.join(tempAppData, 'mcp-server', '5.0.0'); const originalRename = fs.promises.rename.bind(fs.promises); vi.spyOn(fs.promises, 'rename').mockImplementation(async (from, to) => { if (to === stableDir) { fs.mkdirSync(stableDir, { recursive: true }); fs.writeFileSync(path.join(stableDir, 'index.js'), '// winner copy'); fs.writeFileSync(path.join(stableDir, 'package.json'), JSON.stringify({ name: 'winner' })); const err = new Error('EEXIST') as NodeJS.ErrnoException; err.code = 'EEXIST'; throw err; } return originalRename(from, to); }); const builder = new TeamMcpConfigBuilder(); const configPath = await builder.writeConfigFile(); createdPaths.push(configPath); expect(fs.readFileSync(path.join(stableDir, 'index.js'), 'utf8')).toContain('winner copy'); expectNodeEntry(readGeneratedServer(configPath), path.join(stableDir, 'index.js')); }); it('packaged mode falls back to the source workspace MCP entry when resourcesPath bundle is missing', async () => { const { sourceEntry, tsxCli } = mockSourceWorkspaceEntryAvailable(); setPackagedMode(true, '6.0.0'); const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-')); createdDirs.push(resourcesDir); setResourcesPath(resourcesDir); const builder = new TeamMcpConfigBuilder(); const configPath = await builder.writeConfigFile(); createdPaths.push(configPath); expectNodeTsxSourceEntry(readGeneratedServer(configPath), tsxCli, sourceEntry); }); });