- CLAUDE.md for AI agents to understand the codebase - GITEA-GUIDE.md centralizes all Gitea operations (API, Registry, Auth) - DEVELOPMENT-WORKFLOW.md explains complete dev process - ROADMAP.md, NEXT-SESSION.md for planning - QUICK-REFERENCE.md, TROUBLESHOOTING.md for daily use - 40+ detailed docs in /docs folder - Backend as submodule from Gitea Everything documented for autonomous operation. Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>
18 KiB
18 KiB
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
// 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
// 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
// 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<string, string>
})
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í:
# 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" })