# MCP Server para Agentes El MCP (Model Context Protocol) Server es la interfaz que permite a los agentes Claude Code comunicarse con el backend y ejecutar operaciones. ## Arquitectura MCP ``` ┌─────────────────┐ ┌─────────────────┐ │ Claude Code │ MCP Protocol │ MCP Server │ │ (Agent Pod) │◄──────────────────►│ (Backend) │ └─────────────────┘ └─────────────────┘ │ ┌─────────────────────┼─────────────────────┐ │ │ │ ┌────▼────┐ ┌────▼────┐ ┌────▼────┐ │ MySQL │ │ Gitea │ │ K8s │ └─────────┘ └─────────┘ └─────────┘ ``` ## Setup del MCP Server ```typescript // services/mcp/server.ts 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 { tools } from './tools' import { handleToolCall } from './handlers' import { logger } from '../../utils/logger' export class AgentMCPServer { private server: Server constructor() { this.server = new Server( { name: 'aiworker-orchestrator', version: '1.0.0', }, { capabilities: { tools: {}, }, } ) this.setupHandlers() } private setupHandlers() { // List available tools this.server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: tools.map(tool => ({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema, })) } }) // Handle tool calls this.server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params logger.info(`MCP: Tool called: ${name}`, { args }) try { const result = await handleToolCall(name, args) return result } catch (error) { logger.error(`MCP: Tool error: ${name}`, error) return { content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true } } }) } async start() { const transport = new StdioServerTransport() await this.server.connect(transport) logger.info('MCP Server started') } } // Start MCP server let mcpServer: AgentMCPServer export async function startMCPServer() { mcpServer = new AgentMCPServer() await mcpServer.start() return mcpServer } export function getMCPServer() { return mcpServer } ``` ## Definición de Herramientas ```typescript // services/mcp/tools.ts import { z } from 'zod' export const tools = [ { name: 'get_next_task', description: 'Obtiene la siguiente tarea disponible de la cola', inputSchema: { type: 'object', properties: { agentId: { type: 'string', description: 'ID del agente solicitante' }, capabilities: { type: 'array', items: { type: 'string' }, description: 'Capacidades del agente (ej: ["javascript", "react"])' } }, required: ['agentId'] } }, { name: 'update_task_status', description: 'Actualiza el estado de una tarea', inputSchema: { type: 'object', properties: { taskId: { type: 'string', description: 'ID de la tarea' }, status: { type: 'string', enum: ['in_progress', 'needs_input', 'ready_to_test', 'completed'], description: 'Nuevo estado' }, metadata: { type: 'object', description: 'Metadata adicional (duración, errores, etc.)' } }, required: ['taskId', 'status'] } }, { name: 'ask_user_question', description: 'Solicita información al usuario', inputSchema: { type: 'object', properties: { taskId: { type: 'string', description: 'ID de la tarea' }, question: { type: 'string', description: 'Pregunta para el usuario' }, context: { type: 'string', description: 'Contexto adicional' } }, required: ['taskId', 'question'] } }, { name: 'check_question_response', description: 'Verifica si el usuario ha respondido una pregunta', inputSchema: { type: 'object', properties: { taskId: { type: 'string', description: 'ID de la tarea' } }, required: ['taskId'] } }, { name: 'create_branch', description: 'Crea una nueva rama en Gitea', inputSchema: { type: 'object', properties: { taskId: { type: 'string', description: 'ID de la tarea' }, branchName: { type: 'string', description: 'Nombre de la rama (opcional, se genera automático)' } }, required: ['taskId'] } }, { name: 'create_pull_request', description: 'Crea un Pull Request en Gitea', inputSchema: { type: 'object', properties: { taskId: { type: 'string', description: 'ID de la tarea' }, title: { type: 'string', description: 'Título del PR' }, description: { type: 'string', description: 'Descripción del PR' } }, required: ['taskId', 'title', 'description'] } }, { name: 'trigger_preview_deploy', description: 'Despliega un preview environment en K8s', inputSchema: { type: 'object', properties: { taskId: { type: 'string', description: 'ID de la tarea' } }, required: ['taskId'] } }, { name: 'get_task_details', description: 'Obtiene detalles completos de una tarea', inputSchema: { type: 'object', properties: { taskId: { type: 'string', description: 'ID de la tarea' } }, required: ['taskId'] } }, { name: 'log_activity', description: 'Registra actividad del agente', inputSchema: { type: 'object', properties: { agentId: { type: 'string', description: 'ID del agente' }, level: { type: 'string', enum: ['debug', 'info', 'warn', 'error'], description: 'Nivel de log' }, message: { type: 'string', description: 'Mensaje' }, metadata: { type: 'object', description: 'Metadata adicional' } }, required: ['agentId', 'message'] } }, { name: 'heartbeat', description: 'Envía heartbeat para indicar que el agente está activo', inputSchema: { type: 'object', properties: { agentId: { type: 'string', description: 'ID del agente' }, status: { type: 'string', enum: ['idle', 'busy', 'error'], description: 'Estado actual' } }, required: ['agentId', 'status'] } } ] ``` ## Implementación de Handlers ```typescript // services/mcp/handlers.ts import { db } from '../../db/client' import { tasks, agents, taskQuestions, agentLogs } from '../../db/schema' import { eq, and, desc, asc } from 'drizzle-orm' import { GiteaClient } from '../gitea/client' import { K8sClient } from '../kubernetes/client' import { getRedis } from '../../config/redis' import { emitWebSocketEvent } from '../../api/websocket/server' import crypto from 'crypto' const giteaClient = new GiteaClient() const k8sClient = new K8sClient() const redis = getRedis() export async function handleToolCall(name: string, args: any) { switch (name) { case 'get_next_task': return await getNextTask(args) case 'update_task_status': return await updateTaskStatus(args) case 'ask_user_question': return await askUserQuestion(args) case 'check_question_response': return await checkQuestionResponse(args) case 'create_branch': return await createBranch(args) case 'create_pull_request': return await createPullRequest(args) case 'trigger_preview_deploy': return await triggerPreviewDeploy(args) case 'get_task_details': return await getTaskDetails(args) case 'log_activity': return await logActivity(args) case 'heartbeat': return await heartbeat(args) default: throw new Error(`Unknown tool: ${name}`) } } // ============================================ // TOOL IMPLEMENTATIONS // ============================================ async function getNextTask(args: { agentId: string; capabilities?: string[] }) { const { agentId } = args // Get next task from backlog const task = await db.query.tasks.findFirst({ where: eq(tasks.state, 'backlog'), with: { project: true }, orderBy: [desc(tasks.priority), asc(tasks.createdAt)] }) if (!task) { return { content: [{ type: 'text', text: JSON.stringify({ message: 'No tasks available' }) }] } } // Assign task to agent await db.update(tasks) .set({ state: 'in_progress', assignedAgentId: agentId, assignedAt: new Date(), startedAt: new Date() }) .where(eq(tasks.id, task.id)) await db.update(agents) .set({ status: 'busy', currentTaskId: task.id }) .where(eq(agents.id, agentId)) // Emit WebSocket event emitWebSocketEvent('task:status_changed', { taskId: task.id, oldState: 'backlog', newState: 'in_progress', agentId }) // Cache invalidation await redis.del(`task:${task.id}`) await redis.del(`task:list:${task.projectId}`) return { content: [{ type: 'text', text: JSON.stringify({ task: { id: task.id, title: task.title, description: task.description, priority: task.priority, project: task.project } }) }] } } async function updateTaskStatus(args: { taskId: string; status: string; metadata?: any }) { const { taskId, status, metadata } = args const updates: any = { state: status } if (status === 'completed') { updates.completedAt = new Date() } if (metadata?.durationMinutes) { updates.actualDurationMinutes = metadata.durationMinutes } await db.update(tasks) .set(updates) .where(eq(tasks.id, taskId)) // If task completed, free up agent if (status === 'completed' || status === 'ready_to_test') { const task = await db.query.tasks.findFirst({ where: eq(tasks.id, taskId) }) if (task?.assignedAgentId) { await db.update(agents) .set({ status: 'idle', currentTaskId: null, tasksCompleted: db.$sql`tasks_completed + 1` }) .where(eq(agents.id, task.assignedAgentId)) } } emitWebSocketEvent('task:status_changed', { taskId, newState: status, metadata }) await redis.del(`task:${taskId}`) return { content: [{ type: 'text', text: JSON.stringify({ success: true }) }] } } async function askUserQuestion(args: { taskId: string; question: string; context?: string }) { const { taskId, question, context } = args // Update task state await db.update(tasks) .set({ state: 'needs_input' }) .where(eq(tasks.id, taskId)) // Insert question const questionId = crypto.randomUUID() await db.insert(taskQuestions).values({ id: questionId, taskId, question, context, status: 'pending' }) // Notify frontend emitWebSocketEvent('task:needs_input', { taskId, questionId, question, context }) await redis.del(`task:${taskId}`) return { content: [{ type: 'text', text: JSON.stringify({ success: true, message: 'Question sent to user', questionId }) }] } } async function checkQuestionResponse(args: { taskId: string }) { const { taskId } = args const question = await db.query.taskQuestions.findFirst({ where: and( eq(taskQuestions.taskId, taskId), eq(taskQuestions.status, 'answered') ), orderBy: [desc(taskQuestions.respondedAt)] }) if (!question || !question.response) { return { content: [{ type: 'text', text: JSON.stringify({ hasResponse: false, message: 'No response yet' }) }] } } // Update task back to in_progress await db.update(tasks) .set({ state: 'in_progress' }) .where(eq(tasks.id, taskId)) return { content: [{ type: 'text', text: JSON.stringify({ hasResponse: true, response: question.response, question: question.question }) }] } } async function createBranch(args: { taskId: string; branchName?: string }) { const { taskId, branchName } = args const task = await db.query.tasks.findFirst({ where: eq(tasks.id, taskId), with: { project: true } }) if (!task) { throw new Error('Task not found') } const branch = branchName || `task-${taskId.slice(0, 8)}-${task.title.toLowerCase().replace(/\s+/g, '-').slice(0, 30)}` // Create branch in Gitea await giteaClient.createBranch( task.project.giteaOwner!, task.project.giteaRepoName!, branch, task.project.defaultBranch! ) // Update task await db.update(tasks) .set({ branchName: branch }) .where(eq(tasks.id, taskId)) return { content: [{ type: 'text', text: JSON.stringify({ success: true, branchName: branch, repoUrl: task.project.giteaRepoUrl }) }] } } async function createPullRequest(args: { taskId: string; title: string; description: string }) { const { taskId, title, description } = args const task = await db.query.tasks.findFirst({ where: eq(tasks.id, taskId), with: { project: true } }) if (!task || !task.branchName) { throw new Error('Task not found or branch not created') } const pr = await giteaClient.createPullRequest( task.project.giteaOwner!, task.project.giteaRepoName!, { title, body: description, head: task.branchName, base: task.project.defaultBranch! } ) await db.update(tasks) .set({ prNumber: pr.number, prUrl: pr.html_url }) .where(eq(tasks.id, taskId)) emitWebSocketEvent('task:pr_created', { taskId, prUrl: pr.html_url, prNumber: pr.number }) return { content: [{ type: 'text', text: JSON.stringify({ success: true, prUrl: pr.html_url, prNumber: pr.number }) }] } } async function triggerPreviewDeploy(args: { taskId: string }) { const { taskId } = args const task = await db.query.tasks.findFirst({ where: eq(tasks.id, taskId), with: { project: true } }) if (!task) { throw new Error('Task not found') } const previewNamespace = `preview-task-${taskId.slice(0, 8)}` const previewUrl = `https://${previewNamespace}.preview.aiworker.dev` // Deploy to K8s await k8sClient.createPreviewDeployment({ namespace: previewNamespace, taskId, projectId: task.projectId, image: task.project.dockerImage!, branch: task.branchName!, envVars: task.project.envVars as Record }) await db.update(tasks) .set({ state: 'ready_to_test', previewNamespace, previewUrl, previewDeployedAt: new Date() }) .where(eq(tasks.id, taskId)) emitWebSocketEvent('task:ready_to_test', { taskId, previewUrl }) return { content: [{ type: 'text', text: JSON.stringify({ success: true, previewUrl, namespace: previewNamespace }) }] } } async function getTaskDetails(args: { taskId: string }) { const { taskId } = args const task = await db.query.tasks.findFirst({ where: eq(tasks.id, taskId), with: { project: true, questions: true } }) if (!task) { throw new Error('Task not found') } return { content: [{ type: 'text', text: JSON.stringify({ task }) }] } } async function logActivity(args: { agentId: string; level?: string; message: string; metadata?: any }) { const { agentId, level = 'info', message, metadata } = args await db.insert(agentLogs).values({ agentId, level: level as any, message, metadata }) return { content: [{ type: 'text', text: JSON.stringify({ success: true }) }] } } async function heartbeat(args: { agentId: string; status: string }) { const { agentId, status } = args await db.update(agents) .set({ lastHeartbeat: new Date(), status: status as any }) .where(eq(agents.id, agentId)) return { content: [{ type: 'text', text: JSON.stringify({ success: true }) }] } } ``` ## Uso desde Claude Code Agent Desde el pod del agente, Claude Code usaría las herramientas así: ```bash # En el pod del agente, configurar MCP # claude-code config add-mcp-server aiworker stdio \ # "bun run /app/mcp-client.js" # Ejemplo de uso en conversación con Claude Code: # User: "Toma la siguiente tarea y trabaja en ella" # Claude Code internamente llama: # - get_next_task({ agentId: "agent-xyz" }) # - Si necesita info: ask_user_question({ taskId: "...", question: "..." }) # - Trabaja en el código # - create_branch({ taskId: "..." }) # - (commits and pushes) # - create_pull_request({ taskId: "...", title: "...", description: "..." }) # - trigger_preview_deploy({ taskId: "..." }) # - update_task_status({ taskId: "...", status: "ready_to_test" }) ```