- 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>
789 lines
18 KiB
Markdown
789 lines
18 KiB
Markdown
# 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<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í:
|
|
|
|
```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" })
|
|
```
|