From 822bbac23c4bb3ae96229dc424e1730ac7003087 Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 30 Mar 2026 17:58:17 +0300 Subject: [PATCH] feat(agent-teams): integrate MCP tool catalog and enhance tool registration - Added mcpToolCatalog to the agent-teams-controller, exporting new types and constants for MCP tool groups and names. - Updated tools registration to utilize AGENT_TEAMS_MCP_TOOL_GROUPS for streamlined tool management. - Enhanced tests to validate the new operational permissions and ensure correct tool registration behavior. --- agent-teams-controller/src/index.js | 2 + agent-teams-controller/src/mcpToolCatalog.js | 115 +++++++++++++ mcp-server/src/agent-teams-controller.d.ts | 27 +++ mcp-server/src/tools/index.ts | 29 +++- mcp-server/test/tools.test.ts | 43 +---- .../services/team/TeamMcpConfigBuilder.ts | 73 +------- .../services/team/TeamProvisioningService.ts | 105 ++++++++---- .../team/dialogs/CreateTeamDialog.tsx | 4 +- src/shared/utils/toolSummary.ts | 10 +- src/types/agent-teams-controller.d.ts | 26 +++ .../team/TeamMcpConfigBuilder.test.ts | 20 +-- .../team/TeamProvisioningService.test.ts | 157 ++++++++++++++++++ 12 files changed, 442 insertions(+), 169 deletions(-) create mode 100644 agent-teams-controller/src/mcpToolCatalog.js diff --git a/agent-teams-controller/src/index.js b/agent-teams-controller/src/index.js index 464aade2..50bb54f8 100644 --- a/agent-teams-controller/src/index.js +++ b/agent-teams-controller/src/index.js @@ -1,5 +1,7 @@ const controller = require('./controller.js'); +const mcpToolCatalog = require('./mcpToolCatalog.js'); module.exports = { ...controller, + ...mcpToolCatalog, }; diff --git a/agent-teams-controller/src/mcpToolCatalog.js b/agent-teams-controller/src/mcpToolCatalog.js new file mode 100644 index 00000000..3146bad4 --- /dev/null +++ b/agent-teams-controller/src/mcpToolCatalog.js @@ -0,0 +1,115 @@ +const AGENT_TEAMS_TASK_TOOL_NAMES = [ + 'member_briefing', + 'task_add_comment', + 'task_attach_comment_file', + 'task_attach_file', + 'task_briefing', + 'task_complete', + 'task_create', + 'task_create_from_message', + 'task_get', + 'task_get_comment', + 'task_link', + 'task_list', + 'task_set_clarification', + 'task_set_owner', + 'task_set_status', + 'task_start', + 'task_unlink', +]; + +const AGENT_TEAMS_REVIEW_TOOL_NAMES = [ + 'review_approve', + 'review_request', + 'review_request_changes', + 'review_start', +]; + +const AGENT_TEAMS_MESSAGE_TOOL_NAMES = ['message_send']; + +const AGENT_TEAMS_CROSS_TEAM_TOOL_NAMES = [ + 'cross_team_get_outbox', + 'cross_team_list_targets', + 'cross_team_send', +]; + +const AGENT_TEAMS_PROCESS_TOOL_NAMES = [ + 'process_list', + 'process_register', + 'process_stop', + 'process_unregister', +]; + +const AGENT_TEAMS_KANBAN_TOOL_NAMES = [ + 'kanban_add_reviewer', + 'kanban_clear', + 'kanban_get', + 'kanban_list_reviewers', + 'kanban_remove_reviewer', + 'kanban_set_column', +]; + +const AGENT_TEAMS_RUNTIME_TOOL_NAMES = ['team_launch', 'team_stop']; + +const AGENT_TEAMS_MCP_TOOL_GROUPS = [ + { + id: 'task', + teammateOperational: true, + toolNames: AGENT_TEAMS_TASK_TOOL_NAMES, + }, + { + id: 'kanban', + teammateOperational: false, + toolNames: AGENT_TEAMS_KANBAN_TOOL_NAMES, + }, + { + id: 'review', + teammateOperational: true, + toolNames: AGENT_TEAMS_REVIEW_TOOL_NAMES, + }, + { + id: 'message', + teammateOperational: true, + toolNames: AGENT_TEAMS_MESSAGE_TOOL_NAMES, + }, + { + id: 'process', + teammateOperational: true, + toolNames: AGENT_TEAMS_PROCESS_TOOL_NAMES, + }, + { + id: 'runtime', + teammateOperational: false, + toolNames: AGENT_TEAMS_RUNTIME_TOOL_NAMES, + }, + { + id: 'crossTeam', + teammateOperational: true, + toolNames: AGENT_TEAMS_CROSS_TEAM_TOOL_NAMES, + }, +]; + +const AGENT_TEAMS_REGISTERED_TOOL_NAMES = AGENT_TEAMS_MCP_TOOL_GROUPS.flatMap((group) => [ + ...group.toolNames, +]); + +const AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES = AGENT_TEAMS_MCP_TOOL_GROUPS.filter( + (group) => group.teammateOperational +).flatMap((group) => [...group.toolNames]); + +const AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES = + AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES.map((toolName) => `mcp__agent-teams__${toolName}`); + +module.exports = { + AGENT_TEAMS_TASK_TOOL_NAMES, + AGENT_TEAMS_REVIEW_TOOL_NAMES, + AGENT_TEAMS_MESSAGE_TOOL_NAMES, + AGENT_TEAMS_CROSS_TEAM_TOOL_NAMES, + AGENT_TEAMS_PROCESS_TOOL_NAMES, + AGENT_TEAMS_KANBAN_TOOL_NAMES, + AGENT_TEAMS_RUNTIME_TOOL_NAMES, + AGENT_TEAMS_MCP_TOOL_GROUPS, + AGENT_TEAMS_REGISTERED_TOOL_NAMES, + AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES, + AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES, +}; diff --git a/mcp-server/src/agent-teams-controller.d.ts b/mcp-server/src/agent-teams-controller.d.ts index 2e66083b..994ba2d3 100644 --- a/mcp-server/src/agent-teams-controller.d.ts +++ b/mcp-server/src/agent-teams-controller.d.ts @@ -108,4 +108,31 @@ declare module 'agent-teams-controller' { } export const protocols: ProtocolsApi; + + export type AgentTeamsMcpToolGroupId = + | 'task' + | 'kanban' + | 'review' + | 'message' + | 'process' + | 'runtime' + | 'crossTeam'; + + export interface AgentTeamsMcpToolGroup { + id: AgentTeamsMcpToolGroupId; + teammateOperational: boolean; + toolNames: readonly string[]; + } + + export const AGENT_TEAMS_TASK_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_REVIEW_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_MESSAGE_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_CROSS_TEAM_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_PROCESS_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_KANBAN_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_RUNTIME_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_MCP_TOOL_GROUPS: readonly AgentTeamsMcpToolGroup[]; + export const AGENT_TEAMS_REGISTERED_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES: readonly string[]; } diff --git a/mcp-server/src/tools/index.ts b/mcp-server/src/tools/index.ts index ec5b94eb..9d3408a1 100644 --- a/mcp-server/src/tools/index.ts +++ b/mcp-server/src/tools/index.ts @@ -1,5 +1,7 @@ import type { FastMCP } from 'fastmcp'; +import { AGENT_TEAMS_MCP_TOOL_GROUPS, AGENT_TEAMS_REGISTERED_TOOL_NAMES } from 'agent-teams-controller'; + import { registerCrossTeamTools } from './crossTeamTools'; import { registerKanbanTools } from './kanbanTools'; import { registerMessageTools } from './messageTools'; @@ -8,12 +10,25 @@ import { registerReviewTools } from './reviewTools'; import { registerRuntimeTools } from './runtimeTools'; import { registerTaskTools } from './taskTools'; +const REGISTRATION_BY_GROUP = { + task: registerTaskTools, + kanban: registerKanbanTools, + review: registerReviewTools, + message: registerMessageTools, + process: registerProcessTools, + runtime: registerRuntimeTools, + crossTeam: registerCrossTeamTools, +} as const; + +export const AGENT_TEAMS_MCP_REGISTRATION_GROUPS = AGENT_TEAMS_MCP_TOOL_GROUPS.map((group) => ({ + ...group, + register: REGISTRATION_BY_GROUP[group.id as keyof typeof REGISTRATION_BY_GROUP], +})); + +export { AGENT_TEAMS_REGISTERED_TOOL_NAMES }; + export function registerTools(server: FastMCP) { - registerTaskTools(server); - registerKanbanTools(server); - registerReviewTools(server); - registerMessageTools(server); - registerProcessTools(server); - registerRuntimeTools(server); - registerCrossTeamTools(server); + for (const group of AGENT_TEAMS_MCP_REGISTRATION_GROUPS) { + group.register(server); + } } diff --git a/mcp-server/test/tools.test.ts b/mcp-server/test/tools.test.ts index ac3f41e5..d014c7c3 100644 --- a/mcp-server/test/tools.test.ts +++ b/mcp-server/test/tools.test.ts @@ -3,7 +3,7 @@ import http from 'http'; import os from 'os'; import path from 'path'; -import { registerTools } from '../src/tools'; +import { AGENT_TEAMS_REGISTERED_TOOL_NAMES, registerTools } from '../src/tools'; type RegisteredTool = { name: string; @@ -30,45 +30,6 @@ function parseJsonToolResult(result: unknown) { describe('agent-teams-mcp tools', () => { const tools = collectTools(); - const expectedToolNames = [ - 'cross_team_get_outbox', - 'cross_team_list_targets', - 'cross_team_send', - 'kanban_add_reviewer', - 'kanban_clear', - 'kanban_get', - 'kanban_list_reviewers', - 'kanban_remove_reviewer', - 'kanban_set_column', - 'member_briefing', - 'message_send', - 'process_list', - 'process_register', - 'process_stop', - 'process_unregister', - 'review_approve', - 'review_request', - 'review_request_changes', - 'review_start', - 'task_add_comment', - 'task_attach_comment_file', - 'task_attach_file', - 'task_briefing', - 'task_complete', - 'task_create', - 'task_create_from_message', - 'task_get', - 'task_get_comment', - 'task_link', - 'task_list', - 'task_set_clarification', - 'task_set_owner', - 'task_set_status', - 'task_start', - 'task_unlink', - 'team_launch', - 'team_stop', - ] as const; function getTool(name: string) { const tool = tools.get(name); @@ -147,7 +108,7 @@ describe('agent-teams-mcp tools', () => { } it('registers the full expected MCP tool surface', () => { - expect([...tools.keys()].sort()).toEqual([...expectedToolNames]); + expect([...tools.keys()].sort()).toEqual([...AGENT_TEAMS_REGISTERED_TOOL_NAMES].sort()); }); it('accepts explicit conversation threading fields for cross_team_send', () => { diff --git a/src/main/services/team/TeamMcpConfigBuilder.ts b/src/main/services/team/TeamMcpConfigBuilder.ts index 6987729d..2b96a824 100644 --- a/src/main/services/team/TeamMcpConfigBuilder.ts +++ b/src/main/services/team/TeamMcpConfigBuilder.ts @@ -1,4 +1,4 @@ -import { getHomeDir, getMcpConfigsBasePath, getMcpServerBasePath } from '@main/utils/pathDecoder'; +import { getMcpConfigsBasePath, getMcpServerBasePath } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; import { execFile } from 'child_process'; import { randomUUID } from 'crypto'; @@ -14,8 +14,6 @@ interface McpLaunchSpec { const MCP_SERVER_NAME = 'agent-teams'; const logger = createLogger('Service:TeamMcpConfigBuilder'); -const USER_MCP_CONFIG_NAME = '.claude.json'; - const MCP_CONFIG_PREFIX = 'agent-teams-mcp-'; /** * Stale configs older than this are removed on startup (best-effort). @@ -27,10 +25,6 @@ const MCP_CONFIG_STALE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; type McpServerConfig = Record; -function isRecord(value: unknown): value is Record { - return !!value && typeof value === 'object' && !Array.isArray(value); -} - function isPackagedApp(): boolean { try { const { app } = require('electron') as typeof import('electron'); @@ -250,21 +244,23 @@ export class TeamMcpConfigBuilder { configDir, `${MCP_CONFIG_PREFIX}${process.pid}-${Date.now()}-${randomUUID()}.json` ); - const userServers = await this.readUserMcpServers(); + // Keep the team bootstrap config minimal: recent Claude sidechain runs can + // lose the agent-teams tool surface when we inline large user MCP bundles + // into the generated --mcp-config. User/project/local MCP remain loaded + // through Claude's native settings sources. const generatedServers: Record = { [MCP_SERVER_NAME]: { command: launchSpec.command, args: launchSpec.args, }, }; - const mergedServers = this.mergeServers(userServers, generatedServers); await fs.promises.mkdir(configDir, { recursive: true }); await atomicWriteAsync( configPath, JSON.stringify( { - mcpServers: mergedServers, + mcpServers: generatedServers, }, null, 2 @@ -336,61 +332,4 @@ export class TeamMcpConfigBuilder { } } } - - private async readUserMcpServers(): Promise> { - const configPath = path.join(getHomeDir(), USER_MCP_CONFIG_NAME); - return this.readMcpServersFromFile(configPath, 'user'); - } - - private async readMcpServersFromFile( - filePath: string, - scope: 'user' - ): Promise> { - try { - const raw = await fs.promises.readFile(filePath, 'utf8'); - const parsed = JSON.parse(raw) as Record; - const mcpServers = parsed.mcpServers; - if (!isRecord(mcpServers)) { - return {}; - } - - return Object.fromEntries( - Object.entries(mcpServers).filter(([, config]) => isRecord(config)) - ) as Record; - } catch (error) { - const err = error as NodeJS.ErrnoException; - if (err.code === 'ENOENT') { - return {}; - } - - logger.warn( - `Failed to read ${scope} MCP config from ${filePath}: ${ - error instanceof Error ? error.message : String(error) - }` - ); - return {}; - } - } - - private mergeServers( - userServers: Record, - generatedServers: Record - ): Record { - const duplicates = Object.keys(userServers).filter((name) => - Object.hasOwn(generatedServers, name) - ); - - if (duplicates.length > 0) { - logger.info(`Merging MCP configs with overrides for: ${duplicates.join(', ')}`); - } - - // We inline only top-level user MCP into --mcp-config. - // Project/local scopes are still loaded natively by Claude via - // --setting-sources user,project,local, which preserves documented precedence: - // local > project > user. Generated agent-teams must always win on name collision. - return { - ...userServers, - ...generatedServers, - }; - } } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 565ecdc0..66765fa7 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -34,8 +34,8 @@ import { resolveLanguageName } from '@shared/utils/agentLanguage'; import { parseCliArgs } from '@shared/utils/cliArgsParser'; import { isInboxNoiseMessage, - parsePermissionRequest, type ParsedPermissionRequest, + parsePermissionRequest, } from '@shared/utils/inboxNoise'; import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; @@ -109,7 +109,8 @@ import type { } from '@shared/types'; const logger = createLogger('Service:TeamProvisioning'); -const { createController, protocols } = agentTeamsControllerModule; +const { AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES, createController, protocols } = + agentTeamsControllerModule; const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/; const RUN_TIMEOUT_MS = 300_000; const VERIFY_TIMEOUT_MS = 15_000; @@ -2641,7 +2642,7 @@ export class TeamProvisioningService { const mins = Math.floor(silenceSec / 60); const secs = silenceSec % 60; - const elapsed = mins > 0 ? `${mins}m ${secs > 0 ? `${secs}s` : ''}` : `${secs}s`; + const elapsed = mins > 0 ? (secs > 0 ? `${mins}m ${secs}s` : `${mins}m`) : `${secs}s`; // If retry messages are flowing, they are more informative than our // generic stall text — don't overwrite progress.message / severity. @@ -2652,7 +2653,7 @@ export class TeamProvisioningService { ...run.progress, updatedAt: nowIso(), ...(!retryActive && { - message: `CLI not responding for ${elapsed} — possible rate limit`, + message: this.buildStallProgressMessage(silenceSec, elapsed), messageSeverity: 'warning' as const, }), assistantOutput: run.provisioningOutputParts.join('\n\n'), @@ -2678,15 +2679,15 @@ export class TeamProvisioningService { private buildStallWarningText(silenceSec: number, run: ProvisioningRun): string { const mins = Math.floor(silenceSec / 60); const secs = silenceSec % 60; - const elapsed = mins > 0 ? `${mins}m ${secs > 0 ? `${secs}s` : ''}` : `${secs}s`; + const elapsed = mins > 0 ? (secs > 0 ? `${mins}m ${secs}s` : `${mins}m`) : `${secs}s`; if (silenceSec < 60) { return ( `---\n\n` + `**Waiting for CLI response** (silent for ${elapsed})\n\n` + - `The process is running but not producing output yet. ` + - `This may be caused by an API delay (rate limit / model cooldown) — ` + - `the SDK retries automatically.\n\n` + + `The process is running but not producing output yet. Cloud sometimes delays logs, ` + + `and short waits like this are normal. The SDK also retries automatically if the ` + + `request briefly hits rate limiting.\n\n` + `Waiting...` ); } @@ -2695,9 +2696,10 @@ export class TeamProvisioningService { return ( `---\n\n` + `**Waiting for CLI response** (silent for ${elapsed})\n\n` + - `The process is still not responding. Likely delayed due to rate limiting ` + - `(error 429 / model cooldown). The SDK retries the request automatically — ` + - `this usually resolves within 1-3 minutes.\n\n` + + `The process is still waiting on Cloud. Logs can sometimes show up after ` + + `1-1.5 minutes, and that is still okay. The SDK retries automatically if the ` + + `request hits rate limiting (error 429 / model cooldown).\n\n` + + `If there is still no output after 2 minutes, that starts to look unusual.\n\n` + `You can cancel and try again later if the wait continues.` ); } @@ -2708,15 +2710,23 @@ export class TeamProvisioningService { return ( `---\n\n` + `**Extended CLI wait** (silent for ${elapsed})\n\n` + - `Model **${modelName}**${effortLabel} appears to be under heavy load and is not responding. ` + - `Most likely this is a 429 error (rate limit / model cooldown).\n\n` + - `The process has been silent for over ${mins} minutes. Possible causes:\n` + + `Model **${modelName}**${effortLabel} is still waiting on Cloud. Some delay is normal, ` + + `but no logs for ${elapsed} is already unusual.\n\n` + + `Possible causes:\n` + `- Rate limiting / model cooldown (429) — SDK retries automatically\n` + - `- API server overload for this model\n\n` + + `- API server overload for this model\n` + + `- A stalled or delayed Cloud response\n\n` + `Consider canceling and trying with a different model.` ); } + private buildStallProgressMessage(silenceSec: number, elapsed: string): string { + if (silenceSec < 120) { + return `Waiting on Cloud response for ${elapsed} — logs can be delayed, this is still OK`; + } + return `Still waiting on Cloud response for ${elapsed} — this is unusual`; + } + /** * Detects auth failure keywords in stderr/stdout during provisioning. * On first detection: kills process, waits, and respawns automatically. @@ -3216,6 +3226,9 @@ export class TeamProvisioningService { joinedAt: Date.now(), })) ); + if (request.skipPermissions === false) { + await this.seedTeammateOperationalPermissionRules(request.teamName, request.cwd); + } child = spawnCli(claudePath, spawnArgs, { cwd: request.cwd, @@ -3663,6 +3676,9 @@ export class TeamProvisioningService { // Without it, CLI creates a fresh session ID automatically. try { + if (request.skipPermissions === false) { + await this.seedTeammateOperationalPermissionRules(request.teamName, request.cwd); + } child = spawnCli(claudePath, launchArgs, { cwd: request.cwd, env: { ...shellEnv }, @@ -6375,23 +6391,18 @@ export class TeamProvisioningService { .filter((name): name is string => typeof name === 'string' && name.length > 0); if (toolNames.length === 0) continue; - // When approving ANY mcp__agent-teams__ tool, proactively add ALL agent-teams tools. - // FACT: Teammates need multiple MCP tools (member_briefing, task_get, task_start, etc.) - // FACT: Each tool generates a separate permission_request, but by the time we process it - // the teammate is already stuck waiting. Pre-adding all tools prevents future blocks. - if (toolNames.some((name) => name.startsWith('mcp__agent-teams__'))) { - const agentTeamsTools = [ - 'mcp__agent-teams__member_briefing', - 'mcp__agent-teams__task_briefing', - 'mcp__agent-teams__task_create', - 'mcp__agent-teams__task_get', - 'mcp__agent-teams__task_list', - 'mcp__agent-teams__task_start', - 'mcp__agent-teams__task_complete', - 'mcp__agent-teams__task_set_status', - 'mcp__agent-teams__task_add_comment', - ]; - const merged = new Set([...toolNames, ...agentTeamsTools]); + // Expand teammate-safe operational tools only. + // This removes the bootstrap/task workflow race without accidentally granting + // admin/runtime tools like team_stop or kanban_clear. + if ( + toolNames.some((name) => + AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES.includes(name) + ) + ) { + const merged = new Set([ + ...toolNames, + ...AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES, + ]); toolNames = Array.from(merged); } @@ -6426,7 +6437,7 @@ export class TeamProvisioningService { settingsPath: string, toolNames: string[], behavior: string - ): Promise { + ): Promise { const dir = path.dirname(settingsPath); await fs.promises.mkdir(dir, { recursive: true }); @@ -6465,9 +6476,33 @@ export class TeamProvisioningService { } } - if (added === 0) return; // Nothing new to add + if (added === 0) return 0; // Nothing new to add - await fs.promises.writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); + await atomicWriteAsync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); + return added; + } + + private async seedTeammateOperationalPermissionRules( + teamName: string, + projectCwd: string + ): Promise { + const settingsPath = path.join(projectCwd, '.claude', 'settings.local.json'); + try { + const added = await this.addPermissionRulesToSettings( + settingsPath, + [...AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES], + 'allow' + ); + logger.info( + `[${teamName}] Seeded teammate operational MCP rules in ${settingsPath} (${added} added)` + ); + } catch (error) { + logger.warn( + `[${teamName}] Failed to seed teammate operational MCP rules: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } } /** diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index e376daf9..1d2d7550 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -112,9 +112,7 @@ const DEFAULT_MEMBERS: { name: string; roleSelection: string; workflow?: string }, { name: 'tom', - roleSelection: 'researcher', - workflow: - 'Research topics, gather information, and analyze relevant sources. Investigate questions, explore options, and provide detailed findings with clear summaries for the team.', + roleSelection: 'developer', }, { name: 'bob', roleSelection: 'developer' }, { name: 'jack', roleSelection: 'developer' }, diff --git a/src/shared/utils/toolSummary.ts b/src/shared/utils/toolSummary.ts index c9f20810..546fd5ba 100644 --- a/src/shared/utils/toolSummary.ts +++ b/src/shared/utils/toolSummary.ts @@ -112,7 +112,15 @@ export function extractToolPreview( case 'WebSearch': return typeof input.query === 'string' ? truncateStr(input.query, 40) : undefined; default: { - const v = input.name ?? input.path ?? input.file ?? input.query ?? input.command; + const v = + input.subject ?? + input.name ?? + input.description ?? + input.prompt ?? + input.path ?? + input.file ?? + input.query ?? + input.command; return typeof v === 'string' ? truncateStr(v, 50) : undefined; } } diff --git a/src/types/agent-teams-controller.d.ts b/src/types/agent-teams-controller.d.ts index 7c173299..4d60e345 100644 --- a/src/types/agent-teams-controller.d.ts +++ b/src/types/agent-teams-controller.d.ts @@ -93,9 +93,35 @@ declare module 'agent-teams-controller' { buildProcessProtocolText(teamName: string): string; } + export type AgentTeamsMcpToolGroupId = + | 'task' + | 'kanban' + | 'review' + | 'message' + | 'process' + | 'runtime' + | 'crossTeam'; + + export interface AgentTeamsMcpToolGroup { + id: AgentTeamsMcpToolGroupId; + teammateOperational: boolean; + toolNames: readonly string[]; + } + export function createController(options: ControllerContextOptions): AgentTeamsController; export const agentBlocks: AgentBlocksApi; export const protocols: ProtocolsApi; + export const AGENT_TEAMS_TASK_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_REVIEW_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_MESSAGE_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_CROSS_TEAM_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_PROCESS_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_KANBAN_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_RUNTIME_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_MCP_TOOL_GROUPS: readonly AgentTeamsMcpToolGroup[]; + export const AGENT_TEAMS_REGISTERED_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES: readonly string[]; } diff --git a/test/main/services/team/TeamMcpConfigBuilder.test.ts b/test/main/services/team/TeamMcpConfigBuilder.test.ts index 6dcb2620..ad9394e5 100644 --- a/test/main/services/team/TeamMcpConfigBuilder.test.ts +++ b/test/main/services/team/TeamMcpConfigBuilder.test.ts @@ -181,7 +181,7 @@ describe('TeamMcpConfigBuilder', () => { ]); }); - it('merges top-level user MCP with generated agent-teams config', async () => { + 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); @@ -223,19 +223,9 @@ describe('TeamMcpConfigBuilder', () => { mcpServers: Record; }; - expect(Object.keys(parsed.mcpServers).sort()).toEqual([ - 'agent-teams', - 'duplicateServer', - 'globalOnly', - ]); - expect(parsed.mcpServers.globalOnly).toMatchObject({ - type: 'http', - url: 'https://global.example.com/mcp', - }); - expect(parsed.mcpServers.duplicateServer).toMatchObject({ - type: 'http', - url: 'https://global.example.com/duplicate', - }); + expect(Object.keys(parsed.mcpServers)).toEqual(['agent-teams']); + expect(parsed.mcpServers.globalOnly).toBeUndefined(); + expect(parsed.mcpServers.duplicateServer).toBeUndefined(); }); it('does not inline project MCP config to preserve native Claude precedence', async () => { @@ -270,7 +260,7 @@ describe('TeamMcpConfigBuilder', () => { expect(Object.keys(parsed.mcpServers)).toEqual(['agent-teams']); }); - it('generated agent-teams server overrides same-named user MCP entry', async () => { + it('generated agent-teams server ignores same-named user MCP entry', async () => { const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-')); createdDirs.push(homeDir); mockHomeDir = homeDir; diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index caddafe8..10bd96dc 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -53,6 +53,7 @@ vi.mock('@main/utils/pathDecoder', async (importOriginal) => { import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService'; import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; import { spawnCli } from '@main/utils/childProcess'; +import { AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES } from 'agent-teams-controller'; function allowConsoleLogs() { vi.spyOn(console, 'error').mockImplementation(() => {}); @@ -324,4 +325,160 @@ describe('TeamProvisioningService', () => { run.timeoutHandle = null; } }); + + it('pre-seeds teammate operational MCP permissions before createTeam spawn', async () => { + allowConsoleLogs(); + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude'); + vi.mocked(spawnCli).mockImplementation(() => { + throw new Error('spawn EINVAL'); + }); + + const mcpConfigBuilder = { + writeConfigFile: vi.fn(async () => '/mock/mcp-config-create.json'), + removeConfigFile: vi.fn(async () => {}), + }; + const membersMetaStore = { + writeMembers: vi.fn(async () => {}), + }; + const teamMetaStore = { + writeMeta: vi.fn(async () => {}), + deleteMeta: vi.fn(async () => {}), + }; + + const svc = new TeamProvisioningService( + undefined, + undefined, + membersMetaStore as any, + undefined, + mcpConfigBuilder as any, + teamMetaStore as any + ); + (svc as any).buildProvisioningEnv = vi.fn(async () => ({ + env: { ANTHROPIC_API_KEY: 'test' }, + authSource: 'anthropic_api_key', + })); + (svc as any).pathExists = vi.fn(async () => false); + + await expect( + svc.createTeam( + { + teamName: 'seeded-team', + cwd: tempClaudeRoot, + members: [{ name: 'alice' }], + skipPermissions: false, + }, + () => {} + ) + ).rejects.toThrow('spawn EINVAL'); + + const settingsPath = path.join(tempClaudeRoot, '.claude', 'settings.local.json'); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + permissions?: { allow?: string[] }; + }; + expect(settings.permissions?.allow).toEqual( + expect.arrayContaining([...AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES]) + ); + expect(settings.permissions?.allow).not.toContain('mcp__agent-teams__team_stop'); + expect(settings.permissions?.allow).not.toContain('mcp__agent-teams__kanban_clear'); + }); + + it('expands teammate permission suggestions to the operational tool set only', async () => { + allowConsoleLogs(); + const svc = new TeamProvisioningService( + { + getConfig: vi.fn(async () => ({ + projectPath: tempClaudeRoot, + members: [{ cwd: tempClaudeRoot }], + })), + } as any + ); + + await (svc as any).respondToTeammatePermission( + { teamName: 'ops-team' }, + 'alice', + 'req-1', + true, + undefined, + [ + { + type: 'addRules', + behavior: 'allow', + destination: 'localSettings', + rules: [{ toolName: 'mcp__agent-teams__task_get' }], + }, + ] + ); + + const settingsPath = path.join(tempClaudeRoot, '.claude', 'settings.local.json'); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + permissions?: { allow?: string[] }; + }; + expect(settings.permissions?.allow).toEqual( + expect.arrayContaining([...AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES]) + ); + expect(settings.permissions?.allow).not.toContain('mcp__agent-teams__team_stop'); + expect(settings.permissions?.allow).not.toContain('mcp__agent-teams__kanban_clear'); + }); + + it('does not broaden admin/runtime teammate permission suggestions', async () => { + allowConsoleLogs(); + const svc = new TeamProvisioningService( + { + getConfig: vi.fn(async () => ({ + projectPath: tempClaudeRoot, + members: [{ cwd: tempClaudeRoot }], + })), + } as any + ); + + await (svc as any).respondToTeammatePermission( + { teamName: 'ops-team' }, + 'alice', + 'req-2', + true, + undefined, + [ + { + type: 'addRules', + behavior: 'allow', + destination: 'localSettings', + rules: [{ toolName: 'mcp__agent-teams__team_stop' }], + }, + ] + ); + + const settingsPath = path.join(tempClaudeRoot, '.claude', 'settings.local.json'); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + permissions?: { allow?: string[] }; + }; + expect(settings.permissions?.allow).toEqual(['mcp__agent-teams__team_stop']); + }); + + it('uses a non-alarming cloud delay message before 2 minutes of silence', () => { + const svc = new TeamProvisioningService(); + + expect((svc as any).buildStallProgressMessage(90, '1m 30s')).toBe( + 'Waiting on Cloud response for 1m 30s — logs can be delayed, this is still OK' + ); + + expect( + (svc as any).buildStallWarningText(90, { + request: { model: 'sonnet' }, + }) + ).toContain('Logs can sometimes show up after 1-1.5 minutes, and that is still okay.'); + }); + + it('marks a cloud wait as unusual after 2 minutes of silence', () => { + const svc = new TeamProvisioningService(); + + expect((svc as any).buildStallProgressMessage(120, '2m')).toBe( + 'Still waiting on Cloud response for 2m — this is unusual' + ); + + expect( + (svc as any).buildStallWarningText(120, { + request: { model: 'sonnet' }, + }) + ).toContain('but no logs for 2m is already unusual.'); + }); });