- 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>
568 lines
12 KiB
Markdown
568 lines
12 KiB
Markdown
# Comunicación Agentes-Backend
|
|
|
|
## Arquitectura de Comunicación
|
|
|
|
```
|
|
┌─────────────────────┐
|
|
│ Claude Code Agent │
|
|
│ (Pod en K8s) │
|
|
└──────────┬──────────┘
|
|
│
|
|
│ MCP Protocol
|
|
│ (HTTP/JSON-RPC)
|
|
│
|
|
▼
|
|
┌─────────────────────┐
|
|
│ MCP Server │
|
|
│ (Backend Service) │
|
|
└──────────┬──────────┘
|
|
│
|
|
┌──────┴──────┐
|
|
│ │
|
|
▼ ▼
|
|
┌────────┐ ┌────────┐
|
|
│ MySQL │ │ Gitea │
|
|
└────────┘ └────────┘
|
|
```
|
|
|
|
## MCP Protocol Implementation
|
|
|
|
### Request Format
|
|
|
|
```json
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "tools/call",
|
|
"params": {
|
|
"name": "get_next_task",
|
|
"arguments": {
|
|
"agentId": "agent-uuid"
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Response Format
|
|
|
|
```json
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"result": {
|
|
"content": [
|
|
{
|
|
"type": "text",
|
|
"text": "{\"task\": {...}}"
|
|
}
|
|
]
|
|
}
|
|
}
|
|
```
|
|
|
|
## HTTP Client en Agente
|
|
|
|
```typescript
|
|
// agent/mcp-client.ts
|
|
class MCPClient {
|
|
private baseUrl: string
|
|
private agentId: string
|
|
|
|
constructor(baseUrl: string, agentId: string) {
|
|
this.baseUrl = baseUrl
|
|
this.agentId = agentId
|
|
}
|
|
|
|
async callTool(toolName: string, args: any) {
|
|
const response = await fetch(`${this.baseUrl}/rpc`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Agent-ID': this.agentId,
|
|
},
|
|
body: JSON.stringify({
|
|
jsonrpc: '2.0',
|
|
id: Date.now(),
|
|
method: 'tools/call',
|
|
params: {
|
|
name: toolName,
|
|
arguments: args,
|
|
},
|
|
}),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`MCP call failed: ${response.statusText}`)
|
|
}
|
|
|
|
const data = await response.json()
|
|
|
|
if (data.error) {
|
|
throw new Error(data.error.message)
|
|
}
|
|
|
|
return data.result
|
|
}
|
|
|
|
async listTools() {
|
|
const response = await fetch(`${this.baseUrl}/rpc`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Agent-ID': this.agentId,
|
|
},
|
|
body: JSON.stringify({
|
|
jsonrpc: '2.0',
|
|
id: Date.now(),
|
|
method: 'tools/list',
|
|
params: {},
|
|
}),
|
|
})
|
|
|
|
const data = await response.json()
|
|
return data.result.tools
|
|
}
|
|
}
|
|
|
|
// Usage
|
|
const mcp = new MCPClient(
|
|
process.env.MCP_SERVER_URL,
|
|
process.env.AGENT_ID
|
|
)
|
|
|
|
const task = await mcp.callTool('get_next_task', {
|
|
agentId: process.env.AGENT_ID,
|
|
})
|
|
```
|
|
|
|
## Server-Side Handler
|
|
|
|
```typescript
|
|
// backend: api/routes/mcp.ts
|
|
import { Router, Request, Response } from 'express'
|
|
import { handleToolCall } from '../../services/mcp/handlers'
|
|
import { tools } from '../../services/mcp/tools'
|
|
import { logger } from '../../utils/logger'
|
|
|
|
const router = Router()
|
|
|
|
// JSON-RPC endpoint
|
|
router.post('/rpc', async (req: Request, res: Response) => {
|
|
const { jsonrpc, id, method, params } = req.body
|
|
|
|
if (jsonrpc !== '2.0') {
|
|
return res.status(400).json({
|
|
jsonrpc: '2.0',
|
|
id,
|
|
error: {
|
|
code: -32600,
|
|
message: 'Invalid Request',
|
|
},
|
|
})
|
|
}
|
|
|
|
const agentId = req.headers['x-agent-id'] as string
|
|
|
|
if (!agentId) {
|
|
return res.status(401).json({
|
|
jsonrpc: '2.0',
|
|
id,
|
|
error: {
|
|
code: -32001,
|
|
message: 'Missing agent ID',
|
|
},
|
|
})
|
|
}
|
|
|
|
try {
|
|
switch (method) {
|
|
case 'tools/list':
|
|
return res.json({
|
|
jsonrpc: '2.0',
|
|
id,
|
|
result: {
|
|
tools: tools.map((t) => ({
|
|
name: t.name,
|
|
description: t.description,
|
|
inputSchema: t.inputSchema,
|
|
})),
|
|
},
|
|
})
|
|
|
|
case 'tools/call':
|
|
const { name, arguments: args } = params
|
|
|
|
logger.info(`MCP call from ${agentId}: ${name}`)
|
|
|
|
const result = await handleToolCall(name, {
|
|
...args,
|
|
agentId,
|
|
})
|
|
|
|
return res.json({
|
|
jsonrpc: '2.0',
|
|
id,
|
|
result,
|
|
})
|
|
|
|
default:
|
|
return res.status(404).json({
|
|
jsonrpc: '2.0',
|
|
id,
|
|
error: {
|
|
code: -32601,
|
|
message: 'Method not found',
|
|
},
|
|
})
|
|
}
|
|
} catch (error: any) {
|
|
logger.error('MCP error:', error)
|
|
|
|
return res.status(500).json({
|
|
jsonrpc: '2.0',
|
|
id,
|
|
error: {
|
|
code: -32603,
|
|
message: 'Internal error',
|
|
data: error.message,
|
|
},
|
|
})
|
|
}
|
|
})
|
|
|
|
export default router
|
|
```
|
|
|
|
## Heartbeat System
|
|
|
|
### Agent-Side Heartbeat
|
|
|
|
```bash
|
|
# In agent pod
|
|
while true; do
|
|
curl -s -X POST "$MCP_SERVER_URL/heartbeat" \
|
|
-H "Content-Type: application/json" \
|
|
-H "X-Agent-ID: $AGENT_ID" \
|
|
-d "{\"status\":\"idle\"}"
|
|
sleep 30
|
|
done &
|
|
```
|
|
|
|
### Server-Side Heartbeat Handler
|
|
|
|
```typescript
|
|
// api/routes/mcp.ts
|
|
router.post('/heartbeat', async (req: Request, res: Response) => {
|
|
const agentId = req.headers['x-agent-id'] as string
|
|
const { status } = req.body
|
|
|
|
if (!agentId) {
|
|
return res.status(401).json({ error: 'Missing agent ID' })
|
|
}
|
|
|
|
try {
|
|
await db.update(agents)
|
|
.set({
|
|
lastHeartbeat: new Date(),
|
|
status: status || 'idle',
|
|
})
|
|
.where(eq(agents.id, agentId))
|
|
|
|
res.json({ success: true })
|
|
} catch (error) {
|
|
res.status(500).json({ error: 'Failed to update heartbeat' })
|
|
}
|
|
})
|
|
```
|
|
|
|
## WebSocket for Real-Time Updates
|
|
|
|
Alternativamente, para comunicación bidireccional en tiempo real:
|
|
|
|
```typescript
|
|
// backend: api/websocket/agents.ts
|
|
import { Server as SocketIOServer } from 'socket.io'
|
|
|
|
export function setupAgentWebSocket(io: SocketIOServer) {
|
|
const agentNamespace = io.of('/agents')
|
|
|
|
agentNamespace.on('connection', (socket) => {
|
|
const agentId = socket.handshake.query.agentId as string
|
|
|
|
console.log(`Agent connected: ${agentId}`)
|
|
|
|
// Join agent room
|
|
socket.join(agentId)
|
|
|
|
// Heartbeat
|
|
socket.on('heartbeat', async (data) => {
|
|
await db.update(agents)
|
|
.set({
|
|
lastHeartbeat: new Date(),
|
|
status: data.status,
|
|
})
|
|
.where(eq(agents.id, agentId))
|
|
})
|
|
|
|
// Task updates
|
|
socket.on('task_update', async (data) => {
|
|
await db.update(tasks)
|
|
.set({ state: data.state })
|
|
.where(eq(tasks.id, data.taskId))
|
|
|
|
// Notify frontend
|
|
io.emit('task:status_changed', {
|
|
taskId: data.taskId,
|
|
newState: data.state,
|
|
})
|
|
})
|
|
|
|
socket.on('disconnect', () => {
|
|
console.log(`Agent disconnected: ${agentId}`)
|
|
})
|
|
})
|
|
|
|
// Send task assignment to specific agent
|
|
return {
|
|
assignTask: (agentId: string, task: any) => {
|
|
agentNamespace.to(agentId).emit('task_assigned', task)
|
|
},
|
|
}
|
|
}
|
|
```
|
|
|
|
## Authentication & Security
|
|
|
|
### JWT for Agents
|
|
|
|
```typescript
|
|
// Generate agent token
|
|
import jwt from 'jsonwebtoken'
|
|
|
|
export function generateAgentToken(agentId: string) {
|
|
return jwt.sign(
|
|
{
|
|
agentId,
|
|
type: 'agent',
|
|
},
|
|
process.env.JWT_SECRET!,
|
|
{
|
|
expiresIn: '7d',
|
|
}
|
|
)
|
|
}
|
|
|
|
// Verify middleware
|
|
export function verifyAgentToken(req: Request, res: Response, next: NextFunction) {
|
|
const token = req.headers.authorization?.replace('Bearer ', '')
|
|
|
|
if (!token) {
|
|
return res.status(401).json({ error: 'No token provided' })
|
|
}
|
|
|
|
try {
|
|
const decoded = jwt.verify(token, process.env.JWT_SECRET!)
|
|
req.agentId = decoded.agentId
|
|
next()
|
|
} catch (error) {
|
|
return res.status(401).json({ error: 'Invalid token' })
|
|
}
|
|
}
|
|
```
|
|
|
|
### mTLS (Optional)
|
|
|
|
Para seguridad adicional, usar mTLS entre agentes y backend:
|
|
|
|
```yaml
|
|
# Agent pod with client cert
|
|
volumes:
|
|
- name: agent-certs
|
|
secret:
|
|
secretName: agent-client-certs
|
|
|
|
volumeMounts:
|
|
- name: agent-certs
|
|
mountPath: /etc/certs
|
|
readOnly: true
|
|
|
|
env:
|
|
- name: MCP_CLIENT_CERT
|
|
value: /etc/certs/client.crt
|
|
- name: MCP_CLIENT_KEY
|
|
value: /etc/certs/client.key
|
|
```
|
|
|
|
## Retry & Error Handling
|
|
|
|
```typescript
|
|
// agent/mcp-client-with-retry.ts
|
|
class MCPClientWithRetry extends MCPClient {
|
|
async callToolWithRetry(
|
|
toolName: string,
|
|
args: any,
|
|
maxRetries = 3
|
|
) {
|
|
let lastError
|
|
|
|
for (let i = 0; i < maxRetries; i++) {
|
|
try {
|
|
return await this.callTool(toolName, args)
|
|
} catch (error: any) {
|
|
lastError = error
|
|
console.error(`Attempt ${i + 1} failed:`, error.message)
|
|
|
|
if (i < maxRetries - 1) {
|
|
// Exponential backoff
|
|
await sleep(Math.pow(2, i) * 1000)
|
|
}
|
|
}
|
|
}
|
|
|
|
throw lastError
|
|
}
|
|
}
|
|
```
|
|
|
|
## Circuit Breaker
|
|
|
|
```typescript
|
|
// agent/circuit-breaker.ts
|
|
class CircuitBreaker {
|
|
private failures = 0
|
|
private lastFailureTime = 0
|
|
private state: 'closed' | 'open' | 'half-open' = 'closed'
|
|
|
|
private readonly threshold = 5
|
|
private readonly timeout = 60000 // 1 minute
|
|
|
|
async call<T>(fn: () => Promise<T>): Promise<T> {
|
|
if (this.state === 'open') {
|
|
if (Date.now() - this.lastFailureTime > this.timeout) {
|
|
this.state = 'half-open'
|
|
} else {
|
|
throw new Error('Circuit breaker is open')
|
|
}
|
|
}
|
|
|
|
try {
|
|
const result = await fn()
|
|
|
|
if (this.state === 'half-open') {
|
|
this.state = 'closed'
|
|
this.failures = 0
|
|
}
|
|
|
|
return result
|
|
} catch (error) {
|
|
this.failures++
|
|
this.lastFailureTime = Date.now()
|
|
|
|
if (this.failures >= this.threshold) {
|
|
this.state = 'open'
|
|
}
|
|
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
// Usage
|
|
const breaker = new CircuitBreaker()
|
|
|
|
const task = await breaker.call(() =>
|
|
mcp.callTool('get_next_task', { agentId })
|
|
)
|
|
```
|
|
|
|
## Monitoring Communication
|
|
|
|
```typescript
|
|
// backend: middleware/mcp-metrics.ts
|
|
import { Request, Response, NextFunction } from 'express'
|
|
import { logger } from '../utils/logger'
|
|
|
|
const metrics = {
|
|
totalCalls: 0,
|
|
successCalls: 0,
|
|
failedCalls: 0,
|
|
callDurations: [] as number[],
|
|
}
|
|
|
|
export function mcpMetricsMiddleware(
|
|
req: Request,
|
|
res: Response,
|
|
next: NextFunction
|
|
) {
|
|
const start = Date.now()
|
|
metrics.totalCalls++
|
|
|
|
res.on('finish', () => {
|
|
const duration = Date.now() - start
|
|
metrics.callDurations.push(duration)
|
|
|
|
if (res.statusCode < 400) {
|
|
metrics.successCalls++
|
|
} else {
|
|
metrics.failedCalls++
|
|
}
|
|
|
|
logger.debug('MCP call metrics', {
|
|
method: req.body?.method,
|
|
agentId: req.headers['x-agent-id'],
|
|
duration,
|
|
status: res.statusCode,
|
|
})
|
|
})
|
|
|
|
next()
|
|
}
|
|
|
|
// Endpoint para ver métricas
|
|
router.get('/metrics', (req, res) => {
|
|
res.json({
|
|
total: metrics.totalCalls,
|
|
success: metrics.successCalls,
|
|
failed: metrics.failedCalls,
|
|
avgDuration:
|
|
metrics.callDurations.reduce((a, b) => a + b, 0) /
|
|
metrics.callDurations.length,
|
|
})
|
|
})
|
|
```
|
|
|
|
## Testing MCP Communication
|
|
|
|
```typescript
|
|
// test/mcp-client.test.ts
|
|
import { MCPClient } from '../agent/mcp-client'
|
|
|
|
describe('MCP Client', () => {
|
|
let client: MCPClient
|
|
|
|
beforeEach(() => {
|
|
client = new MCPClient('http://localhost:3100', 'test-agent')
|
|
})
|
|
|
|
it('should list available tools', async () => {
|
|
const tools = await client.listTools()
|
|
expect(tools).toContainEqual(
|
|
expect.objectContaining({ name: 'get_next_task' })
|
|
)
|
|
})
|
|
|
|
it('should call tool successfully', async () => {
|
|
const result = await client.callTool('heartbeat', {
|
|
status: 'idle',
|
|
})
|
|
expect(result.content[0].text).toContain('success')
|
|
})
|
|
|
|
it('should handle errors', async () => {
|
|
await expect(
|
|
client.callTool('invalid_tool', {})
|
|
).rejects.toThrow()
|
|
})
|
|
})
|
|
```
|