Files
aiworker/docs/02-backend/mcp-server.md
Hector Ros db71705842 Complete documentation for future sessions
- 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>
2026-01-20 00:37:19 +01:00

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" })