Agregar servidor MCP para Gitea API
- 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:
4
mcp-gitea-server/.gitignore
vendored
Normal file
4
mcp-gitea-server/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.env
|
||||
22
mcp-gitea-server/Dockerfile
Normal file
22
mcp-gitea-server/Dockerfile
Normal 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
1578
mcp-gitea-server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
mcp-gitea-server/package.json
Normal file
22
mcp-gitea-server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
435
mcp-gitea-server/src/index.ts
Normal file
435
mcp-gitea-server/src/index.ts
Normal 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'}`);
|
||||
});
|
||||
20
mcp-gitea-server/tsconfig.json
Normal file
20
mcp-gitea-server/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user