diff --git a/src/main/services/team/TeamMcpConfigBuilder.ts b/src/main/services/team/TeamMcpConfigBuilder.ts index f4d7ebd4..21ec5c63 100644 --- a/src/main/services/team/TeamMcpConfigBuilder.ts +++ b/src/main/services/team/TeamMcpConfigBuilder.ts @@ -75,13 +75,24 @@ function getSourceServerEntry(): string { return path.join(getWorkspaceMcpServerDir(), 'src', 'index.ts'); } -function getWorkspaceTsxBinCandidates(): string[] { +function getWorkspaceTsxPackageJsonCandidates(): string[] { return [ - path.join(getWorkspaceMcpServerDir(), 'node_modules', '.bin', 'tsx'), - path.join(getWorkspaceRoot(), 'node_modules', '.bin', 'tsx'), + 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 }; + 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 { try { await fs.promises.access(targetPath, fs.constants.F_OK); @@ -91,6 +102,40 @@ async function pathExists(targetPath: string): Promise { } } +async function resolveWorkspaceTsxCli(checked: string[]): Promise { + 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'; } @@ -236,14 +281,12 @@ export async function resolveAgentTeamsMcpLaunchSpec(): Promise { const sourceEntry = getSourceServerEntry(); checked.push(sourceEntry); if (await pathExists(sourceEntry)) { - for (const tsxBin of getWorkspaceTsxBinCandidates()) { - checked.push(tsxBin); - if (await pathExists(tsxBin)) { - return { - command: tsxBin, - args: [sourceEntry], - }; - } + const tsxCli = await resolveWorkspaceTsxCli(checked); + if (tsxCli) { + return { + command: await resolveNodePath(), + args: [tsxCli, sourceEntry], + }; } } diff --git a/test/main/services/team/TeamMcpConfigBuilder.test.ts b/test/main/services/team/TeamMcpConfigBuilder.test.ts index 273b2c82..01b50283 100644 --- a/test/main/services/team/TeamMcpConfigBuilder.test.ts +++ b/test/main/services/team/TeamMcpConfigBuilder.test.ts @@ -80,7 +80,10 @@ describe('TeamMcpConfigBuilder', () => { ): { command?: string; args?: string[]; env?: Record } | undefined { const raw = fs.readFileSync(configPath, 'utf8'); const parsed = JSON.parse(raw) as { - mcpServers?: Record }>; + mcpServers?: Record< + string, + { command?: string; args?: string[]; env?: Record } + >; }; return parsed.mcpServers?.['agent-teams']; } @@ -93,12 +96,13 @@ describe('TeamMcpConfigBuilder', () => { expect(server?.command).toMatch(/(^node$|[\\/]node(?:\.exe)?$)/); } - function expectTsxEntry( + function expectNodeTsxSourceEntry( server: { command?: string; args?: string[] } | undefined, - entry: string + tsxCli: string, + sourceEntry: string ): void { - expect(server?.args).toEqual([entry]); - expect(server?.command).toMatch(/[\\/]tsx(?:\.cmd)?$/); + expect(server?.args).toEqual([tsxCli, sourceEntry]); + expect(server?.command).toMatch(/(^node$|[\\/]node(?:\.exe)?$)/); } function getBuiltWorkspaceEntry(): string { @@ -109,8 +113,12 @@ describe('TeamMcpConfigBuilder', () => { return path.join(process.cwd(), 'mcp-server', 'src', 'index.ts'); } - function getWorkspaceTsxBin(): string { - return path.join(process.cwd(), 'mcp-server', 'node_modules', '.bin', 'tsx'); + 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 { @@ -138,14 +146,16 @@ describe('TeamMcpConfigBuilder', () => { function mockSourceWorkspaceEntryAvailable(): { sourceEntry: string; - tsxBin: string; + tsxPackageJson: string; + tsxCli: string; builtEntry: string; } { const sourceEntry = getSourceWorkspaceEntry(); - const tsxBin = getWorkspaceTsxBin(); + const tsxPackageJson = getWorkspaceTsxPackageJson(); + const tsxCli = getWorkspaceTsxCli(); const builtEntry = getBuiltWorkspaceEntry(); - mockPathExists([sourceEntry, tsxBin, builtEntry], { strict: true }); - return { sourceEntry, tsxBin, builtEntry }; + mockPathExists([sourceEntry, tsxPackageJson, tsxCli, builtEntry], { strict: true }); + return { sourceEntry, tsxPackageJson, tsxCli, builtEntry }; } function mockBuiltWorkspaceEntryAvailable(): string { @@ -225,7 +235,7 @@ describe('TeamMcpConfigBuilder', () => { }); it('prefers the source workspace MCP entry in dev mode when available', async () => { - const { sourceEntry } = mockSourceWorkspaceEntryAvailable(); + const { sourceEntry, tsxCli } = mockSourceWorkspaceEntryAvailable(); const builder = new TeamMcpConfigBuilder(); const configPath = await builder.writeConfigFile(); @@ -237,7 +247,7 @@ describe('TeamMcpConfigBuilder', () => { }; const server = parsed.mcpServers?.['agent-teams']; - expectTsxEntry(server, sourceEntry); + expectNodeTsxSourceEntry(server, tsxCli, sourceEntry); }); it('pins the MCP controller to the active Claude base path', async () => { @@ -355,7 +365,7 @@ describe('TeamMcpConfigBuilder', () => { }); it('generated agent-teams server ignores same-named user MCP entry', async () => { - const { sourceEntry } = mockSourceWorkspaceEntryAvailable(); + const { sourceEntry, tsxCli } = mockSourceWorkspaceEntryAvailable(); const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-')); createdDirs.push(homeDir); mockHomeDir = homeDir; @@ -381,7 +391,7 @@ describe('TeamMcpConfigBuilder', () => { mcpServers: Record; }; - expectTsxEntry(parsed.mcpServers['agent-teams'], sourceEntry); + expectNodeTsxSourceEntry(parsed.mcpServers['agent-teams'], tsxCli, sourceEntry); }); it('passes the configured Claude root to the MCP server', async () => { @@ -626,7 +636,7 @@ describe('TeamMcpConfigBuilder', () => { }); it('packaged mode falls back to the source workspace MCP entry when resourcesPath bundle is missing', async () => { - const { sourceEntry } = mockSourceWorkspaceEntryAvailable(); + const { sourceEntry, tsxCli } = mockSourceWorkspaceEntryAvailable(); setPackagedMode(true, '6.0.0'); const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-')); createdDirs.push(resourcesDir); @@ -636,6 +646,6 @@ describe('TeamMcpConfigBuilder', () => { const configPath = await builder.writeConfigFile(); createdPaths.push(configPath); - expectTsxEntry(readGeneratedServer(configPath), sourceEntry); + expectNodeTsxSourceEntry(readGeneratedServer(configPath), tsxCli, sourceEntry); }); });