diff --git a/mcp-server/package.json b/mcp-server/package.json index 65912e27..6a16ddb1 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -29,6 +29,7 @@ "dev": "tsx src/index.ts", "lint": "eslint \"src/**/*.ts\"", "test": "vitest run", + "test:e2e": "pnpm build && vitest run test/stdio.e2e.test.ts", "test:watch": "vitest", "typecheck": "tsc --noEmit", "typecheck:test": "tsc --noEmit -p tsconfig.test.json", diff --git a/mcp-server/src/controller.ts b/mcp-server/src/controller.ts index 1ab4b07e..26dce9b6 100644 --- a/mcp-server/src/controller.ts +++ b/mcp-server/src/controller.ts @@ -1,6 +1,12 @@ import * as agentTeamsControllerModule from 'agent-teams-controller'; -const { createController } = agentTeamsControllerModule; +type ControllerModule = typeof import('agent-teams-controller') & { + default?: typeof import('agent-teams-controller'); +}; + +const controllerModule = + (agentTeamsControllerModule as ControllerModule).default ?? agentTeamsControllerModule; +const { createController } = controllerModule; export function getController(teamName: string, claudeDir?: string) { return createController({ diff --git a/mcp-server/test/stdio.e2e.test.ts b/mcp-server/test/stdio.e2e.test.ts new file mode 100644 index 00000000..a988a07d --- /dev/null +++ b/mcp-server/test/stdio.e2e.test.ts @@ -0,0 +1,154 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +function parseJsonToolResult(result: unknown) { + const text = (result as { content?: Array<{ text?: string }> }).content?.[0]?.text; + return JSON.parse(text ?? 'null'); +} + +class McpStdIoClient { + private readonly child: ChildProcessWithoutNullStreams; + private stdoutBuffer = ''; + + constructor(serverPath: string, cwd: string) { + this.child = spawn('node', [serverPath], { + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + this.child.stdout.setEncoding('utf8'); + this.child.stdout.on('data', (chunk: string) => { + this.stdoutBuffer += chunk; + }); + } + + async initialize() { + const response = await this.request(1, 'initialize', { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'vitest-e2e', version: '1.0.0' }, + }); + + this.notify('notifications/initialized'); + return response; + } + + async listTools() { + return this.request(2, 'tools/list', {}); + } + + async callTool(name: string, args: Record, id = 3) { + return this.request(id, 'tools/call', { name, arguments: args }); + } + + async close() { + this.child.kill('SIGTERM'); + await new Promise((resolve) => { + this.child.once('exit', () => resolve()); + setTimeout(() => resolve(), 1000).unref(); + }); + } + + private notify(method: string, params?: Record) { + this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method, ...(params ? { params } : {}) })}\n`); + } + + private async request(id: number, method: string, params: Record) { + this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`); + return this.readMessage(id); + } + + private async readMessage(expectedId: number) { + const deadline = Date.now() + 5000; + + while (Date.now() < deadline) { + const newlineIndex = this.stdoutBuffer.indexOf('\n'); + if (newlineIndex !== -1) { + const line = this.stdoutBuffer.slice(0, newlineIndex).trim(); + this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1); + + if (!line) { + continue; + } + + const parsed = JSON.parse(line) as { id?: number }; + if (parsed.id === expectedId) { + return parsed; + } + } + + await new Promise((resolve) => setTimeout(resolve, 20)); + } + + throw new Error(`Timed out waiting for MCP response ${expectedId}`); + } +} + +describe('agent-teams-mcp stdio e2e', () => { + const serverPath = fileURLToPath(new URL('../dist/index.js', import.meta.url)); + const workspaceRoot = fileURLToPath(new URL('../..', import.meta.url)); + + let claudeDir: string; + + beforeEach(async () => { + claudeDir = await mkdtemp(path.join(os.tmpdir(), 'agent-teams-mcp-e2e-')); + }); + + afterEach(async () => { + await rm(claudeDir, { recursive: true, force: true }); + }); + + it('boots over stdio, lists task tools, and executes task lifecycle calls', async () => { + const client = new McpStdIoClient(serverPath, workspaceRoot); + + try { + const init = await client.initialize(); + expect(init).toHaveProperty('result'); + + const tools = (await client.listTools()) as { + result?: { tools?: Array<{ name: string }> }; + }; + const toolNames = (tools.result?.tools ?? []).map((tool) => tool.name); + + expect(toolNames).toContain('task_create'); + expect(toolNames).toContain('task_start'); + expect(toolNames).toContain('review_approve'); + + const createResult = await client.callTool( + 'task_create', + { + claudeDir, + teamName: 'e2e-team', + subject: 'Smoke task', + owner: 'alice', + }, + 3 + ); + const createdTask = parseJsonToolResult((createResult as { result: unknown }).result); + + expect(createdTask.subject).toBe('Smoke task'); + expect(createdTask.owner).toBe('alice'); + expect(typeof createdTask.id).toBe('string'); + + const startResult = await client.callTool( + 'task_start', + { + claudeDir, + teamName: 'e2e-team', + taskId: createdTask.id, + actor: 'alice', + }, + 4 + ); + const startedTask = parseJsonToolResult((startResult as { result: unknown }).result); + + expect(startedTask.status).toBe('in_progress'); + expect(startedTask.id).toBe(createdTask.id); + } finally { + await client.close(); + } + }); +}); diff --git a/mcp-server/tsup.config.ts b/mcp-server/tsup.config.ts index 5cd234bb..c1cf301d 100644 --- a/mcp-server/tsup.config.ts +++ b/mcp-server/tsup.config.ts @@ -8,7 +8,4 @@ export default defineConfig({ clean: true, sourcemap: true, dts: false, - banner: { - js: '#!/usr/bin/env node', - }, }); diff --git a/package.json b/package.json index 220e89e0..5f750461 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css}\"", "build:workspace": "pnpm build && pnpm --filter agent-teams-controller build && pnpm --filter agent-teams-mcp build", "test:workspace": "pnpm test && pnpm --filter agent-teams-controller test && pnpm --filter agent-teams-mcp test", - "check:workspace": "pnpm typecheck:workspace && pnpm test:workspace && pnpm build:workspace", + "check:workspace": "pnpm typecheck:workspace && pnpm test:workspace && pnpm build:workspace && pnpm --filter agent-teams-mcp test:e2e", "check": "pnpm check:workspace && pnpm lint && pnpm lint:mcp", "fix": "pnpm lint:fix && pnpm format", "quality": "pnpm check && pnpm format:check && npx knip", diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 3aff867c..0c6dbbf6 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -475,7 +475,10 @@ async function handleDeleteTeam( if (!validated.valid) { return { success: false, error: validated.error ?? 'Invalid teamName' }; } - return wrapTeamHandler('deleteTeam', () => getTeamDataService().deleteTeam(validated.value!)); + return wrapTeamHandler('deleteTeam', async () => { + getTeamProvisioningService().stopTeam(validated.value!); + await getTeamDataService().deleteTeam(validated.value!); + }); } async function handleRestoreTeam( diff --git a/src/main/services/team/TeamMcpConfigBuilder.ts b/src/main/services/team/TeamMcpConfigBuilder.ts index f7217729..6c3d568c 100644 --- a/src/main/services/team/TeamMcpConfigBuilder.ts +++ b/src/main/services/team/TeamMcpConfigBuilder.ts @@ -1,3 +1,4 @@ +import { execFile } from 'child_process'; import { randomUUID } from 'crypto'; import * as fs from 'fs'; import * as os from 'os'; @@ -37,11 +38,44 @@ async function pathExists(targetPath: string): Promise { } } +let _resolvedNodePath: string | undefined; + +/** + * Find the real `node` binary path. In Electron, process.execPath is the + * Electron binary — NOT node — so we must resolve node separately. + * Uses async execFile('node', ...) which is cross-platform (no /usr/bin/env dependency). + */ +async function resolveNodePath(): Promise { + if (_resolvedNodePath) return _resolvedNodePath; + + try { + const resolved = await new Promise((resolve, reject) => { + execFile( + 'node', + ['-e', 'process.stdout.write(process.execPath)'], + { + encoding: 'utf-8', + timeout: 5000, + }, + (err, stdout) => (err ? reject(err) : resolve(stdout.trim())) + ); + }); + if (resolved) { + _resolvedNodePath = resolved; + return _resolvedNodePath; + } + } catch { + // node not found or timed out — use bare 'node' and let the OS resolve it + } + _resolvedNodePath = 'node'; + return _resolvedNodePath; +} + async function resolveMcpLaunchSpec(): Promise { const builtEntry = getBuiltServerEntry(); if (await pathExists(builtEntry)) { return { - command: process.execPath, + command: await resolveNodePath(), args: [builtEntry], }; } diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 9c30dc4e..a32e0cb6 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -332,7 +332,7 @@ export const TaskDetailDialog = ({
- #{currentTask.id} + {formatTaskDisplayLabel(currentTask)} - #{currentTask.id} {currentTask.subject.slice(0, 36)} + {formatTaskDisplayLabel(currentTask)} {currentTask.subject.slice(0, 36)} {currentTask.subject.length > 36 ? '…' : ''} @@ -160,7 +165,7 @@ export const MemberCard = ({ isRemoved ? 'This member has been removed' : member.currentTaskId - ? `Current task: ${member.currentTaskId}` + ? `Current task: #${deriveTaskDisplayId(member.currentTaskId)}` : undefined } >