ERP-node/mcp-agent-orchestrator/src/index.ts

480 lines
15 KiB
JavaScript

#!/usr/bin/env node
/**
b * Multi-Agent Orchestrator MCP Server v2.0
*
* Cursor Agent CLI를 활용한 멀티에이전트 시스템
* - PM (Cursor IDE): 전체 조율
* - Sub-agents (agent CLI): 전문가별 작업 수행
*
* 모든 AI 호출이 Cursor Team Plan으로 처리됨!
* API 키 불필요!
*/
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { spawn } from "child_process";
import { platform } from "os";
import { AGENT_CONFIGS } from "./agents/prompts.js";
import { AgentType, ParallelResult } from "./agents/types.js";
import { logger } from "./utils/logger.js";
// OS 감지
const isWindows = platform() === "win32";
logger.info(`Platform detected: ${platform()} (isWindows: ${isWindows})`);
// MCP 서버 생성
const server = new Server(
{
name: "agent-orchestrator",
version: "2.0.0",
},
{
capabilities: {
tools: {},
},
}
);
/**
* 유틸: ms만큼 대기
*/
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Cursor Agent CLI 단일 호출 (내부용)
* spawn + stdin 직접 전달
*/
function spawnAgentOnce(
agentType: AgentType,
fullPrompt: string,
model: string
): Promise<string> {
const agentPath = isWindows ? 'agent' : `${process.env.HOME}/.local/bin/agent`;
return new Promise<string>((resolve, reject) => {
let stdout = '';
let stderr = '';
let settled = false;
const child = spawn(agentPath, ['--model', model, '--print'], {
cwd: process.cwd(),
env: {
...process.env,
PATH: `${process.env.HOME}/.local/bin:${process.env.PATH}`,
},
stdio: ['pipe', 'pipe', 'pipe'],
});
child.stdout.on('data', (data: Buffer) => {
stdout += data.toString();
});
child.stderr.on('data', (data: Buffer) => {
stderr += data.toString();
});
child.on('error', (err: Error) => {
if (!settled) {
settled = true;
reject(err);
}
});
child.on('close', (code: number | null) => {
if (settled) return;
settled = true;
if (stderr) {
const significantStderr = stderr
.split('\n')
.filter((line: string) => line && !line.includes('warning') && !line.includes('info') && !line.includes('debug'))
.join('\n');
if (significantStderr) {
logger.warn(`${agentType} agent stderr`, { stderr: significantStderr.substring(0, 500) });
}
}
if (code === 0 || stdout.trim().length > 0) {
resolve(stdout.trim());
} else {
reject(new Error(
`Agent exited with code ${code}. stderr: ${stderr.substring(0, 1000)}`
));
}
});
// 타임아웃 (5분)
const timeout = setTimeout(() => {
if (!settled) {
settled = true;
child.kill('SIGTERM');
reject(new Error(`${agentType} agent timed out after 5 minutes`));
}
}, 300000);
child.on('close', () => clearTimeout(timeout));
// stdin으로 프롬프트 직접 전달
child.stdin.write(fullPrompt);
child.stdin.end();
});
}
/**
* Cursor Agent CLI를 통해 에이전트 호출 (재시도 포함)
*
* - 최대 2회 재시도 (총 3회 시도)
* - 재시도 간 2초 대기 (Cursor CLI 동시 실행 제한 대응)
*/
async function callAgentCLI(
agentType: AgentType,
task: string,
context?: string
): Promise<string> {
const config = AGENT_CONFIGS[agentType];
const model = agentType === 'pm' ? 'opus-4.5' : 'sonnet-4.5';
const maxRetries = 2;
logger.info(`Calling ${agentType} agent via CLI (spawn+retry)`, {
model,
task: task.substring(0, 100),
});
const userMessage = context
? `${task}\n\n배경 정보:\n${context}`
: task;
const fullPrompt = `${config.systemPrompt}\n\n---\n\n${userMessage}`;
let lastError: Error | null = null;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
if (attempt > 0) {
const delay = attempt * 2000; // 2초, 4초
logger.info(`${agentType} agent retry ${attempt}/${maxRetries} (waiting ${delay}ms)`);
await sleep(delay);
}
const result = await spawnAgentOnce(agentType, fullPrompt, model);
logger.info(`${agentType} agent completed (attempt ${attempt + 1})`);
return result;
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
logger.warn(`${agentType} agent attempt ${attempt + 1} failed`, {
error: lastError.message.substring(0, 200),
});
}
}
// 모든 재시도 실패
logger.error(`${agentType} agent failed after ${maxRetries + 1} attempts`);
throw lastError!;
}
/**
* 도구 목록 핸들러
*/
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "ask_backend_agent",
description:
"백엔드 전문가에게 질문하거나 작업을 요청합니다. " +
"API 설계, 서비스 로직, 라우팅, 미들웨어 관련 작업에 사용하세요. " +
"담당 폴더: backend-node/src/ (Cursor Agent CLI, sonnet-4.5 모델)" +
"주의: 단순 파일 읽기/수정은 PM이 직접 처리하세요. 깊은 분석이 필요할 때만 호출!",
inputSchema: {
type: "object" as const,
properties: {
task: {
type: "string",
description: "백엔드 에이전트에게 요청할 작업 내용",
},
context: {
type: "string",
description: "작업에 필요한 배경 정보 (선택사항)",
},
},
required: ["task"],
},
},
{
name: "ask_db_agent",
description:
"DB 전문가에게 질문하거나 작업을 요청합니다. " +
"스키마 설계, SQL 쿼리, MyBatis 매퍼, 마이그레이션 관련 작업에 사용하세요. " +
"담당 폴더: src/com/pms/mapper/, db/ (Cursor Agent CLI, sonnet-4.5 모델)" +
"주의: 단순 스키마 확인은 PM이 직접 처리하세요. 복잡한 쿼리 설계/최적화 시에만 호출!",
inputSchema: {
type: "object" as const,
properties: {
task: {
type: "string",
description: "DB 에이전트에게 요청할 작업 내용",
},
context: {
type: "string",
description: "작업에 필요한 배경 정보 (선택사항)",
},
},
required: ["task"],
},
},
{
name: "ask_frontend_agent",
description:
"프론트엔드 전문가에게 질문하거나 작업을 요청합니다. " +
"React 컴포넌트, 페이지, 스타일링, 상태관리 관련 작업에 사용하세요. " +
"담당 폴더: frontend/ (Cursor Agent CLI, sonnet-4.5 모델)" +
"주의: 단순 컴포넌트 읽기/수정은 PM이 직접 처리하세요. 구조 분석이 필요할 때만 호출!",
inputSchema: {
type: "object" as const,
properties: {
task: {
type: "string",
description: "프론트엔드 에이전트에게 요청할 작업 내용",
},
context: {
type: "string",
description: "작업에 필요한 배경 정보 (선택사항)",
},
},
required: ["task"],
},
},
{
name: "parallel_ask",
description:
"여러 전문가에게 동시에 질문합니다 (진짜 병렬 실행!). " +
"3개 영역(FE+BE+DB) 크로스도메인 분석이 필요할 때만 사용하세요. " +
"주의: 호출 시간이 오래 걸림! 단순 작업은 PM이 직접 처리하는 게 훨씬 빠릅니다. " +
"적합한 경우: 전체 아키텍처 파악, 대규모 리팩토링 계획, 크로스도메인 영향 분석",
inputSchema: {
type: "object" as const,
properties: {
requests: {
type: "array",
description: "각 에이전트에게 보낼 요청 목록",
items: {
type: "object",
properties: {
agent: {
type: "string",
enum: ["backend", "db", "frontend"],
description: "요청할 에이전트 타입",
},
task: {
type: "string",
description: "해당 에이전트에게 요청할 작업",
},
context: {
type: "string",
description: "배경 정보 (선택사항)",
},
},
required: ["agent", "task"],
},
},
},
required: ["requests"],
},
},
{
name: "get_agent_info",
description:
"에이전트 시스템의 현재 상태와 사용 가능한 에이전트 정보를 확인합니다.",
inputSchema: {
type: "object" as const,
properties: {},
},
},
],
};
});
/**
* 도구 호출 핸들러
*/
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
logger.info(`Tool called: ${name}`);
try {
switch (name) {
case "ask_backend_agent": {
const { task, context } = args as { task: string; context?: string };
const result = await callAgentCLI("backend", task, context);
return {
content: [{ type: "text" as const, text: result }],
};
}
case "ask_db_agent": {
const { task, context } = args as { task: string; context?: string };
const result = await callAgentCLI("db", task, context);
return {
content: [{ type: "text" as const, text: result }],
};
}
case "ask_frontend_agent": {
const { task, context } = args as { task: string; context?: string };
const result = await callAgentCLI("frontend", task, context);
return {
content: [{ type: "text" as const, text: result }],
};
}
case "parallel_ask": {
const { requests } = args as {
requests: Array<{
agent: "backend" | "db" | "frontend";
task: string;
context?: string;
}>;
};
logger.info(`Parallel ask to ${requests.length} agents (STAGGERED PARALLEL)`);
// 시차 병렬 실행: 각 에이전트를 500ms 간격으로 시작
// Cursor Agent CLI 동시 실행 제한 대응
const STAGGER_DELAY = 500; // ms
const results: ParallelResult[] = await Promise.all(
requests.map(async (req, index) => {
try {
// 시차 적용 (첫 번째는 즉시, 이후 500ms 간격)
if (index > 0) {
await sleep(index * STAGGER_DELAY);
}
const result = await callAgentCLI(req.agent, req.task, req.context);
return { agent: req.agent, result };
} catch (error) {
return {
agent: req.agent,
result: "",
error: error instanceof Error ? error.message : "Unknown error",
};
}
})
);
// 결과를 보기 좋게 포맷팅
const formattedResults = results.map((r) => {
const header = `\n${"=".repeat(60)}\n## ${r.agent.toUpperCase()} Agent 응답\n${"=".repeat(60)}\n`;
if (r.error) {
return `${header}❌ 에러: ${r.error}`;
}
return `${header}${r.result}`;
});
return {
content: [
{
type: "text" as const,
text: formattedResults.join("\n"),
},
],
};
}
case "get_agent_info": {
const info = {
system: "Multi-Agent Orchestrator v2.0",
version: "2.0.0",
backend: "Cursor Agent CLI (Team Plan)",
cliPath: `${process.env.HOME}/.local/bin/agent`,
apiKey: "NOT REQUIRED! Using Cursor Team Plan credits",
agents: {
pm: {
role: "Project Manager",
model: "opus-4.5 (Cursor IDE에서 직접)",
description: "전체 조율, 사용자 의도 파악, 작업 분배",
},
backend: {
role: "Backend Specialist",
model: "sonnet-4.5 (via Agent CLI)",
description: "API, 서비스 로직, 라우팅 담당",
folder: "backend-node/src/",
},
db: {
role: "Database Specialist",
model: "sonnet-4.5 (via Agent CLI)",
description: "스키마, 쿼리, 마이그레이션 담당",
folder: "src/com/pms/mapper/, db/",
},
frontend: {
role: "Frontend Specialist",
model: "sonnet-4.5 (via Agent CLI)",
description: "컴포넌트, 페이지, 스타일링 담당",
folder: "frontend/",
},
},
features: {
parallel_execution: true,
cursor_team_plan: true,
cursor_agent_cli: true,
separate_api_key: false,
cross_platform: true,
},
usage: {
single_agent: "ask_backend_agent, ask_db_agent, ask_frontend_agent",
parallel: "parallel_ask로 여러 에이전트 동시 호출",
workflow: "1. parallel_ask로 정보 수집 → 2. 개별 에이전트로 작업 분배",
},
};
return {
content: [
{
type: "text" as const,
text: JSON.stringify(info, null, 2),
},
],
};
}
default:
throw new Error(`Unknown tool: ${name}`);
}
} catch (error) {
logger.error(`Tool error: ${name}`, error);
return {
content: [
{
type: "text" as const,
text: `❌ 에러 발생: ${error instanceof Error ? error.message : "Unknown error"}`,
},
],
isError: true,
};
}
});
/**
* 서버 시작
*/
async function main() {
logger.info("Starting Multi-Agent Orchestrator MCP Server v2.0...");
logger.info(`Backend: Cursor Agent CLI (${process.env.HOME}/.local/bin/agent)`);
logger.info("Credits: Cursor Team Plan - No API Key Required!");
const transport = new StdioServerTransport();
await server.connect(transport);
logger.info("MCP Server connected and ready!");
}
main().catch((error) => {
logger.error("Server failed to start", error);
process.exit(1);
});