Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions apps/mcp-server/src/agent/agent-stack.loader.spec.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof fs.readdir>>);

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<ReturnType<typeof fs.readdir>>);

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<ReturnType<typeof fs.readdir>>);

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');
});
});
});
56 changes: 56 additions & 0 deletions apps/mcp-server/src/agent/agent-stack.loader.ts
Original file line number Diff line number Diff line change
@@ -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<AgentStack> {
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<AgentStack[]> {
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;
}
132 changes: 132 additions & 0 deletions apps/mcp-server/src/agent/agent-stack.schema.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
24 changes: 24 additions & 0 deletions apps/mcp-server/src/agent/agent-stack.schema.ts
Original file line number Diff line number Diff line change
@@ -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<typeof AgentStackSchema>;
16 changes: 16 additions & 0 deletions apps/mcp-server/src/agent/agent.types.ts
Original file line number Diff line number Diff line change
@@ -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
*/
Expand Down
2 changes: 2 additions & 0 deletions apps/mcp-server/src/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
17 changes: 17 additions & 0 deletions packages/rules/.ai-rules/agent-stacks/api-development.json
Original file line number Diff line number Diff line change
@@ -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"]
}
}
12 changes: 12 additions & 0 deletions packages/rules/.ai-rules/agent-stacks/data-pipeline.json
Original file line number Diff line number Diff line change
@@ -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"]
}
}
17 changes: 17 additions & 0 deletions packages/rules/.ai-rules/agent-stacks/frontend-polish.json
Original file line number Diff line number Diff line change
@@ -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"]
}
}
Loading
Loading