From 4965a7cff090a709e8afe200ffbd3fe07182d345 Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Fri, 3 Apr 2026 19:22:43 +0900 Subject: [PATCH 1/3] feat(mcp): add agent-stack resolution and list_agent_stacks tool (#1183, #1184) - Add AgentStackService for loading/resolving agent stack presets - Extend dispatch_agents with agentStack parameter for preset-based dispatch - Add list_agent_stacks MCP tool with optional category filter - Stack resolution: custom (.codingbuddy/agent-stacks/) > default (rules/agent-stacks/) - Backward compatible: explicit primaryAgent/specialists still override --- .../src/agent/agent-stack.service.spec.ts | 239 ++++++++++++++++++ .../src/agent/agent-stack.service.ts | 121 +++++++++ apps/mcp-server/src/agent/agent.module.ts | 5 +- apps/mcp-server/src/agent/agent.types.ts | 26 ++ apps/mcp-server/src/agent/index.ts | 1 + .../abstract-handler.integration.spec.ts | 5 + .../src/mcp/handlers/agent.handler.spec.ts | 199 ++++++++++++++- .../src/mcp/handlers/agent.handler.ts | 67 ++++- apps/mcp-server/src/mcp/mcp.service.spec.ts | 1 + apps/mcp-server/src/rules/rules.service.ts | 4 + 10 files changed, 661 insertions(+), 7 deletions(-) create mode 100644 apps/mcp-server/src/agent/agent-stack.service.spec.ts create mode 100644 apps/mcp-server/src/agent/agent-stack.service.ts diff --git a/apps/mcp-server/src/agent/agent-stack.service.spec.ts b/apps/mcp-server/src/agent/agent-stack.service.spec.ts new file mode 100644 index 00000000..017b9416 --- /dev/null +++ b/apps/mcp-server/src/agent/agent-stack.service.spec.ts @@ -0,0 +1,239 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { AgentStackService } from './agent-stack.service'; +import type { RulesService } from '../rules/rules.service'; +import type { ConfigService } from '../config/config.service'; +import * as fs from 'fs/promises'; + +vi.mock('fs/promises'); + +const mockFs = vi.mocked(fs); + +describe('AgentStackService', () => { + let service: AgentStackService; + let mockRulesService: Partial; + let mockConfigService: Partial; + + const sampleStack = { + name: 'api-development', + description: 'API development stack', + category: 'development', + primary_agent: 'backend-developer', + specialists: ['security-specialist', 'test-engineer'], + tags: ['api', 'backend'], + }; + + const sampleStack2 = { + name: 'frontend-review', + description: 'Frontend review stack', + category: 'review', + primary_agent: 'frontend-developer', + specialists: ['accessibility-specialist', 'performance-specialist'], + tags: ['frontend', 'ui'], + }; + + beforeEach(() => { + mockRulesService = { + getRulesDir: vi.fn().mockReturnValue('/rules/.ai-rules'), + }; + mockConfigService = { + getProjectRoot: vi.fn().mockReturnValue('/project'), + }; + service = new AgentStackService( + mockRulesService as RulesService, + mockConfigService as ConfigService, + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('listStacks', () => { + it('should return stacks from default location', async () => { + mockFs.readdir.mockImplementation(async (dirPath: fs.PathLike) => { + if (String(dirPath).includes('.ai-rules/agent-stacks')) { + return ['api-development.json', 'frontend-review.json'] as unknown as fs.Dirent[]; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + mockFs.readFile.mockImplementation(async (filePath: fs.PathLike) => { + const p = String(filePath); + if (p.includes('api-development.json')) return JSON.stringify(sampleStack); + if (p.includes('frontend-review.json')) return JSON.stringify(sampleStack2); + throw new Error('Not found'); + }); + + const stacks = await service.listStacks(); + + expect(stacks).toHaveLength(2); + expect(stacks[0].name).toBe('api-development'); + expect(stacks[0].specialist_count).toBe(2); + expect(stacks[1].name).toBe('frontend-review'); + }); + + it('should merge custom stacks with default, custom taking priority', async () => { + const customStack = { + ...sampleStack, + description: 'Custom API stack', + specialists: ['security-specialist'], + }; + + mockFs.readdir.mockImplementation(async (dirPath: fs.PathLike) => { + const p = String(dirPath); + if (p.includes('.codingbuddy/agent-stacks')) { + return ['api-development.json'] as unknown as fs.Dirent[]; + } + if (p.includes('.ai-rules/agent-stacks')) { + return ['api-development.json', 'frontend-review.json'] as unknown as fs.Dirent[]; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + mockFs.readFile.mockImplementation(async (filePath: fs.PathLike) => { + const p = String(filePath); + if (p.includes('.codingbuddy') && p.includes('api-development.json')) { + return JSON.stringify(customStack); + } + if (p.includes('api-development.json')) return JSON.stringify(sampleStack); + if (p.includes('frontend-review.json')) return JSON.stringify(sampleStack2); + throw new Error('Not found'); + }); + + const stacks = await service.listStacks(); + + expect(stacks).toHaveLength(2); + const apiStack = stacks.find(s => s.name === 'api-development'); + expect(apiStack?.description).toBe('Custom API stack'); + expect(apiStack?.specialist_count).toBe(1); + }); + + it('should filter by category', async () => { + mockFs.readdir.mockImplementation(async (dirPath: fs.PathLike) => { + if (String(dirPath).includes('.ai-rules/agent-stacks')) { + return ['api-development.json', 'frontend-review.json'] as unknown as fs.Dirent[]; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + mockFs.readFile.mockImplementation(async (filePath: fs.PathLike) => { + const p = String(filePath); + if (p.includes('api-development.json')) return JSON.stringify(sampleStack); + if (p.includes('frontend-review.json')) return JSON.stringify(sampleStack2); + throw new Error('Not found'); + }); + + const stacks = await service.listStacks('review'); + + expect(stacks).toHaveLength(1); + expect(stacks[0].name).toBe('frontend-review'); + }); + + it('should return empty array when no stack directories exist', async () => { + mockFs.readdir.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + + const stacks = await service.listStacks(); + + expect(stacks).toEqual([]); + }); + + it('should skip invalid JSON files gracefully', async () => { + mockFs.readdir.mockImplementation(async (dirPath: fs.PathLike) => { + if (String(dirPath).includes('.ai-rules/agent-stacks')) { + return ['valid.json', 'invalid.json', 'not-json.txt'] as unknown as fs.Dirent[]; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + mockFs.readFile.mockImplementation(async (filePath: fs.PathLike) => { + const p = String(filePath); + if (p.includes('valid.json')) return JSON.stringify(sampleStack); + if (p.includes('invalid.json')) return 'not valid json {{{'; + throw new Error('Not found'); + }); + + const stacks = await service.listStacks(); + + expect(stacks).toHaveLength(1); + expect(stacks[0].name).toBe('api-development'); + }); + + it('should skip stacks missing required fields', async () => { + const incompleteStack = { name: 'incomplete' }; + + mockFs.readdir.mockImplementation(async (dirPath: fs.PathLike) => { + if (String(dirPath).includes('.ai-rules/agent-stacks')) { + return ['valid.json', 'incomplete.json'] as unknown as fs.Dirent[]; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + mockFs.readFile.mockImplementation(async (filePath: fs.PathLike) => { + const p = String(filePath); + if (p.includes('valid.json')) return JSON.stringify(sampleStack); + if (p.includes('incomplete.json')) return JSON.stringify(incompleteStack); + throw new Error('Not found'); + }); + + const stacks = await service.listStacks(); + + expect(stacks).toHaveLength(1); + }); + }); + + describe('resolveStack', () => { + it('should resolve stack by name from default location', async () => { + mockFs.readdir.mockImplementation(async (dirPath: fs.PathLike) => { + if (String(dirPath).includes('.ai-rules/agent-stacks')) { + return ['api-development.json'] as unknown as fs.Dirent[]; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + mockFs.readFile.mockImplementation(async (filePath: fs.PathLike) => { + if (String(filePath).includes('api-development.json')) { + return JSON.stringify(sampleStack); + } + throw new Error('Not found'); + }); + + const stack = await service.resolveStack('api-development'); + + expect(stack.name).toBe('api-development'); + expect(stack.primary_agent).toBe('backend-developer'); + expect(stack.specialists).toEqual(['security-specialist', 'test-engineer']); + }); + + it('should prefer custom stack over default', async () => { + const customStack = { + ...sampleStack, + specialists: ['security-specialist', 'test-engineer', 'code-quality-specialist'], + }; + + mockFs.readdir.mockImplementation(async (dirPath: fs.PathLike) => { + const p = String(dirPath); + if (p.includes('.codingbuddy/agent-stacks')) { + return ['api-development.json'] as unknown as fs.Dirent[]; + } + if (p.includes('.ai-rules/agent-stacks')) { + return ['api-development.json'] as unknown as fs.Dirent[]; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + mockFs.readFile.mockImplementation(async (filePath: fs.PathLike) => { + const p = String(filePath); + if (p.includes('.codingbuddy') && p.includes('api-development.json')) { + return JSON.stringify(customStack); + } + if (p.includes('api-development.json')) return JSON.stringify(sampleStack); + throw new Error('Not found'); + }); + + const stack = await service.resolveStack('api-development'); + + expect(stack.specialists).toHaveLength(3); + }); + + it('should throw when stack not found', async () => { + mockFs.readdir.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + + await expect(service.resolveStack('non-existent')).rejects.toThrow( + "Agent stack 'non-existent' not found", + ); + }); + }); +}); diff --git a/apps/mcp-server/src/agent/agent-stack.service.ts b/apps/mcp-server/src/agent/agent-stack.service.ts new file mode 100644 index 00000000..b40f5566 --- /dev/null +++ b/apps/mcp-server/src/agent/agent-stack.service.ts @@ -0,0 +1,121 @@ +import { Injectable, Logger } from '@nestjs/common'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { RulesService } from '../rules/rules.service'; +import { ConfigService } from '../config/config.service'; +import type { AgentStack, AgentStackSummary } from './agent.types'; + +const AGENT_STACKS_DIR = 'agent-stacks'; +const CUSTOM_STACKS_DIR = '.codingbuddy/agent-stacks'; + +@Injectable() +export class AgentStackService { + private readonly logger = new Logger(AgentStackService.name); + + constructor( + private readonly rulesService: RulesService, + private readonly configService: ConfigService, + ) {} + + /** + * List all available agent stacks, optionally filtered by category. + * Custom stacks (.codingbuddy/agent-stacks/) override default stacks with the same name. + */ + async listStacks(category?: string): Promise { + const stackMap = new Map(); + + // Load default stacks first + const defaultDir = path.join(this.rulesService.getRulesDir(), AGENT_STACKS_DIR); + const defaultStacks = await this.loadStacksFromDir(defaultDir); + for (const stack of defaultStacks) { + stackMap.set(stack.name, stack); + } + + // Load custom stacks (override defaults) + const customDir = path.join(this.configService.getProjectRoot(), CUSTOM_STACKS_DIR); + const customStacks = await this.loadStacksFromDir(customDir); + for (const stack of customStacks) { + stackMap.set(stack.name, stack); + } + + let stacks = Array.from(stackMap.values()); + + if (category) { + stacks = stacks.filter(s => s.category === category); + } + + return stacks.map(this.toSummary); + } + + /** + * Resolve a stack by name. + * Resolution order: custom (.codingbuddy/agent-stacks/) > default (rules/agent-stacks/). + */ + async resolveStack(stackName: string): Promise { + // Try custom first + const customDir = path.join(this.configService.getProjectRoot(), CUSTOM_STACKS_DIR); + const customStack = await this.loadStackFile(path.join(customDir, `${stackName}.json`)); + if (customStack) return customStack; + + // Fall back to default + const defaultDir = path.join(this.rulesService.getRulesDir(), AGENT_STACKS_DIR); + const defaultStack = await this.loadStackFile(path.join(defaultDir, `${stackName}.json`)); + if (defaultStack) return defaultStack; + + throw new Error(`Agent stack '${stackName}' not found`); + } + + private async loadStacksFromDir(dirPath: string): Promise { + let entries: string[]; + try { + entries = (await fs.readdir(dirPath)) as string[]; + } catch { + return []; + } + + const stacks: AgentStack[] = []; + for (const entry of entries) { + if (!entry.endsWith('.json')) continue; + const stack = await this.loadStackFile(path.join(dirPath, entry)); + if (stack) stacks.push(stack); + } + return stacks; + } + + private async loadStackFile(filePath: string): Promise { + try { + const content = await fs.readFile(filePath, 'utf-8'); + const parsed = JSON.parse(content) as Record; + + if (!this.isValidStack(parsed)) { + this.logger.warn(`Invalid agent stack file (missing required fields): ${filePath}`); + return null; + } + + return parsed as unknown as AgentStack; + } catch { + return null; + } + } + + private isValidStack(obj: Record): boolean { + return ( + typeof obj.name === 'string' && + typeof obj.description === 'string' && + typeof obj.category === 'string' && + typeof obj.primary_agent === 'string' && + Array.isArray(obj.specialists) + ); + } + + private toSummary(stack: AgentStack): AgentStackSummary { + return { + name: stack.name, + description: stack.description, + category: stack.category, + primary_agent: stack.primary_agent, + specialist_count: stack.specialists.length, + tags: stack.tags ?? [], + }; + } +} diff --git a/apps/mcp-server/src/agent/agent.module.ts b/apps/mcp-server/src/agent/agent.module.ts index 04996992..3e217005 100644 --- a/apps/mcp-server/src/agent/agent.module.ts +++ b/apps/mcp-server/src/agent/agent.module.ts @@ -1,12 +1,13 @@ import { Module } from '@nestjs/common'; import { AgentService } from './agent.service'; +import { AgentStackService } from './agent-stack.service'; import { RulesModule } from '../rules/rules.module'; import { CustomModule } from '../custom'; import { CodingBuddyConfigModule } from '../config/config.module'; @Module({ imports: [RulesModule, CustomModule, CodingBuddyConfigModule], - providers: [AgentService], - exports: [AgentService], + providers: [AgentService, AgentStackService], + exports: [AgentService, AgentStackService], }) export class AgentModule {} diff --git a/apps/mcp-server/src/agent/agent.types.ts b/apps/mcp-server/src/agent/agent.types.ts index 60c2c08a..34bb013c 100644 --- a/apps/mcp-server/src/agent/agent.types.ts +++ b/apps/mcp-server/src/agent/agent.types.ts @@ -168,6 +168,30 @@ export interface InlineAgentDefinition { [key: string]: unknown; } +/** + * Agent stack definition — a preset combination of primary agent + specialists + */ +export interface AgentStack { + name: string; + description: string; + category: string; + primary_agent: string; + specialists: string[]; + tags?: string[]; +} + +/** + * Summary of an agent stack for listing + */ +export interface AgentStackSummary { + name: string; + description: string; + category: string; + primary_agent: string; + specialist_count: number; + tags: string[]; +} + /** * Input parameters for the dispatch_agents tool */ @@ -178,6 +202,8 @@ export interface DispatchAgentsInput { specialists?: string[]; includeParallel?: boolean; primaryAgent?: string; + /** Agent stack name to resolve primary + specialists from a preset */ + agentStack?: string; /** Execution strategy: 'subagent' (default), 'taskmaestro', or 'teams' */ executionStrategy?: 'subagent' | 'taskmaestro' | 'teams'; /** Inline agent definitions keyed by agent ID, highest priority in resolution */ diff --git a/apps/mcp-server/src/agent/index.ts b/apps/mcp-server/src/agent/index.ts index 280d8742..8f6525e1 100644 --- a/apps/mcp-server/src/agent/index.ts +++ b/apps/mcp-server/src/agent/index.ts @@ -1,4 +1,5 @@ export * from './agent.module'; export * from './agent.service'; +export * from './agent-stack.service'; export * from './agent.types'; export * from './agent-prompt.builder'; diff --git a/apps/mcp-server/src/mcp/handlers/abstract-handler.integration.spec.ts b/apps/mcp-server/src/mcp/handlers/abstract-handler.integration.spec.ts index 7c933251..584a1f0a 100644 --- a/apps/mcp-server/src/mcp/handlers/abstract-handler.integration.spec.ts +++ b/apps/mcp-server/src/mcp/handlers/abstract-handler.integration.spec.ts @@ -144,8 +144,13 @@ describe('Handler Security Integration', () => { const mockRuleEventCollector = { record: vi.fn() } as unknown as RuleEventCollector; // Initialize handlers + const mockAgentStackService = { + listStacks: vi.fn().mockResolvedValue([]), + resolveStack: vi.fn().mockRejectedValue(new Error('Stack not found')), + }; agentHandler = new AgentHandler( mockAgentService, + mockAgentStackService as never, mockImpactEventService, mockRuleEventCollector, ); diff --git a/apps/mcp-server/src/mcp/handlers/agent.handler.spec.ts b/apps/mcp-server/src/mcp/handlers/agent.handler.spec.ts index f83aa086..c57e0ba6 100644 --- a/apps/mcp-server/src/mcp/handlers/agent.handler.spec.ts +++ b/apps/mcp-server/src/mcp/handlers/agent.handler.spec.ts @@ -1,12 +1,14 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { AgentHandler } from './agent.handler'; import { AgentService } from '../../agent/agent.service'; +import { AgentStackService } from '../../agent/agent-stack.service'; import type { ImpactEventService } from '../../impact'; import type { RuleEventCollector } from '../../rules/rule-event-collector'; describe('AgentHandler', () => { let handler: AgentHandler; let mockAgentService: AgentService; + let mockAgentStackService: AgentStackService; let mockImpactEventService: Partial; let mockRuleEventCollector: Partial; @@ -26,11 +28,17 @@ describe('AgentHandler', () => { prepareParallelAgents: vi.fn().mockResolvedValue(mockParallelAgentsResult), } as unknown as AgentService; + mockAgentStackService = { + listStacks: vi.fn().mockResolvedValue([]), + resolveStack: vi.fn().mockRejectedValue(new Error('Stack not found')), + } as unknown as AgentStackService; + mockImpactEventService = { logEvent: vi.fn() }; mockRuleEventCollector = { record: vi.fn() }; handler = new AgentHandler( mockAgentService, + mockAgentStackService, mockImpactEventService as ImpactEventService, mockRuleEventCollector as RuleEventCollector, ); @@ -739,11 +747,12 @@ describe('AgentHandler', () => { it('should return tool definitions', () => { const definitions = handler.getToolDefinitions(); - expect(definitions).toHaveLength(3); + expect(definitions).toHaveLength(4); expect(definitions.map(d => d.name)).toEqual([ 'get_agent_system_prompt', 'prepare_parallel_agents', 'dispatch_agents', + 'list_agent_stacks', ]); }); @@ -777,6 +786,21 @@ describe('AgentHandler', () => { const properties = dispatchAgents?.inputSchema.properties as Record; expect(properties).toHaveProperty('inlineAgents'); }); + + it('should include agentStack in dispatch_agents schema', () => { + const definitions = handler.getToolDefinitions(); + const dispatchAgents = definitions.find(d => d.name === 'dispatch_agents'); + const properties = dispatchAgents?.inputSchema.properties as Record; + expect(properties).toHaveProperty('agentStack'); + }); + + it('should include list_agent_stacks tool definition', () => { + const definitions = handler.getToolDefinitions(); + const listStacks = definitions.find(d => d.name === 'list_agent_stacks'); + expect(listStacks).toBeDefined(); + const properties = listStacks?.inputSchema.properties as Record; + expect(properties).toHaveProperty('category'); + }); }); describe('unified agent registry (inlineAgents)', () => { @@ -946,4 +970,177 @@ describe('AgentHandler', () => { }); }); }); + + describe('dispatch_agents with agentStack', () => { + const mockStack = { + name: 'api-development', + description: 'API development stack', + category: 'development', + primary_agent: 'backend-developer', + specialists: ['security-specialist', 'test-engineer'], + tags: ['api', 'backend'], + }; + + const mockDispatchResult = { + primaryAgent: { + name: 'backend-developer', + displayName: 'Backend Developer', + description: 'Backend review', + dispatchParams: { + subagent_type: 'general-purpose' as const, + prompt: 'You are a backend developer...', + description: 'Backend review', + }, + }, + executionHint: 'Use Task tool...', + }; + + beforeEach(() => { + mockAgentService.dispatchAgents = vi.fn().mockResolvedValue(mockDispatchResult); + }); + + it('should resolve stack and pass primary + specialists to service', async () => { + mockAgentStackService.resolveStack = vi.fn().mockResolvedValue(mockStack); + + await handler.handle('dispatch_agents', { + mode: 'ACT', + agentStack: 'api-development', + }); + + expect(mockAgentStackService.resolveStack).toHaveBeenCalledWith('api-development'); + expect(mockAgentService.dispatchAgents).toHaveBeenCalledWith( + expect.objectContaining({ + mode: 'ACT', + primaryAgent: 'backend-developer', + specialists: ['security-specialist', 'test-engineer'], + includeParallel: true, + }), + ); + }); + + it('should prefer explicit primaryAgent over stack primary', async () => { + mockAgentStackService.resolveStack = vi.fn().mockResolvedValue(mockStack); + + await handler.handle('dispatch_agents', { + mode: 'ACT', + agentStack: 'api-development', + primaryAgent: 'frontend-developer', + }); + + expect(mockAgentService.dispatchAgents).toHaveBeenCalledWith( + expect.objectContaining({ + primaryAgent: 'frontend-developer', + }), + ); + }); + + it('should prefer explicit specialists over stack specialists', async () => { + mockAgentStackService.resolveStack = vi.fn().mockResolvedValue(mockStack); + + await handler.handle('dispatch_agents', { + mode: 'ACT', + agentStack: 'api-development', + specialists: ['performance-specialist'], + }); + + expect(mockAgentService.dispatchAgents).toHaveBeenCalledWith( + expect.objectContaining({ + specialists: ['performance-specialist'], + }), + ); + }); + + it('should return error when stack not found', async () => { + mockAgentStackService.resolveStack = vi + .fn() + .mockRejectedValue(new Error("Agent stack 'non-existent' not found")); + + const result = await handler.handle('dispatch_agents', { + mode: 'ACT', + agentStack: 'non-existent', + }); + + expect(result?.isError).toBe(true); + expect(result?.content[0]).toMatchObject({ + type: 'text', + text: expect.stringContaining('Failed to resolve agent stack'), + }); + }); + + it('should work without agentStack (backward compatible)', async () => { + await handler.handle('dispatch_agents', { + mode: 'EVAL', + primaryAgent: 'security-specialist', + }); + + expect(mockAgentStackService.resolveStack).not.toHaveBeenCalled(); + }); + }); + + describe('list_agent_stacks', () => { + const mockStacks = [ + { + name: 'api-development', + description: 'API development stack', + category: 'development', + primary_agent: 'backend-developer', + specialist_count: 2, + tags: ['api', 'backend'], + }, + { + name: 'frontend-review', + description: 'Frontend review stack', + category: 'review', + primary_agent: 'frontend-developer', + specialist_count: 3, + tags: ['frontend'], + }, + ]; + + it('should list all agent stacks', async () => { + mockAgentStackService.listStacks = vi.fn().mockResolvedValue(mockStacks); + + const result = await handler.handle('list_agent_stacks', {}); + + expect(result?.isError).toBeFalsy(); + const content = JSON.parse(result?.content[0]?.text as string); + expect(content.stacks).toHaveLength(2); + expect(content.stacks[0].name).toBe('api-development'); + expect(content.stacks[1].specialist_count).toBe(3); + }); + + it('should pass category filter', async () => { + mockAgentStackService.listStacks = vi.fn().mockResolvedValue([mockStacks[1]]); + + const result = await handler.handle('list_agent_stacks', { + category: 'review', + }); + + expect(mockAgentStackService.listStacks).toHaveBeenCalledWith('review'); + expect(result?.isError).toBeFalsy(); + const content = JSON.parse(result?.content[0]?.text as string); + expect(content.stacks).toHaveLength(1); + }); + + it('should work without any parameters', async () => { + mockAgentStackService.listStacks = vi.fn().mockResolvedValue([]); + + const result = await handler.handle('list_agent_stacks', undefined); + + expect(result?.isError).toBeFalsy(); + expect(mockAgentStackService.listStacks).toHaveBeenCalledWith(undefined); + }); + + it('should return error when service fails', async () => { + mockAgentStackService.listStacks = vi.fn().mockRejectedValue(new Error('Read error')); + + const result = await handler.handle('list_agent_stacks', {}); + + expect(result?.isError).toBe(true); + expect(result?.content[0]).toMatchObject({ + type: 'text', + text: expect.stringContaining('Failed to list agent stacks'), + }); + }); + }); }); diff --git a/apps/mcp-server/src/mcp/handlers/agent.handler.ts b/apps/mcp-server/src/mcp/handlers/agent.handler.ts index a4d7ca67..75bd134c 100644 --- a/apps/mcp-server/src/mcp/handlers/agent.handler.ts +++ b/apps/mcp-server/src/mcp/handlers/agent.handler.ts @@ -3,6 +3,7 @@ import type { ToolDefinition } from './base.handler'; import type { ToolResponse } from '../response.utils'; import { AbstractHandler } from './abstract-handler'; import { AgentService } from '../../agent/agent.service'; +import { AgentStackService } from '../../agent/agent-stack.service'; import type { InlineAgentDefinition } from '../../agent/agent.types'; import type { Mode } from '../../keyword/keyword.types'; import { isValidVerbosity } from '../../shared/verbosity.types'; @@ -27,6 +28,7 @@ import { RuleEventCollector } from '../../rules/rule-event-collector'; export class AgentHandler extends AbstractHandler { constructor( private readonly agentService: AgentService, + private readonly agentStackService: AgentStackService, private readonly impactEventService: ImpactEventService, private readonly ruleEventCollector: RuleEventCollector, ) { @@ -34,7 +36,12 @@ export class AgentHandler extends AbstractHandler { } protected getHandledTools(): string[] { - return ['get_agent_system_prompt', 'prepare_parallel_agents', 'dispatch_agents']; + return [ + 'get_agent_system_prompt', + 'prepare_parallel_agents', + 'dispatch_agents', + 'list_agent_stacks', + ]; } protected async handleTool( @@ -48,6 +55,8 @@ export class AgentHandler extends AbstractHandler { return this.handlePrepareParallelAgents(args); case 'dispatch_agents': return this.handleDispatchAgents(args); + case 'list_agent_stacks': + return this.handleListAgentStacks(args); default: return createErrorResponse(`Unknown tool: ${toolName}`); } @@ -171,6 +180,11 @@ export class AgentHandler extends AbstractHandler { description: 'Execution strategy for specialist agents. "subagent" (default) uses Claude Code Agent tool with run_in_background. "taskmaestro" returns tmux pane assignments for /taskmaestro skill. "teams" uses Claude Code native teams with shared TaskList coordination.', }, + agentStack: { + type: 'string', + description: + 'Agent stack name to resolve primary + specialists from a preset (e.g., "api-development"). Overrides primaryAgent and specialists when provided.', + }, inlineAgents: { type: 'object', description: @@ -196,6 +210,21 @@ export class AgentHandler extends AbstractHandler { required: ['mode'], }, }, + { + name: 'list_agent_stacks', + description: + 'List available agent stack presets. Each stack defines a primary agent and specialist combination for common workflows.', + inputSchema: { + type: 'object', + properties: { + category: { + type: 'string', + description: + 'Optional category filter (e.g., "development", "review", "data", "security")', + }, + }, + }, + }, ]; } @@ -211,15 +240,30 @@ export class AgentHandler extends AbstractHandler { ); } - const primaryAgent = extractOptionalString(args, 'primaryAgent'); - const specialists = extractStringArray(args, 'specialists'); + const agentStack = extractOptionalString(args, 'agentStack'); + let primaryAgent = extractOptionalString(args, 'primaryAgent'); + let specialists = extractStringArray(args, 'specialists'); const targetFiles = extractStringArray(args, 'targetFiles'); const taskDescription = extractOptionalString(args, 'taskDescription'); - const includeParallel = args?.includeParallel === true; + let includeParallel = args?.includeParallel === true; const executionStrategy = (args?.executionStrategy as 'subagent' | 'taskmaestro' | 'teams' | undefined) ?? 'subagent'; const inlineAgents = this.extractInlineAgents(args); + // Resolve agent stack if provided + if (agentStack) { + try { + const stack = await this.agentStackService.resolveStack(agentStack); + primaryAgent = primaryAgent ?? stack.primary_agent; + specialists = specialists?.length ? specialists : stack.specialists; + includeParallel = includeParallel || stack.specialists.length > 0; + } catch (error) { + return createErrorResponse( + `Failed to resolve agent stack: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } + try { const result = await this.agentService.dispatchAgents({ mode: mode as Mode, @@ -380,6 +424,21 @@ export class AgentHandler extends AbstractHandler { } } + private async handleListAgentStacks( + args: Record | undefined, + ): Promise { + const category = extractOptionalString(args, 'category'); + + try { + const stacks = await this.agentStackService.listStacks(category); + return createJsonResponse({ stacks }); + } catch (error) { + return createErrorResponse( + `Failed to list agent stacks: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } + /** * Extract and validate inlineAgents from tool args. * Returns undefined if not present or not a valid object. diff --git a/apps/mcp-server/src/mcp/mcp.service.spec.ts b/apps/mcp-server/src/mcp/mcp.service.spec.ts index 4e036154..52350dd4 100644 --- a/apps/mcp-server/src/mcp/mcp.service.spec.ts +++ b/apps/mcp-server/src/mcp/mcp.service.spec.ts @@ -479,6 +479,7 @@ function createMcpServiceWithHandlers( ), new AgentHandler( services.agentService as AgentService, + { listStacks: vi.fn().mockResolvedValue([]), resolveStack: vi.fn() } as never, services.impactEventService as ImpactEventService, services.ruleEventCollector as RuleEventCollector, ), diff --git a/apps/mcp-server/src/rules/rules.service.ts b/apps/mcp-server/src/rules/rules.service.ts index 3b6081b0..7eb17951 100644 --- a/apps/mcp-server/src/rules/rules.service.ts +++ b/apps/mcp-server/src/rules/rules.service.ts @@ -95,6 +95,10 @@ export class RulesService { return [...sortedModeAgents, ...otherAgents.sort()]; } + getRulesDir(): string { + return this.rulesDir; + } + isModeAgent(agentName: string): boolean { return (MODE_AGENTS as readonly string[]).includes(agentName); } From 2ae56b5f2bbed4de318a6b9d39052f8d16bdfdd1 Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Fri, 3 Apr 2026 19:28:25 +0900 Subject: [PATCH 2/3] fix(mcp): use unknown types in agent-stack.service.spec.ts mock params Replace fs.PathLike and fs.Dirent references (not exported from fs/promises) with unknown type assertions to fix CI type-check. --- .../src/agent/agent-stack.service.spec.ts | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/apps/mcp-server/src/agent/agent-stack.service.spec.ts b/apps/mcp-server/src/agent/agent-stack.service.spec.ts index 017b9416..8e00ba04 100644 --- a/apps/mcp-server/src/agent/agent-stack.service.spec.ts +++ b/apps/mcp-server/src/agent/agent-stack.service.spec.ts @@ -50,13 +50,13 @@ describe('AgentStackService', () => { describe('listStacks', () => { it('should return stacks from default location', async () => { - mockFs.readdir.mockImplementation(async (dirPath: fs.PathLike) => { + mockFs.readdir.mockImplementation(async (dirPath: unknown) => { if (String(dirPath).includes('.ai-rules/agent-stacks')) { - return ['api-development.json', 'frontend-review.json'] as unknown as fs.Dirent[]; + return ['api-development.json', 'frontend-review.json'] as unknown[]; } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); }); - mockFs.readFile.mockImplementation(async (filePath: fs.PathLike) => { + mockFs.readFile.mockImplementation(async (filePath: unknown) => { const p = String(filePath); if (p.includes('api-development.json')) return JSON.stringify(sampleStack); if (p.includes('frontend-review.json')) return JSON.stringify(sampleStack2); @@ -78,17 +78,17 @@ describe('AgentStackService', () => { specialists: ['security-specialist'], }; - mockFs.readdir.mockImplementation(async (dirPath: fs.PathLike) => { + mockFs.readdir.mockImplementation(async (dirPath: unknown) => { const p = String(dirPath); if (p.includes('.codingbuddy/agent-stacks')) { - return ['api-development.json'] as unknown as fs.Dirent[]; + return ['api-development.json'] as unknown[]; } if (p.includes('.ai-rules/agent-stacks')) { - return ['api-development.json', 'frontend-review.json'] as unknown as fs.Dirent[]; + return ['api-development.json', 'frontend-review.json'] as unknown[]; } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); }); - mockFs.readFile.mockImplementation(async (filePath: fs.PathLike) => { + mockFs.readFile.mockImplementation(async (filePath: unknown) => { const p = String(filePath); if (p.includes('.codingbuddy') && p.includes('api-development.json')) { return JSON.stringify(customStack); @@ -107,13 +107,13 @@ describe('AgentStackService', () => { }); it('should filter by category', async () => { - mockFs.readdir.mockImplementation(async (dirPath: fs.PathLike) => { + mockFs.readdir.mockImplementation(async (dirPath: unknown) => { if (String(dirPath).includes('.ai-rules/agent-stacks')) { - return ['api-development.json', 'frontend-review.json'] as unknown as fs.Dirent[]; + return ['api-development.json', 'frontend-review.json'] as unknown[]; } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); }); - mockFs.readFile.mockImplementation(async (filePath: fs.PathLike) => { + mockFs.readFile.mockImplementation(async (filePath: unknown) => { const p = String(filePath); if (p.includes('api-development.json')) return JSON.stringify(sampleStack); if (p.includes('frontend-review.json')) return JSON.stringify(sampleStack2); @@ -135,13 +135,13 @@ describe('AgentStackService', () => { }); it('should skip invalid JSON files gracefully', async () => { - mockFs.readdir.mockImplementation(async (dirPath: fs.PathLike) => { + mockFs.readdir.mockImplementation(async (dirPath: unknown) => { if (String(dirPath).includes('.ai-rules/agent-stacks')) { - return ['valid.json', 'invalid.json', 'not-json.txt'] as unknown as fs.Dirent[]; + return ['valid.json', 'invalid.json', 'not-json.txt'] as unknown[]; } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); }); - mockFs.readFile.mockImplementation(async (filePath: fs.PathLike) => { + mockFs.readFile.mockImplementation(async (filePath: unknown) => { const p = String(filePath); if (p.includes('valid.json')) return JSON.stringify(sampleStack); if (p.includes('invalid.json')) return 'not valid json {{{'; @@ -157,13 +157,13 @@ describe('AgentStackService', () => { it('should skip stacks missing required fields', async () => { const incompleteStack = { name: 'incomplete' }; - mockFs.readdir.mockImplementation(async (dirPath: fs.PathLike) => { + mockFs.readdir.mockImplementation(async (dirPath: unknown) => { if (String(dirPath).includes('.ai-rules/agent-stacks')) { - return ['valid.json', 'incomplete.json'] as unknown as fs.Dirent[]; + return ['valid.json', 'incomplete.json'] as unknown[]; } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); }); - mockFs.readFile.mockImplementation(async (filePath: fs.PathLike) => { + mockFs.readFile.mockImplementation(async (filePath: unknown) => { const p = String(filePath); if (p.includes('valid.json')) return JSON.stringify(sampleStack); if (p.includes('incomplete.json')) return JSON.stringify(incompleteStack); @@ -178,13 +178,13 @@ describe('AgentStackService', () => { describe('resolveStack', () => { it('should resolve stack by name from default location', async () => { - mockFs.readdir.mockImplementation(async (dirPath: fs.PathLike) => { + mockFs.readdir.mockImplementation(async (dirPath: unknown) => { if (String(dirPath).includes('.ai-rules/agent-stacks')) { - return ['api-development.json'] as unknown as fs.Dirent[]; + return ['api-development.json'] as unknown[]; } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); }); - mockFs.readFile.mockImplementation(async (filePath: fs.PathLike) => { + mockFs.readFile.mockImplementation(async (filePath: unknown) => { if (String(filePath).includes('api-development.json')) { return JSON.stringify(sampleStack); } @@ -204,17 +204,17 @@ describe('AgentStackService', () => { specialists: ['security-specialist', 'test-engineer', 'code-quality-specialist'], }; - mockFs.readdir.mockImplementation(async (dirPath: fs.PathLike) => { + mockFs.readdir.mockImplementation(async (dirPath: unknown) => { const p = String(dirPath); if (p.includes('.codingbuddy/agent-stacks')) { - return ['api-development.json'] as unknown as fs.Dirent[]; + return ['api-development.json'] as unknown[]; } if (p.includes('.ai-rules/agent-stacks')) { - return ['api-development.json'] as unknown as fs.Dirent[]; + return ['api-development.json'] as unknown[]; } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); }); - mockFs.readFile.mockImplementation(async (filePath: fs.PathLike) => { + mockFs.readFile.mockImplementation(async (filePath: unknown) => { const p = String(filePath); if (p.includes('.codingbuddy') && p.includes('api-development.json')) { return JSON.stringify(customStack); From e1e99ecbb0449d02e666192f57441c034a6f91e7 Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Fri, 3 Apr 2026 19:34:35 +0900 Subject: [PATCH 3/3] fix(mcp): resolve type-check failure in agent-stack.service.spec.ts Cast overloaded fs.readdir/readFile mocks to vi.fn() to bypass TypeScript overload resolution. Matches project convention used in custom.service.spec.ts. --- .../src/agent/agent-stack.service.spec.ts | 108 ++++++++---------- 1 file changed, 45 insertions(+), 63 deletions(-) diff --git a/apps/mcp-server/src/agent/agent-stack.service.spec.ts b/apps/mcp-server/src/agent/agent-stack.service.spec.ts index 8e00ba04..1f0c3f2b 100644 --- a/apps/mcp-server/src/agent/agent-stack.service.spec.ts +++ b/apps/mcp-server/src/agent/agent-stack.service.spec.ts @@ -8,6 +8,11 @@ vi.mock('fs/promises'); const mockFs = vi.mocked(fs); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mockReaddir = mockFs.readdir as any as ReturnType; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mockReadFile = mockFs.readFile as any as ReturnType; + describe('AgentStackService', () => { let service: AgentStackService; let mockRulesService: Partial; @@ -50,16 +55,15 @@ describe('AgentStackService', () => { describe('listStacks', () => { it('should return stacks from default location', async () => { - mockFs.readdir.mockImplementation(async (dirPath: unknown) => { - if (String(dirPath).includes('.ai-rules/agent-stacks')) { - return ['api-development.json', 'frontend-review.json'] as unknown[]; + mockReaddir.mockImplementation(async (dirPath: string) => { + if (dirPath.includes('.ai-rules/agent-stacks')) { + return ['api-development.json', 'frontend-review.json']; } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); }); - mockFs.readFile.mockImplementation(async (filePath: unknown) => { - const p = String(filePath); - if (p.includes('api-development.json')) return JSON.stringify(sampleStack); - if (p.includes('frontend-review.json')) return JSON.stringify(sampleStack2); + mockReadFile.mockImplementation(async (filePath: string) => { + if (filePath.includes('api-development.json')) return JSON.stringify(sampleStack); + if (filePath.includes('frontend-review.json')) return JSON.stringify(sampleStack2); throw new Error('Not found'); }); @@ -78,23 +82,21 @@ describe('AgentStackService', () => { specialists: ['security-specialist'], }; - mockFs.readdir.mockImplementation(async (dirPath: unknown) => { - const p = String(dirPath); - if (p.includes('.codingbuddy/agent-stacks')) { - return ['api-development.json'] as unknown[]; + mockReaddir.mockImplementation(async (dirPath: string) => { + if (dirPath.includes('.codingbuddy/agent-stacks')) { + return ['api-development.json']; } - if (p.includes('.ai-rules/agent-stacks')) { - return ['api-development.json', 'frontend-review.json'] as unknown[]; + if (dirPath.includes('.ai-rules/agent-stacks')) { + return ['api-development.json', 'frontend-review.json']; } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); }); - mockFs.readFile.mockImplementation(async (filePath: unknown) => { - const p = String(filePath); - if (p.includes('.codingbuddy') && p.includes('api-development.json')) { + mockReadFile.mockImplementation(async (filePath: string) => { + if (filePath.includes('.codingbuddy') && filePath.includes('api-development.json')) { return JSON.stringify(customStack); } - if (p.includes('api-development.json')) return JSON.stringify(sampleStack); - if (p.includes('frontend-review.json')) return JSON.stringify(sampleStack2); + if (filePath.includes('api-development.json')) return JSON.stringify(sampleStack); + if (filePath.includes('frontend-review.json')) return JSON.stringify(sampleStack2); throw new Error('Not found'); }); @@ -107,16 +109,15 @@ describe('AgentStackService', () => { }); it('should filter by category', async () => { - mockFs.readdir.mockImplementation(async (dirPath: unknown) => { - if (String(dirPath).includes('.ai-rules/agent-stacks')) { - return ['api-development.json', 'frontend-review.json'] as unknown[]; + mockReaddir.mockImplementation(async (dirPath: string) => { + if (dirPath.includes('.ai-rules/agent-stacks')) { + return ['api-development.json', 'frontend-review.json']; } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); }); - mockFs.readFile.mockImplementation(async (filePath: unknown) => { - const p = String(filePath); - if (p.includes('api-development.json')) return JSON.stringify(sampleStack); - if (p.includes('frontend-review.json')) return JSON.stringify(sampleStack2); + mockReadFile.mockImplementation(async (filePath: string) => { + if (filePath.includes('api-development.json')) return JSON.stringify(sampleStack); + if (filePath.includes('frontend-review.json')) return JSON.stringify(sampleStack2); throw new Error('Not found'); }); @@ -127,7 +128,7 @@ describe('AgentStackService', () => { }); it('should return empty array when no stack directories exist', async () => { - mockFs.readdir.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + mockReaddir.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); const stacks = await service.listStacks(); @@ -135,16 +136,15 @@ describe('AgentStackService', () => { }); it('should skip invalid JSON files gracefully', async () => { - mockFs.readdir.mockImplementation(async (dirPath: unknown) => { - if (String(dirPath).includes('.ai-rules/agent-stacks')) { - return ['valid.json', 'invalid.json', 'not-json.txt'] as unknown[]; + mockReaddir.mockImplementation(async (dirPath: string) => { + if (dirPath.includes('.ai-rules/agent-stacks')) { + return ['valid.json', 'invalid.json', 'not-json.txt']; } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); }); - mockFs.readFile.mockImplementation(async (filePath: unknown) => { - const p = String(filePath); - if (p.includes('valid.json')) return JSON.stringify(sampleStack); - if (p.includes('invalid.json')) return 'not valid json {{{'; + mockReadFile.mockImplementation(async (filePath: string) => { + if (filePath.includes('valid.json')) return JSON.stringify(sampleStack); + if (filePath.includes('invalid.json')) return 'not valid json {{{'; throw new Error('Not found'); }); @@ -157,16 +157,15 @@ describe('AgentStackService', () => { it('should skip stacks missing required fields', async () => { const incompleteStack = { name: 'incomplete' }; - mockFs.readdir.mockImplementation(async (dirPath: unknown) => { - if (String(dirPath).includes('.ai-rules/agent-stacks')) { - return ['valid.json', 'incomplete.json'] as unknown[]; + mockReaddir.mockImplementation(async (dirPath: string) => { + if (dirPath.includes('.ai-rules/agent-stacks')) { + return ['valid.json', 'incomplete.json']; } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); }); - mockFs.readFile.mockImplementation(async (filePath: unknown) => { - const p = String(filePath); - if (p.includes('valid.json')) return JSON.stringify(sampleStack); - if (p.includes('incomplete.json')) return JSON.stringify(incompleteStack); + mockReadFile.mockImplementation(async (filePath: string) => { + if (filePath.includes('valid.json')) return JSON.stringify(sampleStack); + if (filePath.includes('incomplete.json')) return JSON.stringify(incompleteStack); throw new Error('Not found'); }); @@ -178,14 +177,8 @@ describe('AgentStackService', () => { describe('resolveStack', () => { it('should resolve stack by name from default location', async () => { - mockFs.readdir.mockImplementation(async (dirPath: unknown) => { - if (String(dirPath).includes('.ai-rules/agent-stacks')) { - return ['api-development.json'] as unknown[]; - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - mockFs.readFile.mockImplementation(async (filePath: unknown) => { - if (String(filePath).includes('api-development.json')) { + mockReadFile.mockImplementation(async (filePath: string) => { + if (filePath.includes('api-development.json')) { return JSON.stringify(sampleStack); } throw new Error('Not found'); @@ -204,22 +197,11 @@ describe('AgentStackService', () => { specialists: ['security-specialist', 'test-engineer', 'code-quality-specialist'], }; - mockFs.readdir.mockImplementation(async (dirPath: unknown) => { - const p = String(dirPath); - if (p.includes('.codingbuddy/agent-stacks')) { - return ['api-development.json'] as unknown[]; - } - if (p.includes('.ai-rules/agent-stacks')) { - return ['api-development.json'] as unknown[]; - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - mockFs.readFile.mockImplementation(async (filePath: unknown) => { - const p = String(filePath); - if (p.includes('.codingbuddy') && p.includes('api-development.json')) { + mockReadFile.mockImplementation(async (filePath: string) => { + if (filePath.includes('.codingbuddy') && filePath.includes('api-development.json')) { return JSON.stringify(customStack); } - if (p.includes('api-development.json')) return JSON.stringify(sampleStack); + if (filePath.includes('api-development.json')) return JSON.stringify(sampleStack); throw new Error('Not found'); }); @@ -229,7 +211,7 @@ describe('AgentStackService', () => { }); it('should throw when stack not found', async () => { - mockFs.readdir.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + mockReadFile.mockRejectedValue(new Error('ENOENT')); await expect(service.resolveStack('non-existent')).rejects.toThrow( "Agent stack 'non-existent' not found",