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.
This commit is contained in:
iliya 2026-03-30 17:58:17 +03:00
parent 9241970b02
commit 822bbac23c
12 changed files with 442 additions and 169 deletions

View file

@ -1,5 +1,7 @@
const controller = require('./controller.js');
const mcpToolCatalog = require('./mcpToolCatalog.js');
module.exports = {
...controller,
...mcpToolCatalog,
};

View file

@ -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,
};

View file

@ -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[];
}

View file

@ -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);
}
}

View file

@ -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', () => {

View file

@ -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<string, unknown>;
function isRecord(value: unknown): value is Record<string, unknown> {
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<string, McpServerConfig> = {
[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<Record<string, McpServerConfig>> {
const configPath = path.join(getHomeDir(), USER_MCP_CONFIG_NAME);
return this.readMcpServersFromFile(configPath, 'user');
}
private async readMcpServersFromFile(
filePath: string,
scope: 'user'
): Promise<Record<string, McpServerConfig>> {
try {
const raw = await fs.promises.readFile(filePath, 'utf8');
const parsed = JSON.parse(raw) as Record<string, unknown>;
const mcpServers = parsed.mcpServers;
if (!isRecord(mcpServers)) {
return {};
}
return Object.fromEntries(
Object.entries(mcpServers).filter(([, config]) => isRecord(config))
) as Record<string, McpServerConfig>;
} 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<string, McpServerConfig>,
generatedServers: Record<string, McpServerConfig>
): Record<string, McpServerConfig> {
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,
};
}
}

View file

@ -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<void> {
): Promise<number> {
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<void> {
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)
}`
);
}
}
/**

View file

@ -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' },

View file

@ -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;
}
}

View file

@ -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[];
}

View file

@ -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<string, { command?: string; args?: string[]; type?: string; url?: string }>;
};
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;

View file

@ -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.');
});
});