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:
parent
9241970b02
commit
822bbac23c
12 changed files with 442 additions and 169 deletions
|
|
@ -1,5 +1,7 @@
|
|||
const controller = require('./controller.js');
|
||||
const mcpToolCatalog = require('./mcpToolCatalog.js');
|
||||
|
||||
module.exports = {
|
||||
...controller,
|
||||
...mcpToolCatalog,
|
||||
};
|
||||
|
|
|
|||
115
agent-teams-controller/src/mcpToolCatalog.js
Normal file
115
agent-teams-controller/src/mcpToolCatalog.js
Normal 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,
|
||||
};
|
||||
27
mcp-server/src/agent-teams-controller.d.ts
vendored
27
mcp-server/src/agent-teams-controller.d.ts
vendored
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
26
src/types/agent-teams-controller.d.ts
vendored
26
src/types/agent-teams-controller.d.ts
vendored
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue