Agregar servidor MCP para Gitea API
Some checks failed
build-and-deploy / build (push) Failing after 13s
build-and-deploy / deploy (push) Has been skipped

- Implementadas 5 herramientas optimizadas: repos, commits, issues, secrets, variables
- Descripciones compactas y claras para minimizar tokens
- Integración con Gitea API usando token de autenticación
- Enrutado en gitea.nucleoriofrio.com/mcp/* via Traefik
- Sin autenticación Authentik (acceso directo)
- Dockerfile y package.json configurados
- Workflow actualizado para build y deploy automático
- Variables de entorno agregadas al .env.example
This commit is contained in:
2025-10-14 00:16:33 -06:00
parent 0dc18d2154
commit 0f88dd4a91
9 changed files with 2132 additions and 0 deletions

View File

@@ -55,3 +55,12 @@ REGISTRY_PASSWORD=mi-password-secreto
# - X-authentik-name: nombre completo
# - X-authentik-groups: grupos del usuario (separados por |)
# - X-authentik-uid: ID único del usuario
# ===========================================
# MCP SERVERS
# ===========================================
# Servidor MCP para Gitea API
# El servidor estará disponible en: GITEA_DOMAIN/mcp (sin autenticación)
GITEA_URL=https://gitea.ejemplo.com
GITEA_DOMAIN=gitea.ejemplo.com
GITEA_TOKEN=token-de-gitea-aqui

View File

@@ -35,6 +35,13 @@ jobs:
docker push $REG/$REPO_OWNER/mcp-docker-server:${{ github.sha }}
docker push $REG/$REPO_OWNER/mcp-docker-server:latest
- name: Build+push MCP Gitea Server
run: |
cd mcp-gitea-server
docker build -t $REG/$REPO_OWNER/mcp-gitea-server:${{ github.sha }} -t $REG/$REPO_OWNER/mcp-gitea-server:latest .
docker push $REG/$REPO_OWNER/mcp-gitea-server:${{ github.sha }}
docker push $REG/$REPO_OWNER/mcp-gitea-server:latest
#───────────────── deploy ─────────────────
deploy:
needs: build
@@ -46,6 +53,10 @@ jobs:
# Variables de entorno para docker-compose
APP_DOMAIN: ${{ vars.APP_DOMAIN }}
NUXT_PUBLIC_APP_URL: ${{ vars.NUXT_PUBLIC_APP_URL }}
# Variables para MCP Gitea Server
GITEA_URL: ${{ vars.GITEA_URL }}
GITEA_DOMAIN: ${{ vars.GITEA_DOMAIN }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
steps:
- uses: actions/checkout@v3
- name: Login to registry

View File

@@ -80,6 +80,37 @@ services:
- "traefik.http.middlewares.${APP_NAME}-mcp-headers.headers.customrequestheaders.X-Forwarded-Proto=https"
- "traefik.http.routers.${APP_NAME}-mcp.middlewares=${APP_NAME}-mcp-stripprefix,${APP_NAME}-mcp-headers"
mcp-gitea:
image: ${REG}/${REPO_OWNER}/mcp-gitea-server:latest
container_name: ${APP_NAME}-mcp-gitea
restart: unless-stopped
environment:
- PORT=3000
- GITEA_URL=${GITEA_URL}
- GITEA_TOKEN=${GITEA_TOKEN}
networks:
- principal
- traefik-network
labels:
# Traefik labels - Exposición sin autenticación
- "traefik.enable=true"
- "traefik.docker.network=traefik-network"
# Service
- "traefik.http.services.${APP_NAME}-mcp-gitea.loadbalancer.server.port=3000"
# Router sin autenticación para /mcp en gitea.nucleoriofrio.com
- "traefik.http.routers.${APP_NAME}-mcp-gitea.rule=Host(`${GITEA_DOMAIN}`) && PathPrefix(`/mcp`)"
- "traefik.http.routers.${APP_NAME}-mcp-gitea.entrypoints=websecure"
- "traefik.http.routers.${APP_NAME}-mcp-gitea.tls.certresolver=letsencrypt"
- "traefik.http.routers.${APP_NAME}-mcp-gitea.priority=200"
- "traefik.http.routers.${APP_NAME}-mcp-gitea.service=${APP_NAME}-mcp-gitea"
# Middlewares para MCP Gitea
- "traefik.http.middlewares.${APP_NAME}-mcp-gitea-stripprefix.stripprefix.prefixes=/mcp"
- "traefik.http.middlewares.${APP_NAME}-mcp-gitea-headers.headers.customrequestheaders.X-Forwarded-Proto=https"
- "traefik.http.routers.${APP_NAME}-mcp-gitea.middlewares=${APP_NAME}-mcp-gitea-stripprefix,${APP_NAME}-mcp-gitea-headers"
networks:
principal:
external: true

4
mcp-gitea-server/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
*.log
.env

View File

@@ -0,0 +1,22 @@
FROM node:20-alpine
WORKDIR /app
# Copiar archivos de configuración
COPY package*.json ./
COPY tsconfig.json ./
# Instalar dependencias
RUN npm ci
# Copiar código fuente
COPY src ./src
# Compilar TypeScript
RUN npm run build
# Exponer puerto
EXPOSE 3000
# Comando de inicio
CMD ["npm", "start"]

1578
mcp-gitea-server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
{
"name": "mcp-gitea-server",
"version": "1.0.0",
"description": "MCP Server para operaciones con Gitea API",
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsc && node dist/index.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4",
"express": "^4.21.2",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.11.19",
"typescript": "^5.3.3"
}
}

View File

@@ -0,0 +1,435 @@
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamablehttp.js';
import express from 'express';
import { z } from 'zod';
// Configuración de Gitea
const GITEA_URL = process.env.GITEA_URL || 'https://gitea.nucleoriofrio.com';
const GITEA_TOKEN = process.env.GITEA_TOKEN || '';
const PORT = parseInt(process.env.PORT || '3000');
// Cliente HTTP para Gitea API
async function giteaRequest(endpoint: string, options: RequestInit = {}) {
const url = `${GITEA_URL}/api/v1${endpoint}`;
const headers = {
'Authorization': `token ${GITEA_TOKEN}`,
'Content-Type': 'application/json',
...options.headers,
};
const response = await fetch(url, { ...options, headers });
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Gitea API error (${response.status}): ${errorText}`);
}
// Si es DELETE o respuesta vacía, retornar objeto de éxito
if (response.status === 204 || response.headers.get('content-length') === '0') {
return { success: true };
}
return await response.json();
}
// Crear servidor MCP
const server = new Server(
{
name: 'mcp-gitea-server',
version: '1.0.0',
},
{
capabilities: {
tools: {},
},
}
);
// ==================== HERRAMIENTA 1: gitea_repos ====================
server.registerTool(
'gitea_repos',
{
title: 'Repositorios Gitea',
description: 'Gestiona repos: list (tus repos), search (buscar globalmente), get (info de repo), create (nuevo repo), update (editar propiedades). Requiere owner/repo según acción.',
inputSchema: {
action: z.enum(['list', 'search', 'get', 'create', 'update']).describe('Acción a realizar'),
owner: z.string().optional().describe('Propietario del repo (requerido para get/update)'),
repo: z.string().optional().describe('Nombre del repo (requerido para get/update)'),
query: z.string().optional().describe('Término de búsqueda (requerido para search)'),
name: z.string().optional().describe('Nombre del nuevo repo (requerido para create)'),
description: z.string().optional().describe('Descripción del repo (para create/update)'),
private: z.boolean().optional().describe('Si el repo es privado (para create/update)'),
auto_init: z.boolean().optional().describe('Inicializar con README (para create)'),
},
outputSchema: {
result: z.any().describe('Resultado de la operación')
}
},
async ({ action, owner, repo, query, name, description, private: isPrivate, auto_init }) => {
try {
let result;
switch (action) {
case 'list':
result = await giteaRequest('/user/repos');
break;
case 'search':
if (!query) throw new Error('query es requerido para search');
result = await giteaRequest(`/repos/search?q=${encodeURIComponent(query)}`);
break;
case 'get':
if (!owner || !repo) throw new Error('owner y repo son requeridos para get');
result = await giteaRequest(`/repos/${owner}/${repo}`);
break;
case 'create':
if (!name) throw new Error('name es requerido para create');
result = await giteaRequest('/user/repos', {
method: 'POST',
body: JSON.stringify({
name,
description: description || '',
private: isPrivate || false,
auto_init: auto_init || false,
}),
});
break;
case 'update':
if (!owner || !repo) throw new Error('owner y repo son requeridos para update');
const updateData: any = {};
if (description !== undefined) updateData.description = description;
if (isPrivate !== undefined) updateData.private = isPrivate;
result = await giteaRequest(`/repos/${owner}/${repo}`, {
method: 'PATCH',
body: JSON.stringify(updateData),
});
break;
}
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
structuredContent: { result }
};
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Error desconocido';
return {
content: [{ type: 'text', text: `Error: ${errorMsg}` }],
isError: true
};
}
}
);
// ==================== HERRAMIENTA 2: gitea_commits ====================
server.registerTool(
'gitea_commits',
{
title: 'Commits Gitea',
description: 'Opera commits: list (historial), get (ver commit por SHA), diff (cambios del commit). Para diff usa format: "diff" o "patch". Requiere owner/repo/sha según acción.',
inputSchema: {
action: z.enum(['list', 'get', 'diff']).describe('Acción a realizar'),
owner: z.string().describe('Propietario del repo'),
repo: z.string().describe('Nombre del repo'),
sha: z.string().optional().describe('SHA del commit (requerido para get/diff)'),
format: z.enum(['diff', 'patch']).optional().describe('Formato para diff (solo para action=diff)'),
page: z.number().optional().describe('Página de resultados (para list)'),
limit: z.number().optional().describe('Límite de resultados (para list, max 50)'),
},
outputSchema: {
result: z.any().describe('Resultado de la operación')
}
},
async ({ action, owner, repo, sha, format, page, limit }) => {
try {
let result;
switch (action) {
case 'list':
const params = new URLSearchParams();
if (page) params.append('page', page.toString());
if (limit) params.append('limit', Math.min(limit, 50).toString());
const queryString = params.toString() ? `?${params.toString()}` : '';
result = await giteaRequest(`/repos/${owner}/${repo}/commits${queryString}`);
break;
case 'get':
if (!sha) throw new Error('sha es requerido para get');
result = await giteaRequest(`/repos/${owner}/${repo}/git/commits/${sha}`);
break;
case 'diff':
if (!sha) throw new Error('sha es requerido para diff');
const diffFormat = format || 'diff';
result = await giteaRequest(`/repos/${owner}/${repo}/git/commits/${sha}.${diffFormat}`);
// El resultado es texto plano para diff/patch
return {
content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result, null, 2) }],
structuredContent: { result }
};
}
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
structuredContent: { result }
};
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Error desconocido';
return {
content: [{ type: 'text', text: `Error: ${errorMsg}` }],
isError: true
};
}
}
);
// ==================== HERRAMIENTA 3: gitea_issues ====================
server.registerTool(
'gitea_issues',
{
title: 'Issues Gitea',
description: 'Maneja issues: search (busca en todos tus repos), list (issues de un repo), create (nuevo issue). Para list/create requiere owner/repo.',
inputSchema: {
action: z.enum(['search', 'list', 'create']).describe('Acción a realizar'),
owner: z.string().optional().describe('Propietario del repo (requerido para list/create)'),
repo: z.string().optional().describe('Nombre del repo (requerido para list/create)'),
query: z.string().optional().describe('Término de búsqueda (para search)'),
title: z.string().optional().describe('Título del issue (requerido para create)'),
body: z.string().optional().describe('Descripción del issue (para create)'),
state: z.enum(['open', 'closed', 'all']).optional().describe('Estado de issues (para list)'),
},
outputSchema: {
result: z.any().describe('Resultado de la operación')
}
},
async ({ action, owner, repo, query, title, body, state }) => {
try {
let result;
switch (action) {
case 'search':
const searchParams = new URLSearchParams();
if (query) searchParams.append('q', query);
const searchQuery = searchParams.toString() ? `?${searchParams.toString()}` : '';
result = await giteaRequest(`/repos/issues/search${searchQuery}`);
break;
case 'list':
if (!owner || !repo) throw new Error('owner y repo son requeridos para list');
const listParams = new URLSearchParams();
if (state) listParams.append('state', state);
const listQuery = listParams.toString() ? `?${listParams.toString()}` : '';
result = await giteaRequest(`/repos/${owner}/${repo}/issues${listQuery}`);
break;
case 'create':
if (!owner || !repo) throw new Error('owner y repo son requeridos para create');
if (!title) throw new Error('title es requerido para create');
result = await giteaRequest(`/repos/${owner}/${repo}/issues`, {
method: 'POST',
body: JSON.stringify({
title,
body: body || '',
}),
});
break;
}
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
structuredContent: { result }
};
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Error desconocido';
return {
content: [{ type: 'text', text: `Error: ${errorMsg}` }],
isError: true
};
}
}
);
// ==================== HERRAMIENTA 4: gitea_secrets ====================
server.registerTool(
'gitea_secrets',
{
title: 'Secrets Gitea',
description: 'Gestiona secrets cifrados de Actions: list, set (crear/actualizar), delete. Scope: "repo" (requiere owner/repo) o "user" (tus secrets). Set requiere nombre y valor.',
inputSchema: {
action: z.enum(['list', 'set', 'delete']).describe('Acción a realizar'),
scope: z.enum(['repo', 'user']).describe('Ámbito del secret (repo o user)'),
owner: z.string().optional().describe('Propietario del repo (requerido si scope=repo)'),
repo: z.string().optional().describe('Nombre del repo (requerido si scope=repo)'),
name: z.string().optional().describe('Nombre del secret (requerido para set/delete)'),
value: z.string().optional().describe('Valor del secret (requerido para set)'),
},
outputSchema: {
result: z.any().describe('Resultado de la operación')
}
},
async ({ action, scope, owner, repo, name, value }) => {
try {
// Validar scope=repo requiere owner/repo
if (scope === 'repo' && (!owner || !repo)) {
throw new Error('owner y repo son requeridos cuando scope=repo');
}
const basePath = scope === 'repo'
? `/repos/${owner}/${repo}/actions/secrets`
: '/user/actions/secrets';
let result;
switch (action) {
case 'list':
result = await giteaRequest(basePath);
break;
case 'set':
if (!name) throw new Error('name es requerido para set');
if (!value) throw new Error('value es requerido para set');
result = await giteaRequest(`${basePath}/${name}`, {
method: 'PUT',
body: JSON.stringify({ data: value }),
});
break;
case 'delete':
if (!name) throw new Error('name es requerido para delete');
result = await giteaRequest(`${basePath}/${name}`, {
method: 'DELETE',
});
break;
}
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
structuredContent: { result }
};
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Error desconocido';
return {
content: [{ type: 'text', text: `Error: ${errorMsg}` }],
isError: true
};
}
}
);
// ==================== HERRAMIENTA 5: gitea_variables ====================
server.registerTool(
'gitea_variables',
{
title: 'Variables Gitea',
description: 'Gestiona variables de Actions: list, get (ver una), set (crear/actualizar), delete. Scope: "repo" (requiere owner/repo) o "user" (tus variables). Set requiere nombre y valor.',
inputSchema: {
action: z.enum(['list', 'get', 'set', 'delete']).describe('Acción a realizar'),
scope: z.enum(['repo', 'user']).describe('Ámbito de la variable (repo o user)'),
owner: z.string().optional().describe('Propietario del repo (requerido si scope=repo)'),
repo: z.string().optional().describe('Nombre del repo (requerido si scope=repo)'),
name: z.string().optional().describe('Nombre de la variable (requerido para get/set/delete)'),
value: z.string().optional().describe('Valor de la variable (requerido para set)'),
},
outputSchema: {
result: z.any().describe('Resultado de la operación')
}
},
async ({ action, scope, owner, repo, name, value }) => {
try {
// Validar scope=repo requiere owner/repo
if (scope === 'repo' && (!owner || !repo)) {
throw new Error('owner y repo son requeridos cuando scope=repo');
}
const basePath = scope === 'repo'
? `/repos/${owner}/${repo}/actions/variables`
: '/user/actions/variables';
let result;
switch (action) {
case 'list':
result = await giteaRequest(basePath);
break;
case 'get':
if (!name) throw new Error('name es requerido para get');
result = await giteaRequest(`${basePath}/${name}`);
break;
case 'set':
if (!name) throw new Error('name es requerido para set');
if (!value) throw new Error('value es requerido para set');
// Intentar actualizar (PUT) primero, si falla crear (POST)
try {
result = await giteaRequest(`${basePath}/${name}`, {
method: 'PUT',
body: JSON.stringify({ value }),
});
} catch (error) {
// Si el PUT falla (variable no existe), intentar POST
result = await giteaRequest(`${basePath}/${name}`, {
method: 'POST',
body: JSON.stringify({ value }),
});
}
break;
case 'delete':
if (!name) throw new Error('name es requerido para delete');
result = await giteaRequest(`${basePath}/${name}`, {
method: 'DELETE',
});
break;
}
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
structuredContent: { result }
};
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Error desconocido';
return {
content: [{ type: 'text', text: `Error: ${errorMsg}` }],
isError: true
};
}
}
);
// ==================== SERVIDOR EXPRESS ====================
const app = express();
// Health check
app.get('/health', (_req, res) => {
res.json({
status: 'ok',
service: 'mcp-gitea-server',
gitea_url: GITEA_URL,
token_configured: !!GITEA_TOKEN
});
});
// Endpoint MCP
app.post('/mcp', async (req, res) => {
const transport = new StreamableHTTPServerTransport({
sessionId: `session-${Date.now()}`,
req,
res,
server,
});
await transport.start();
await transport.runLoop();
});
// Iniciar servidor
app.listen(PORT, '0.0.0.0', () => {
console.log(`MCP Gitea Server corriendo en http://0.0.0.0:${PORT}/mcp`);
console.log(`Health check disponible en http://0.0.0.0:${PORT}/health`);
console.log(`Gitea URL: ${GITEA_URL}`);
console.log(`Token configurado: ${GITEA_TOKEN ? 'Sí' : 'No'}`);
});

View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}