1287 lines
46 KiB
TypeScript
1287 lines
46 KiB
TypeScript
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<string, string | undefined>;
|
|
}
|
|
) => Promise<{ stdout: string; stderr: string }>;
|
|
|
|
type ResolveInteractiveShellEnvMock = (options?: unknown) => Promise<NodeJS.ProcessEnv>;
|
|
|
|
const hoisted = vi.hoisted(() => ({
|
|
electronState: {
|
|
isPackaged: false,
|
|
version: '9.9.9-test',
|
|
},
|
|
execCliMock: vi.fn<ExecCliMock>(async () => ({
|
|
stdout: JSON.stringify({ execPath: '/mock/node', version: '24.16.0' }),
|
|
stderr: '',
|
|
})),
|
|
cachedShellEnv: null as NodeJS.ProcessEnv | null,
|
|
resolveInteractiveShellEnvMock: vi.fn<ResolveInteractiveShellEnvMock>(
|
|
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<typeof import('@main/utils/childProcess')>();
|
|
return {
|
|
...actual,
|
|
execCli: hoisted.execCliMock,
|
|
};
|
|
});
|
|
|
|
vi.mock('@main/utils/pathDecoder', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@main/utils/pathDecoder')>();
|
|
return {
|
|
...actual,
|
|
getHomeDir: () => mockHomeDir || actual.getHomeDir(),
|
|
};
|
|
});
|
|
|
|
vi.mock('@main/utils/shellEnv', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@main/utils/shellEnv')>();
|
|
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<string, string> }
|
|
| undefined {
|
|
const raw = fs.readFileSync(configPath, 'utf8');
|
|
const parsed = JSON.parse(raw) as {
|
|
mcpServers?: Record<
|
|
string,
|
|
{ command?: string; args?: string[]; enabled?: boolean; env?: Record<string, string> }
|
|
>;
|
|
};
|
|
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<string, { command?: string; args?: string[] }>;
|
|
};
|
|
|
|
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<string, { command?: string; args?: string[] }>;
|
|
};
|
|
|
|
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<string, string> }
|
|
>;
|
|
};
|
|
|
|
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<string, { command?: string; args?: string[] }>;
|
|
};
|
|
|
|
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<string, { command?: string; args?: string[]; enabled?: boolean }>;
|
|
};
|
|
|
|
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<string, { command?: string; args?: string[]; enabled?: boolean }>;
|
|
};
|
|
|
|
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<string, { command?: string; args?: string[]; enabled?: boolean }>;
|
|
};
|
|
|
|
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<string, { command?: string; args?: string[] }>;
|
|
};
|
|
|
|
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);
|
|
});
|
|
});
|