fix: launch dev mcp server via node
This commit is contained in:
parent
27a38034cc
commit
7fa71019b2
2 changed files with 81 additions and 28 deletions
|
|
@ -75,13 +75,24 @@ function getSourceServerEntry(): string {
|
||||||
return path.join(getWorkspaceMcpServerDir(), 'src', 'index.ts');
|
return path.join(getWorkspaceMcpServerDir(), 'src', 'index.ts');
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWorkspaceTsxBinCandidates(): string[] {
|
function getWorkspaceTsxPackageJsonCandidates(): string[] {
|
||||||
return [
|
return [
|
||||||
path.join(getWorkspaceMcpServerDir(), 'node_modules', '.bin', 'tsx'),
|
path.join(getWorkspaceMcpServerDir(), 'node_modules', 'tsx', 'package.json'),
|
||||||
path.join(getWorkspaceRoot(), 'node_modules', '.bin', 'tsx'),
|
path.join(getWorkspaceRoot(), 'node_modules', 'tsx', 'package.json'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolvePackageBin(
|
||||||
|
packageJsonPath: string,
|
||||||
|
binName: string,
|
||||||
|
packageJsonRaw: string
|
||||||
|
): string | null {
|
||||||
|
const packageJson = JSON.parse(packageJsonRaw) as { bin?: string | Record<string, string> };
|
||||||
|
const bin = typeof packageJson.bin === 'string' ? packageJson.bin : packageJson.bin?.[binName];
|
||||||
|
if (!bin) return null;
|
||||||
|
return path.resolve(path.dirname(packageJsonPath), bin);
|
||||||
|
}
|
||||||
|
|
||||||
async function pathExists(targetPath: string): Promise<boolean> {
|
async function pathExists(targetPath: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await fs.promises.access(targetPath, fs.constants.F_OK);
|
await fs.promises.access(targetPath, fs.constants.F_OK);
|
||||||
|
|
@ -91,6 +102,40 @@ async function pathExists(targetPath: string): Promise<boolean> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveWorkspaceTsxCli(checked: string[]): Promise<string | null> {
|
||||||
|
for (const packageJsonPath of getWorkspaceTsxPackageJsonCandidates()) {
|
||||||
|
checked.push(packageJsonPath);
|
||||||
|
if (!(await pathExists(packageJsonPath))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tsxCli = resolvePackageBin(
|
||||||
|
packageJsonPath,
|
||||||
|
'tsx',
|
||||||
|
await fs.promises.readFile(packageJsonPath, 'utf8')
|
||||||
|
);
|
||||||
|
if (!tsxCli) {
|
||||||
|
logger.warn(`tsx package has no bin.tsx entry at ${packageJsonPath}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
checked.push(tsxCli);
|
||||||
|
if (await pathExists(tsxCli)) {
|
||||||
|
return tsxCli;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
`Failed to resolve tsx CLI from ${packageJsonPath}: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function shouldRetryMcpConfigRemoval(error: NodeJS.ErrnoException): boolean {
|
function shouldRetryMcpConfigRemoval(error: NodeJS.ErrnoException): boolean {
|
||||||
return error.code === 'EPERM' || error.code === 'EBUSY';
|
return error.code === 'EPERM' || error.code === 'EBUSY';
|
||||||
}
|
}
|
||||||
|
|
@ -236,14 +281,12 @@ export async function resolveAgentTeamsMcpLaunchSpec(): Promise<McpLaunchSpec> {
|
||||||
const sourceEntry = getSourceServerEntry();
|
const sourceEntry = getSourceServerEntry();
|
||||||
checked.push(sourceEntry);
|
checked.push(sourceEntry);
|
||||||
if (await pathExists(sourceEntry)) {
|
if (await pathExists(sourceEntry)) {
|
||||||
for (const tsxBin of getWorkspaceTsxBinCandidates()) {
|
const tsxCli = await resolveWorkspaceTsxCli(checked);
|
||||||
checked.push(tsxBin);
|
if (tsxCli) {
|
||||||
if (await pathExists(tsxBin)) {
|
return {
|
||||||
return {
|
command: await resolveNodePath(),
|
||||||
command: tsxBin,
|
args: [tsxCli, sourceEntry],
|
||||||
args: [sourceEntry],
|
};
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,10 @@ describe('TeamMcpConfigBuilder', () => {
|
||||||
): { command?: string; args?: string[]; env?: Record<string, string> } | undefined {
|
): { command?: string; args?: string[]; env?: Record<string, string> } | undefined {
|
||||||
const raw = fs.readFileSync(configPath, 'utf8');
|
const raw = fs.readFileSync(configPath, 'utf8');
|
||||||
const parsed = JSON.parse(raw) as {
|
const parsed = JSON.parse(raw) as {
|
||||||
mcpServers?: Record<string, { command?: string; args?: string[]; env?: Record<string, string> }>;
|
mcpServers?: Record<
|
||||||
|
string,
|
||||||
|
{ command?: string; args?: string[]; env?: Record<string, string> }
|
||||||
|
>;
|
||||||
};
|
};
|
||||||
return parsed.mcpServers?.['agent-teams'];
|
return parsed.mcpServers?.['agent-teams'];
|
||||||
}
|
}
|
||||||
|
|
@ -93,12 +96,13 @@ describe('TeamMcpConfigBuilder', () => {
|
||||||
expect(server?.command).toMatch(/(^node$|[\\/]node(?:\.exe)?$)/);
|
expect(server?.command).toMatch(/(^node$|[\\/]node(?:\.exe)?$)/);
|
||||||
}
|
}
|
||||||
|
|
||||||
function expectTsxEntry(
|
function expectNodeTsxSourceEntry(
|
||||||
server: { command?: string; args?: string[] } | undefined,
|
server: { command?: string; args?: string[] } | undefined,
|
||||||
entry: string
|
tsxCli: string,
|
||||||
|
sourceEntry: string
|
||||||
): void {
|
): void {
|
||||||
expect(server?.args).toEqual([entry]);
|
expect(server?.args).toEqual([tsxCli, sourceEntry]);
|
||||||
expect(server?.command).toMatch(/[\\/]tsx(?:\.cmd)?$/);
|
expect(server?.command).toMatch(/(^node$|[\\/]node(?:\.exe)?$)/);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBuiltWorkspaceEntry(): string {
|
function getBuiltWorkspaceEntry(): string {
|
||||||
|
|
@ -109,8 +113,12 @@ describe('TeamMcpConfigBuilder', () => {
|
||||||
return path.join(process.cwd(), 'mcp-server', 'src', 'index.ts');
|
return path.join(process.cwd(), 'mcp-server', 'src', 'index.ts');
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWorkspaceTsxBin(): string {
|
function getWorkspaceTsxPackageJson(): string {
|
||||||
return path.join(process.cwd(), 'mcp-server', 'node_modules', '.bin', 'tsx');
|
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 {
|
function mockPathExists(existingPaths: string[], options: { strict?: boolean } = {}): void {
|
||||||
|
|
@ -138,14 +146,16 @@ describe('TeamMcpConfigBuilder', () => {
|
||||||
|
|
||||||
function mockSourceWorkspaceEntryAvailable(): {
|
function mockSourceWorkspaceEntryAvailable(): {
|
||||||
sourceEntry: string;
|
sourceEntry: string;
|
||||||
tsxBin: string;
|
tsxPackageJson: string;
|
||||||
|
tsxCli: string;
|
||||||
builtEntry: string;
|
builtEntry: string;
|
||||||
} {
|
} {
|
||||||
const sourceEntry = getSourceWorkspaceEntry();
|
const sourceEntry = getSourceWorkspaceEntry();
|
||||||
const tsxBin = getWorkspaceTsxBin();
|
const tsxPackageJson = getWorkspaceTsxPackageJson();
|
||||||
|
const tsxCli = getWorkspaceTsxCli();
|
||||||
const builtEntry = getBuiltWorkspaceEntry();
|
const builtEntry = getBuiltWorkspaceEntry();
|
||||||
mockPathExists([sourceEntry, tsxBin, builtEntry], { strict: true });
|
mockPathExists([sourceEntry, tsxPackageJson, tsxCli, builtEntry], { strict: true });
|
||||||
return { sourceEntry, tsxBin, builtEntry };
|
return { sourceEntry, tsxPackageJson, tsxCli, builtEntry };
|
||||||
}
|
}
|
||||||
|
|
||||||
function mockBuiltWorkspaceEntryAvailable(): string {
|
function mockBuiltWorkspaceEntryAvailable(): string {
|
||||||
|
|
@ -225,7 +235,7 @@ describe('TeamMcpConfigBuilder', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prefers the source workspace MCP entry in dev mode when available', async () => {
|
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 builder = new TeamMcpConfigBuilder();
|
||||||
|
|
||||||
const configPath = await builder.writeConfigFile();
|
const configPath = await builder.writeConfigFile();
|
||||||
|
|
@ -237,7 +247,7 @@ describe('TeamMcpConfigBuilder', () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const server = parsed.mcpServers?.['agent-teams'];
|
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 () => {
|
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 () => {
|
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-'));
|
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-'));
|
||||||
createdDirs.push(homeDir);
|
createdDirs.push(homeDir);
|
||||||
mockHomeDir = homeDir;
|
mockHomeDir = homeDir;
|
||||||
|
|
@ -381,7 +391,7 @@ describe('TeamMcpConfigBuilder', () => {
|
||||||
mcpServers: Record<string, { command?: string; args?: string[] }>;
|
mcpServers: Record<string, { command?: string; args?: string[] }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
expectTsxEntry(parsed.mcpServers['agent-teams'], sourceEntry);
|
expectNodeTsxSourceEntry(parsed.mcpServers['agent-teams'], tsxCli, sourceEntry);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('passes the configured Claude root to the MCP server', async () => {
|
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 () => {
|
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');
|
setPackagedMode(true, '6.0.0');
|
||||||
const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-'));
|
const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-'));
|
||||||
createdDirs.push(resourcesDir);
|
createdDirs.push(resourcesDir);
|
||||||
|
|
@ -636,6 +646,6 @@ describe('TeamMcpConfigBuilder', () => {
|
||||||
const configPath = await builder.writeConfigFile();
|
const configPath = await builder.writeConfigFile();
|
||||||
createdPaths.push(configPath);
|
createdPaths.push(configPath);
|
||||||
|
|
||||||
expectTsxEntry(readGeneratedServer(configPath), sourceEntry);
|
expectNodeTsxSourceEntry(readGeneratedServer(configPath), tsxCli, sourceEntry);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue