From 36c6a224e5006502808686dbe6d4160d5113d148 Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Fri, 3 Apr 2026 19:18:47 +0900 Subject: [PATCH] feat(mcp): add AgentStack schema and default stacks (#1182) - Add AgentStack interface to agent.types.ts - Add Zod validation schema in agent-stack.schema.ts - Add filesystem loader in agent-stack.loader.ts - Create 6 default stack presets: full-stack, api-development, frontend-polish, data-pipeline, security-audit, ml-infrastructure - Add comprehensive tests for schema validation and stack loading --- .../src/agent/agent-stack.loader.spec.ts | 96 +++++++++++++ .../src/agent/agent-stack.loader.ts | 56 ++++++++ .../src/agent/agent-stack.schema.spec.ts | 132 ++++++++++++++++++ .../src/agent/agent-stack.schema.ts | 24 ++++ apps/mcp-server/src/agent/agent.types.ts | 16 +++ apps/mcp-server/src/agent/index.ts | 2 + .../agent-stacks/api-development.json | 17 +++ .../.ai-rules/agent-stacks/data-pipeline.json | 12 ++ .../agent-stacks/frontend-polish.json | 17 +++ .../.ai-rules/agent-stacks/full-stack.json | 18 +++ .../agent-stacks/ml-infrastructure.json | 17 +++ .../agent-stacks/security-audit.json | 16 +++ 12 files changed, 423 insertions(+) create mode 100644 apps/mcp-server/src/agent/agent-stack.loader.spec.ts create mode 100644 apps/mcp-server/src/agent/agent-stack.loader.ts create mode 100644 apps/mcp-server/src/agent/agent-stack.schema.spec.ts create mode 100644 apps/mcp-server/src/agent/agent-stack.schema.ts create mode 100644 packages/rules/.ai-rules/agent-stacks/api-development.json create mode 100644 packages/rules/.ai-rules/agent-stacks/data-pipeline.json create mode 100644 packages/rules/.ai-rules/agent-stacks/frontend-polish.json create mode 100644 packages/rules/.ai-rules/agent-stacks/full-stack.json create mode 100644 packages/rules/.ai-rules/agent-stacks/ml-infrastructure.json create mode 100644 packages/rules/.ai-rules/agent-stacks/security-audit.json diff --git a/apps/mcp-server/src/agent/agent-stack.loader.spec.ts b/apps/mcp-server/src/agent/agent-stack.loader.spec.ts new file mode 100644 index 00000000..78f81e0b --- /dev/null +++ b/apps/mcp-server/src/agent/agent-stack.loader.spec.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { loadAgentStacks, loadAgentStack } from './agent-stack.loader'; +import * as fs from 'fs/promises'; + +vi.mock('fs/promises'); + +describe('agent-stack.loader', () => { + const validStackJson = JSON.stringify({ + name: 'full-stack', + description: 'Full-stack development team', + category: 'development', + tags: ['web', 'fullstack'], + primary_agent: 'software-engineer', + specialist_agents: ['frontend-developer', 'backend-developer'], + }); + + beforeEach(() => { + vi.resetAllMocks(); + }); + + describe('loadAgentStack', () => { + it('loads and validates a single stack JSON file', async () => { + vi.mocked(fs.readFile).mockResolvedValue(validStackJson); + + const result = await loadAgentStack('/path/to/full-stack.json'); + expect(result.name).toBe('full-stack'); + expect(result.primary_agent).toBe('software-engineer'); + expect(result.specialist_agents).toEqual(['frontend-developer', 'backend-developer']); + }); + + it('throws on invalid JSON', async () => { + vi.mocked(fs.readFile).mockResolvedValue('not json'); + + await expect(loadAgentStack('/path/to/bad.json')).rejects.toThrow(); + }); + + it('throws on invalid schema', async () => { + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify({ name: 'only-name' })); + + await expect(loadAgentStack('/path/to/invalid.json')).rejects.toThrow('Invalid agent stack'); + }); + }); + + describe('loadAgentStacks', () => { + it('loads all .json files from directory', async () => { + vi.mocked(fs.readdir).mockResolvedValue([ + { name: 'full-stack.json', isFile: () => true }, + { name: 'api-development.json', isFile: () => true }, + ] as unknown as Awaited>); + + vi.mocked(fs.readFile).mockResolvedValue(validStackJson); + + const result = await loadAgentStacks('/path/to/stacks'); + expect(result).toHaveLength(2); + expect(result[0].name).toBe('full-stack'); + }); + + it('skips non-json files', async () => { + vi.mocked(fs.readdir).mockResolvedValue([ + { name: 'full-stack.json', isFile: () => true }, + { name: 'README.md', isFile: () => true }, + { name: 'subdir', isFile: () => false }, + ] as unknown as Awaited>); + + vi.mocked(fs.readFile).mockResolvedValue(validStackJson); + + const result = await loadAgentStacks('/path/to/stacks'); + expect(result).toHaveLength(1); + }); + + it('returns empty array when directory does not exist', async () => { + vi.mocked(fs.readdir).mockRejectedValue(new Error('ENOENT')); + + const result = await loadAgentStacks('/path/to/missing'); + expect(result).toEqual([]); + }); + + it('skips individual files that fail validation', async () => { + vi.mocked(fs.readdir).mockResolvedValue([ + { name: 'valid.json', isFile: () => true }, + { name: 'invalid.json', isFile: () => true }, + ] as unknown as Awaited>); + + const invalidStackJson = JSON.stringify({ name: 'only-name' }); + vi.mocked(fs.readFile).mockImplementation(async (filePath: unknown) => { + const path = String(filePath); + if (path.endsWith('/valid.json')) return validStackJson; + return invalidStackJson; + }); + + const result = await loadAgentStacks('/path/to/stacks'); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('full-stack'); + }); + }); +}); diff --git a/apps/mcp-server/src/agent/agent-stack.loader.ts b/apps/mcp-server/src/agent/agent-stack.loader.ts new file mode 100644 index 00000000..a4d43053 --- /dev/null +++ b/apps/mcp-server/src/agent/agent-stack.loader.ts @@ -0,0 +1,56 @@ +import { readFile, readdir } from 'fs/promises'; +import { join } from 'path'; +import { AgentStackSchema } from './agent-stack.schema'; +import type { AgentStack } from './agent.types'; + +/** + * Load and validate a single agent stack JSON file. + * + * @param filePath - Absolute path to the stack JSON file + * @returns Validated AgentStack + * @throws Error if file cannot be read or fails validation + */ +export async function loadAgentStack(filePath: string): Promise { + const raw = await readFile(filePath, 'utf-8'); + const data: unknown = JSON.parse(raw); + const result = AgentStackSchema.safeParse(data); + + if (!result.success) { + const errors = result.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join(', '); + throw new Error(`Invalid agent stack in ${filePath}: ${errors}`); + } + + return result.data; +} + +/** + * Load all agent stack JSON files from a directory. + * + * Skips non-JSON files and files that fail validation. + * Returns empty array if directory does not exist. + * + * @param dirPath - Absolute path to the stacks directory + * @returns Array of validated AgentStacks + */ +export async function loadAgentStacks(dirPath: string): Promise { + let entries; + try { + entries = await readdir(dirPath, { withFileTypes: true }); + } catch { + return []; + } + + const jsonFiles = entries.filter(e => e.isFile() && e.name.endsWith('.json')); + const stacks: AgentStack[] = []; + + for (const entry of jsonFiles) { + try { + const stack = await loadAgentStack(join(dirPath, entry.name)); + stacks.push(stack); + } catch { + // Skip invalid files + } + } + + return stacks; +} diff --git a/apps/mcp-server/src/agent/agent-stack.schema.spec.ts b/apps/mcp-server/src/agent/agent-stack.schema.spec.ts new file mode 100644 index 00000000..57a083f0 --- /dev/null +++ b/apps/mcp-server/src/agent/agent-stack.schema.spec.ts @@ -0,0 +1,132 @@ +import { describe, it, expect } from 'vitest'; +import { AgentStackSchema } from './agent-stack.schema'; + +describe('AgentStackSchema', () => { + const validStack = { + name: 'full-stack', + description: 'Full-stack development team', + category: 'development', + tags: ['web', 'fullstack'], + primary_agent: 'software-engineer', + specialist_agents: ['frontend-developer', 'backend-developer'], + }; + + describe('valid data', () => { + it('accepts a complete AgentStack with all required fields', () => { + const result = AgentStackSchema.safeParse(validStack); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.name).toBe('full-stack'); + expect(result.data.specialist_agents).toHaveLength(2); + } + }); + + it('accepts a stack with recommended_for', () => { + const stackWithRecommended = { + ...validStack, + recommended_for: { + file_patterns: ['*.tsx', '*.css'], + modes: ['PLAN', 'ACT'], + }, + }; + const result = AgentStackSchema.safeParse(stackWithRecommended); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.recommended_for?.file_patterns).toEqual(['*.tsx', '*.css']); + expect(result.data.recommended_for?.modes).toEqual(['PLAN', 'ACT']); + } + }); + + it('accepts a stack with partial recommended_for (file_patterns only)', () => { + const stack = { + ...validStack, + recommended_for: { + file_patterns: ['*.py'], + }, + }; + const result = AgentStackSchema.safeParse(stack); + expect(result.success).toBe(true); + }); + + it('accepts a stack with partial recommended_for (modes only)', () => { + const stack = { + ...validStack, + recommended_for: { + modes: ['EVAL'], + }, + }; + const result = AgentStackSchema.safeParse(stack); + expect(result.success).toBe(true); + }); + + it('accepts a stack with empty tags array', () => { + const stack = { ...validStack, tags: [] }; + const result = AgentStackSchema.safeParse(stack); + expect(result.success).toBe(true); + }); + + it('accepts a stack with empty specialist_agents array', () => { + const stack = { ...validStack, specialist_agents: [] }; + const result = AgentStackSchema.safeParse(stack); + expect(result.success).toBe(true); + }); + }); + + describe('missing required fields', () => { + it('rejects when name is missing', () => { + const { name: _, ...stack } = validStack; + const result = AgentStackSchema.safeParse(stack); + expect(result.success).toBe(false); + }); + + it('rejects when description is missing', () => { + const { description: _, ...stack } = validStack; + const result = AgentStackSchema.safeParse(stack); + expect(result.success).toBe(false); + }); + + it('rejects when category is missing', () => { + const { category: _, ...stack } = validStack; + const result = AgentStackSchema.safeParse(stack); + expect(result.success).toBe(false); + }); + + it('rejects when tags is missing', () => { + const { tags: _, ...stack } = validStack; + const result = AgentStackSchema.safeParse(stack); + expect(result.success).toBe(false); + }); + + it('rejects when primary_agent is missing', () => { + const { primary_agent: _, ...stack } = validStack; + const result = AgentStackSchema.safeParse(stack); + expect(result.success).toBe(false); + }); + + it('rejects when specialist_agents is missing', () => { + const { specialist_agents: _, ...stack } = validStack; + const result = AgentStackSchema.safeParse(stack); + expect(result.success).toBe(false); + }); + }); + + describe('invalid types', () => { + it('rejects when name is not a string', () => { + const result = AgentStackSchema.safeParse({ ...validStack, name: 123 }); + expect(result.success).toBe(false); + }); + + it('rejects when tags is not an array', () => { + const result = AgentStackSchema.safeParse({ ...validStack, tags: 'web' }); + expect(result.success).toBe(false); + }); + + it('rejects when specialist_agents contains non-strings', () => { + const result = AgentStackSchema.safeParse({ + ...validStack, + specialist_agents: [123, true], + }); + expect(result.success).toBe(false); + }); + }); +}); diff --git a/apps/mcp-server/src/agent/agent-stack.schema.ts b/apps/mcp-server/src/agent/agent-stack.schema.ts new file mode 100644 index 00000000..94fff6cd --- /dev/null +++ b/apps/mcp-server/src/agent/agent-stack.schema.ts @@ -0,0 +1,24 @@ +import * as z from 'zod'; + +/** + * Zod schema for AgentStack validation. + * + * Validates agent stack JSON files with required fields + * and optional recommended_for configuration. + */ +export const AgentStackSchema = z.object({ + name: z.string(), + description: z.string(), + category: z.string(), + tags: z.array(z.string()), + primary_agent: z.string(), + specialist_agents: z.array(z.string()), + recommended_for: z + .object({ + file_patterns: z.array(z.string()).optional(), + modes: z.array(z.string()).optional(), + }) + .optional(), +}); + +export type ValidatedAgentStack = z.infer; diff --git a/apps/mcp-server/src/agent/agent.types.ts b/apps/mcp-server/src/agent/agent.types.ts index 60c2c08a..91dec403 100644 --- a/apps/mcp-server/src/agent/agent.types.ts +++ b/apps/mcp-server/src/agent/agent.types.ts @@ -1,5 +1,21 @@ import type { Mode } from '../keyword/keyword.types'; +/** + * Agent Stack - a pre-configured team of agents for common workflows + */ +export interface AgentStack { + name: string; + description: string; + category: string; + tags: string[]; + primary_agent: string; + specialist_agents: string[]; + recommended_for?: { + file_patterns?: string[]; + modes?: string[]; + }; +} + /** * Context for agent prompt generation */ diff --git a/apps/mcp-server/src/agent/index.ts b/apps/mcp-server/src/agent/index.ts index 280d8742..73c5d435 100644 --- a/apps/mcp-server/src/agent/index.ts +++ b/apps/mcp-server/src/agent/index.ts @@ -2,3 +2,5 @@ export * from './agent.module'; export * from './agent.service'; export * from './agent.types'; export * from './agent-prompt.builder'; +export * from './agent-stack.schema'; +export * from './agent-stack.loader'; diff --git a/packages/rules/.ai-rules/agent-stacks/api-development.json b/packages/rules/.ai-rules/agent-stacks/api-development.json new file mode 100644 index 00000000..37135849 --- /dev/null +++ b/packages/rules/.ai-rules/agent-stacks/api-development.json @@ -0,0 +1,17 @@ +{ + "name": "api-development", + "description": "API development team for building and maintaining backend services", + "category": "backend", + "tags": ["api", "backend", "rest", "graphql"], + "primary_agent": "backend-developer", + "specialist_agents": [ + "security-specialist", + "test-engineer", + "performance-specialist", + "documentation-specialist" + ], + "recommended_for": { + "file_patterns": ["*.controller.ts", "*.service.ts", "*.resolver.ts", "*.route.ts"], + "modes": ["PLAN", "ACT"] + } +} diff --git a/packages/rules/.ai-rules/agent-stacks/data-pipeline.json b/packages/rules/.ai-rules/agent-stacks/data-pipeline.json new file mode 100644 index 00000000..a6b8e6c6 --- /dev/null +++ b/packages/rules/.ai-rules/agent-stacks/data-pipeline.json @@ -0,0 +1,12 @@ +{ + "name": "data-pipeline", + "description": "Data pipeline team for data engineering, analysis, and monitoring", + "category": "data", + "tags": ["data", "pipeline", "etl", "analytics"], + "primary_agent": "data-engineer", + "specialist_agents": ["data-scientist", "performance-specialist", "observability-specialist"], + "recommended_for": { + "file_patterns": ["*.sql", "*.py", "*migration*", "*pipeline*"], + "modes": ["PLAN", "ACT"] + } +} diff --git a/packages/rules/.ai-rules/agent-stacks/frontend-polish.json b/packages/rules/.ai-rules/agent-stacks/frontend-polish.json new file mode 100644 index 00000000..b1d96b29 --- /dev/null +++ b/packages/rules/.ai-rules/agent-stacks/frontend-polish.json @@ -0,0 +1,17 @@ +{ + "name": "frontend-polish", + "description": "Frontend polish team for UI/UX refinement, accessibility, and performance", + "category": "frontend", + "tags": ["ui", "ux", "accessibility", "frontend", "seo"], + "primary_agent": "frontend-developer", + "specialist_agents": [ + "ui-ux-designer", + "accessibility-specialist", + "performance-specialist", + "seo-specialist" + ], + "recommended_for": { + "file_patterns": ["*.tsx", "*.css", "*.scss", "*.module.css"], + "modes": ["EVAL", "ACT"] + } +} diff --git a/packages/rules/.ai-rules/agent-stacks/full-stack.json b/packages/rules/.ai-rules/agent-stacks/full-stack.json new file mode 100644 index 00000000..75fbf4b0 --- /dev/null +++ b/packages/rules/.ai-rules/agent-stacks/full-stack.json @@ -0,0 +1,18 @@ +{ + "name": "full-stack", + "description": "Full-stack development team for end-to-end web application development", + "category": "development", + "tags": ["web", "fullstack", "frontend", "backend"], + "primary_agent": "software-engineer", + "specialist_agents": [ + "frontend-developer", + "backend-developer", + "security-specialist", + "test-engineer", + "performance-specialist" + ], + "recommended_for": { + "file_patterns": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "modes": ["PLAN", "ACT", "AUTO"] + } +} diff --git a/packages/rules/.ai-rules/agent-stacks/ml-infrastructure.json b/packages/rules/.ai-rules/agent-stacks/ml-infrastructure.json new file mode 100644 index 00000000..d525e9dc --- /dev/null +++ b/packages/rules/.ai-rules/agent-stacks/ml-infrastructure.json @@ -0,0 +1,17 @@ +{ + "name": "ml-infrastructure", + "description": "ML infrastructure team for AI/ML model development and deployment", + "category": "ml", + "tags": ["ml", "ai", "model", "inference", "training"], + "primary_agent": "ai-ml-engineer", + "specialist_agents": [ + "data-scientist", + "performance-specialist", + "observability-specialist", + "test-engineer" + ], + "recommended_for": { + "file_patterns": ["*.py", "*model*", "*predict*", "*train*"], + "modes": ["PLAN", "ACT", "EVAL"] + } +} diff --git a/packages/rules/.ai-rules/agent-stacks/security-audit.json b/packages/rules/.ai-rules/agent-stacks/security-audit.json new file mode 100644 index 00000000..cb30891f --- /dev/null +++ b/packages/rules/.ai-rules/agent-stacks/security-audit.json @@ -0,0 +1,16 @@ +{ + "name": "security-audit", + "description": "Security audit team for vulnerability assessment and security hardening", + "category": "security", + "tags": ["security", "audit", "vulnerability", "compliance"], + "primary_agent": "security-engineer", + "specialist_agents": [ + "security-specialist", + "test-strategy-specialist", + "code-quality-specialist" + ], + "recommended_for": { + "file_patterns": ["*auth*", "*token*", "*session*", "*crypto*"], + "modes": ["EVAL", "AUTO"] + } +}