From 0a1831bc5e3069b37e0a35c94b651e36b61f4b35 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 18 Apr 2026 15:00:47 +0300 Subject: [PATCH] fix(team): validate agent-teams MCP via direct stdio preflight --- .../services/team/TeamProvisioningService.ts | 402 ++++++++++++++++-- .../TeamProvisioningServicePrepare.test.ts | 164 +++++++ 2 files changed, 534 insertions(+), 32 deletions(-) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 6e4bfb47..0e8d2ee8 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -2079,6 +2079,72 @@ function buildCombinedLogs(stdoutBuffer: string, stderrBuffer: string): string { return [`[stdout]`, stdoutTrimmed, '', `[stderr]`, stderrTrimmed].join('\n'); } +interface AgentTeamsMcpConfigEntry { + command?: unknown; + args?: unknown; + env?: unknown; + cwd?: unknown; +} + +interface AgentTeamsMcpConfigFile { + mcpServers?: Record; +} + +interface AgentTeamsMcpLaunchSpec { + command: string; + args: string[]; + cwd?: string; + env: Record; +} + +interface McpJsonRpcErrorPayload { + code?: number; + message?: string; +} + +interface McpJsonRpcResponse { + id?: number; + result?: TResult; + error?: McpJsonRpcErrorPayload; +} + +interface McpToolsListResult { + tools?: Array<{ + name?: string; + _meta?: Record; + }>; +} + +interface McpToolCallResult { + content?: Array<{ + type?: string; + text?: string; + }>; + isError?: boolean; +} + +interface AgentTeamsMcpValidationFixture { + claudeDir: string; + teamName: string; + memberName: string; +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((entry) => typeof entry === 'string'); +} + +function normalizeRecordStringValues(value: unknown): Record { + if (!value || typeof value !== 'object') { + return {}; + } + + return Object.fromEntries( + Object.entries(value).flatMap(([key, entry]) => + typeof entry === 'string' ? [[key, entry]] : [] + ) + ); +} + function extractLogsTail(stdoutBuffer: string, stderrBuffer: string): string | undefined { const trimmed = buildCombinedLogs(stdoutBuffer, stderrBuffer).trim(); if (trimmed.length === 0) { @@ -12428,50 +12494,322 @@ export class TeamProvisioningService { private buildAgentTeamsMcpValidationError(output: string): string { const detail = this.normalizeApiRetryErrorMessage(output) || output.trim(); if (!detail) { - return ( - 'agent-teams MCP loaded config but did not expose member_briefing. ' + - 'The leader would start without required team MCP tools.' + return 'agent-teams MCP preflight failed before team launch.'; + } + return `agent-teams MCP preflight failed before team launch. Details: ${detail}`; + } + + private async readAgentTeamsMcpLaunchSpec( + mcpConfigPath: string + ): Promise { + let parsed: AgentTeamsMcpConfigFile; + try { + const raw = await fs.promises.readFile(mcpConfigPath, 'utf8'); + parsed = JSON.parse(raw) as AgentTeamsMcpConfigFile; + } catch (error) { + throw new Error( + this.buildAgentTeamsMcpValidationError( + `Failed to read generated MCP config ${mcpConfigPath}: ${ + error instanceof Error ? error.message : String(error) + }` + ) ); } - return ( - 'agent-teams MCP loaded config but did not expose member_briefing. ' + `Details: ${detail}` + + const server = parsed.mcpServers?.['agent-teams']; + if (!server) { + throw new Error( + this.buildAgentTeamsMcpValidationError( + `Generated MCP config ${mcpConfigPath} does not contain an "agent-teams" server entry.` + ) + ); + } + + if (typeof server.command !== 'string' || server.command.trim().length === 0) { + throw new Error( + this.buildAgentTeamsMcpValidationError( + 'Generated agent-teams MCP config is missing a valid launch command.' + ) + ); + } + + if (server.args !== undefined && !isStringArray(server.args)) { + throw new Error( + this.buildAgentTeamsMcpValidationError( + 'Generated agent-teams MCP config has invalid args; expected a string array.' + ) + ); + } + + if (server.cwd !== undefined && typeof server.cwd !== 'string') { + throw new Error( + this.buildAgentTeamsMcpValidationError( + 'Generated agent-teams MCP config has invalid cwd; expected a string path.' + ) + ); + } + + return { + command: server.command, + args: server.args ?? [], + cwd: typeof server.cwd === 'string' ? server.cwd : undefined, + env: normalizeRecordStringValues(server.env), + }; + } + + private async createAgentTeamsMcpValidationFixture( + projectPath: string + ): Promise { + const claudeDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'agent-teams-mcp-validate-') ); + const teamName = 'mcp-validation-team'; + const memberName = 'mcp-validation-member'; + const teamDir = path.join(claudeDir, 'teams', teamName); + + await fs.promises.mkdir(teamDir, { recursive: true }); + await fs.promises.writeFile( + path.join(teamDir, 'config.json'), + JSON.stringify( + { + name: teamName, + projectPath, + members: [ + { name: 'team-lead', agentType: 'team-lead', role: 'lead' }, + { name: memberName, agentType: 'teammate', role: 'developer' }, + ], + }, + null, + 2 + ), + 'utf8' + ); + + return { + claudeDir, + teamName, + memberName, + }; } private async validateAgentTeamsMcpRuntime( - claudePath: string, + _claudePath: string, cwd: string, env: NodeJS.ProcessEnv, mcpConfigPath: string ): Promise { - const result = await this.spawnProbe( - claudePath, - [ - '--setting-sources', - 'user,project,local', - '--mcp-config', - mcpConfigPath, - '--', - 'mcp', - 'get', - 'agent-teams', - ], - cwd, - env, - VERIFY_TIMEOUT_MS - ); + const launchSpec = await this.readAgentTeamsMcpLaunchSpec(mcpConfigPath); + const fixture = await this.createAgentTeamsMcpValidationFixture(cwd); + let child: ReturnType | null = null; + let stdoutBuffer = ''; + let stderrBuffer = ''; + let nextRequestId = 1; + const pending = new Map< + number, + { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timeoutHandle: ReturnType; + } + >(); - const combinedOutput = buildCombinedLogs(result.stdout, result.stderr).trim(); - if (result.exitCode !== 0) { - throw new Error(this.buildAgentTeamsMcpValidationError(combinedOutput)); - } + const rejectAll = (error: Error): void => { + for (const [id, entry] of pending) { + clearTimeout(entry.timeoutHandle); + entry.reject(error); + pending.delete(id); + } + }; - const normalizedOutput = combinedOutput.toLowerCase(); - if ( - !normalizedOutput.includes('status: ✓ connected') && - !normalizedOutput.includes('status: connected') - ) { - throw new Error(this.buildAgentTeamsMcpValidationError(combinedOutput)); + try { + child = spawnCli(launchSpec.command, launchSpec.args, { + cwd: launchSpec.cwd ?? cwd, + env: { ...env, ...launchSpec.env }, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + + const parseStdoutLine = (line: string): void => { + let message: McpJsonRpcResponse; + try { + message = JSON.parse(line) as McpJsonRpcResponse; + } catch (error) { + logger.warn( + `agent-teams MCP preflight emitted non-JSON stdout line: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return; + } + + if (typeof message.id !== 'number') { + return; + } + + const entry = pending.get(message.id); + if (!entry) { + return; + } + + clearTimeout(entry.timeoutHandle); + pending.delete(message.id); + + if (message.error) { + entry.reject(new Error(message.error.message ?? 'Unknown MCP JSON-RPC error')); + return; + } + + entry.resolve(message.result); + }; + + child.stdout?.setEncoding('utf8'); + child.stdout?.on('data', (chunk: string | Buffer) => { + stdoutBuffer += chunk.toString(); + + while (true) { + const newlineIndex = stdoutBuffer.indexOf('\n'); + if (newlineIndex === -1) { + break; + } + + const line = stdoutBuffer.slice(0, newlineIndex).trim(); + stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1); + if (!line) { + continue; + } + parseStdoutLine(line); + } + }); + + child.stderr?.setEncoding('utf8'); + child.stderr?.on('data', (chunk: string | Buffer) => { + stderrBuffer += chunk.toString(); + }); + + child.once('error', (error) => { + rejectAll(error instanceof Error ? error : new Error(String(error))); + }); + + child.once('close', (code, signal) => { + if (pending.size === 0) { + return; + } + rejectAll( + new Error( + `agent-teams MCP process exited unexpectedly during preflight (code=${ + code ?? 'null' + } signal=${signal ?? 'null'})` + ) + ); + }); + + const request = ( + method: string, + params: Record, + timeoutMs: number = VERIFY_TIMEOUT_MS + ): Promise => + new Promise((resolve, reject) => { + if (!child?.stdin) { + reject(new Error('agent-teams MCP stdin is not available')); + return; + } + + const id = nextRequestId++; + const timeoutHandle = setTimeout(() => { + pending.delete(id); + reject(new Error(`agent-teams MCP request timed out: ${method}`)); + }, timeoutMs); + + pending.set(id, { + resolve: resolve as (value: unknown) => void, + reject, + timeoutHandle, + }); + + child.stdin.write( + `${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`, + (error) => { + if (!error) { + return; + } + clearTimeout(timeoutHandle); + pending.delete(id); + reject(error instanceof Error ? error : new Error(String(error))); + } + ); + }); + + const notify = async (method: string, params?: Record): Promise => { + if (!child?.stdin) { + throw new Error('agent-teams MCP stdin is not available'); + } + const stdin = child.stdin; + + await new Promise((resolve, reject) => { + stdin.write( + `${JSON.stringify({ jsonrpc: '2.0', method, ...(params ? { params } : {}) })}\n`, + (error) => { + if (error) { + reject(error instanceof Error ? error : new Error(String(error))); + return; + } + resolve(); + } + ); + }); + }; + + await request('initialize', { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'claude-agent-teams-ui', version: '1.0.0' }, + }); + await notify('notifications/initialized'); + + const toolsList = await request('tools/list', {}); + const memberBriefingTool = (toolsList.tools ?? []).find( + (tool) => tool.name === 'member_briefing' + ); + if (!memberBriefingTool) { + throw new Error('agent-teams MCP started but tools/list did not include member_briefing'); + } + + const memberBriefing = await request('tools/call', { + name: 'member_briefing', + arguments: { + claudeDir: fixture.claudeDir, + teamName: fixture.teamName, + memberName: fixture.memberName, + }, + }); + + if (memberBriefing.isError) { + throw new Error( + memberBriefing.content?.[0]?.text ?? + 'agent-teams MCP returned an unspecified error for member_briefing' + ); + } + + const briefingText = memberBriefing.content?.find((item) => item.type === 'text')?.text ?? ''; + if (briefingText.trim().length === 0) { + throw new Error('agent-teams MCP returned empty content for member_briefing'); + } + } catch (error) { + const detail = buildCombinedLogs('', stderrBuffer).trim(); + const errorText = + error instanceof Error && detail.length > 0 + ? `${error.message}\n${detail}` + : detail || String(error); + throw new Error(this.buildAgentTeamsMcpValidationError(errorText)); + } finally { + rejectAll(new Error('agent-teams MCP preflight session closed')); + if (child?.stdin && !child.stdin.destroyed) { + child.stdin.end(); + } + if (child) { + killProcessTree(child); + } + await fs.promises.rm(fixture.claudeDir, { recursive: true, force: true }).catch(() => {}); } } diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index 6d641ce9..64e668fb 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -32,6 +32,118 @@ import { TeamProvisioningService } from '@main/services/team/TeamProvisioningSer import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; +function getRealAgentTeamsMcpLaunchSpec(): { command: string; args: string[] } { + const workspaceRoot = process.cwd(); + const distEntry = path.join(workspaceRoot, 'mcp-server', 'dist', 'index.js'); + if (fs.existsSync(distEntry)) { + return { + command: process.execPath, + args: [distEntry], + }; + } + + return { + command: path.join( + workspaceRoot, + 'node_modules', + '.bin', + process.platform === 'win32' ? 'tsx.cmd' : 'tsx' + ), + args: [path.join(workspaceRoot, 'mcp-server', 'src', 'index.ts')], + }; +} + +function writeMcpConfig( + targetDir: string, + serverConfig: Record +): string { + const configPath = path.join(targetDir, `agent-teams-mcp-${Date.now()}.json`); + fs.writeFileSync( + configPath, + JSON.stringify( + { + mcpServers: serverConfig, + }, + null, + 2 + ), + 'utf8' + ); + return configPath; +} + +function writeMockMcpServer( + targetDir: string, + variant: 'missing-member-briefing' | 'member-briefing-error' +): string { + const scriptPath = path.join(targetDir, `mock-mcp-${variant}.js`); + const tools = + variant === 'missing-member-briefing' + ? [{ name: 'task_create' }] + : [{ name: 'member_briefing' }]; + const toolCallResult = + variant === 'member-briefing-error' + ? { + content: [{ type: 'text', text: 'mock member_briefing failure' }], + isError: true, + } + : { + content: [{ type: 'text', text: 'ok' }], + isError: false, + }; + + fs.writeFileSync( + scriptPath, + `'use strict'; +let buffer = ''; +function send(message) { + process.stdout.write(JSON.stringify(message) + '\\n'); +} +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + buffer += chunk; + while (true) { + const newlineIndex = buffer.indexOf('\\n'); + if (newlineIndex === -1) break; + const line = buffer.slice(0, newlineIndex).trim(); + buffer = buffer.slice(newlineIndex + 1); + if (!line) continue; + const message = JSON.parse(line); + if (message.method === 'initialize') { + send({ + jsonrpc: '2.0', + id: message.id, + result: { + serverInfo: { name: 'mock-agent-teams-mcp', version: '1.0.0' }, + capabilities: {}, + }, + }); + continue; + } + if (message.method === 'tools/list') { + send({ + jsonrpc: '2.0', + id: message.id, + result: { tools: ${JSON.stringify(tools)} }, + }); + continue; + } + if (message.method === 'tools/call') { + send({ + jsonrpc: '2.0', + id: message.id, + result: ${JSON.stringify(toolCallResult)}, + }); + } + } +}); +`, + 'utf8' + ); + + return scriptPath; +} + describe('TeamProvisioningService prepare/auth behavior', () => { let tempRoot = ''; @@ -626,4 +738,56 @@ describe('TeamProvisioningService prepare/auth behavior', () => { }) ); }); + + it('validates the generated agent-teams MCP server directly over stdio', async () => { + const svc = new TeamProvisioningService(); + const configPath = writeMcpConfig(tempRoot, { + 'agent-teams': getRealAgentTeamsMcpLaunchSpec(), + }); + + await expect( + (svc as any).validateAgentTeamsMcpRuntime('/fake/claude', tempRoot, process.env, configPath) + ).resolves.toBeUndefined(); + }); + + it('fails validation when the generated MCP config has no agent-teams entry', async () => { + const svc = new TeamProvisioningService(); + const configPath = writeMcpConfig(tempRoot, { + unrelated: getRealAgentTeamsMcpLaunchSpec(), + }); + + await expect( + (svc as any).validateAgentTeamsMcpRuntime('/fake/claude', tempRoot, process.env, configPath) + ).rejects.toThrow('does not contain an "agent-teams" server entry'); + }); + + it('fails validation when tools/list does not include member_briefing', async () => { + const svc = new TeamProvisioningService(); + const mockServerPath = writeMockMcpServer(tempRoot, 'missing-member-briefing'); + const configPath = writeMcpConfig(tempRoot, { + 'agent-teams': { + command: process.execPath, + args: [mockServerPath], + }, + }); + + await expect( + (svc as any).validateAgentTeamsMcpRuntime('/fake/claude', tempRoot, process.env, configPath) + ).rejects.toThrow('tools/list did not include member_briefing'); + }); + + it('fails validation when member_briefing itself returns an MCP error', async () => { + const svc = new TeamProvisioningService(); + const mockServerPath = writeMockMcpServer(tempRoot, 'member-briefing-error'); + const configPath = writeMcpConfig(tempRoot, { + 'agent-teams': { + command: process.execPath, + args: [mockServerPath], + }, + }); + + await expect( + (svc as any).validateAgentTeamsMcpRuntime('/fake/claude', tempRoot, process.env, configPath) + ).rejects.toThrow('mock member_briefing failure'); + }); });