From a84c7b911441cd39e721ece6a3cdc3aaa3fe38db Mon Sep 17 00:00:00 2001 From: josedario87 Date: Mon, 13 Oct 2025 19:12:34 -0600 Subject: [PATCH] Implementar MCP Docker Server personalizado MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Creado servidor MCP en TypeScript con @modelcontextprotocol/sdk - Implementadas 13 herramientas Docker seguras usando dockerode: * docker_ps: Listar contenedores * docker_logs: Ver logs de contenedores * docker_inspect: Inspeccionar contenedor * docker_stats: Estadísticas de recursos * docker_top: Procesos del contenedor * docker_start/stop/restart: Gestión de contenedores * docker_exec: Ejecutar comandos * docker_images/networks/volumes: Listar recursos * docker_info: Información del sistema - Configurado servidor HTTP con Express en puerto 3000 - Agregado endpoint /mcp para protocolo MCP - Agregado health check en /health - Actualizado docker-compose.yml para usar imagen personalizada - Configurado GitHub Actions para build y deploy automático - Socket Docker montado en modo solo lectura para seguridad --- .github/workflows/deploy-mcp-docker.yml | 50 +++ docker-compose.yml | 13 +- mcp-docker-server/.gitignore | 5 + mcp-docker-server/Dockerfile | 22 + mcp-docker-server/README.md | 50 +++ mcp-docker-server/package.json | 25 ++ mcp-docker-server/src/index.ts | 533 ++++++++++++++++++++++++ mcp-docker-server/tsconfig.json | 20 + 8 files changed, 712 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/deploy-mcp-docker.yml create mode 100644 mcp-docker-server/.gitignore create mode 100644 mcp-docker-server/Dockerfile create mode 100644 mcp-docker-server/README.md create mode 100644 mcp-docker-server/package.json create mode 100644 mcp-docker-server/src/index.ts create mode 100644 mcp-docker-server/tsconfig.json diff --git a/.github/workflows/deploy-mcp-docker.yml b/.github/workflows/deploy-mcp-docker.yml new file mode 100644 index 0000000..ead195e --- /dev/null +++ b/.github/workflows/deploy-mcp-docker.yml @@ -0,0 +1,50 @@ +name: Deploy MCP Docker Server + +on: + push: + branches: + - master + paths: + - 'mcp-docker-server/**' + - '.github/workflows/deploy-mcp-docker.yml' + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Gitea Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ secrets.REGISTRY_URL }} + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: ./mcp-docker-server + push: true + tags: | + ${{ secrets.REGISTRY_URL }}/${{ secrets.REPO_OWNER }}/mcp-docker-server:latest + ${{ secrets.REGISTRY_URL }}/${{ secrets.REPO_OWNER }}/mcp-docker-server:${{ github.sha }} + cache-from: type=registry,ref=${{ secrets.REGISTRY_URL }}/${{ secrets.REPO_OWNER }}/mcp-docker-server:buildcache + cache-to: type=registry,ref=${{ secrets.REGISTRY_URL }}/${{ secrets.REPO_OWNER }}/mcp-docker-server:buildcache,mode=max + + - name: Deploy to server + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + key: ${{ secrets.SERVER_SSH_KEY }} + script: | + cd ${{ secrets.DEPLOY_PATH }} + docker compose pull mcp-docker + docker compose up -d mcp-docker + docker image prune -f diff --git a/docker-compose.yml b/docker-compose.yml index bc49858..9f995a7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,10 +49,11 @@ services: - "traefik.http.middlewares.${APP_NAME}-cors.headers.addvaryheader=true" mcp-docker: - image: docker:cli + image: ${REG}/${REPO_OWNER}/mcp-docker-server:latest container_name: ${APP_NAME}-mcp-docker restart: unless-stopped - command: mcp gateway run --port 8080 --transport streaming + environment: + - PORT=3000 volumes: # Montar el socket de Docker para acceso al daemon - /var/run/docker.sock:/var/run/docker.sock:ro @@ -65,15 +66,15 @@ services: - "traefik.docker.network=traefik-network" # Service - - "traefik.http.services.${APP_NAME}-mcp.loadbalancer.server.port=8080" + - "traefik.http.services.${APP_NAME}-mcp.loadbalancer.server.port=3000" - # Router sin autenticación - - "traefik.http.routers.${APP_NAME}-mcp.rule=Host(`${MCP_DOMAIN}`)" + # Router sin autenticación con PathPrefix para /mcp + - "traefik.http.routers.${APP_NAME}-mcp.rule=Host(`${MCP_DOMAIN}`) && PathPrefix(`/mcp`)" - "traefik.http.routers.${APP_NAME}-mcp.entrypoints=websecure" - "traefik.http.routers.${APP_NAME}-mcp.tls.certresolver=letsencrypt" - "traefik.http.routers.${APP_NAME}-mcp.service=${APP_NAME}-mcp" - # Headers personalizados para WebSocket y streaming + # Headers personalizados para MCP - "traefik.http.middlewares.${APP_NAME}-mcp-headers.headers.customrequestheaders.X-Forwarded-Proto=https" - "traefik.http.routers.${APP_NAME}-mcp.middlewares=${APP_NAME}-mcp-headers" diff --git a/mcp-docker-server/.gitignore b/mcp-docker-server/.gitignore new file mode 100644 index 0000000..4274b51 --- /dev/null +++ b/mcp-docker-server/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.env +*.log +.DS_Store diff --git a/mcp-docker-server/Dockerfile b/mcp-docker-server/Dockerfile new file mode 100644 index 0000000..366087d --- /dev/null +++ b/mcp-docker-server/Dockerfile @@ -0,0 +1,22 @@ +FROM node:20-alpine + +WORKDIR /app + +# Copiar archivos de dependencias +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"] diff --git a/mcp-docker-server/README.md b/mcp-docker-server/README.md new file mode 100644 index 0000000..3241c52 --- /dev/null +++ b/mcp-docker-server/README.md @@ -0,0 +1,50 @@ +# MCP Docker Server + +Servidor MCP (Model Context Protocol) para operaciones Docker seguras. + +## Herramientas Disponibles + +### Información y Monitoreo +- `docker_ps` - Listar contenedores +- `docker_logs` - Ver logs de contenedores +- `docker_inspect` - Inspeccionar contenedor +- `docker_stats` - Estadísticas de uso de recursos +- `docker_top` - Procesos del contenedor +- `docker_info` - Información del sistema Docker + +### Gestión de Contenedores +- `docker_start` - Iniciar contenedor +- `docker_stop` - Detener contenedor +- `docker_restart` - Reiniciar contenedor +- `docker_exec` - Ejecutar comando en contenedor + +### Recursos +- `docker_images` - Listar imágenes +- `docker_networks` - Listar redes +- `docker_volumes` - Listar volúmenes + +## Desarrollo + +```bash +npm install +npm run dev +``` + +## Producción + +```bash +npm run build +npm start +``` + +## Docker + +```bash +docker build -t mcp-docker-server . +docker run -v /var/run/docker.sock:/var/run/docker.sock:ro -p 3000:3000 mcp-docker-server +``` + +## Endpoints + +- `POST /mcp` - Endpoint MCP principal +- `GET /health` - Health check diff --git a/mcp-docker-server/package.json b/mcp-docker-server/package.json new file mode 100644 index 0000000..492629d --- /dev/null +++ b/mcp-docker-server/package.json @@ -0,0 +1,25 @@ +{ + "name": "mcp-docker-server", + "version": "1.0.0", + "description": "MCP Server para operaciones Docker", + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "tsx src/index.ts" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.4", + "dockerode": "^4.0.2", + "express": "^4.21.2", + "zod": "^3.24.1" + }, + "devDependencies": { + "@types/dockerode": "^3.3.34", + "@types/express": "^5.0.0", + "@types/node": "^22.10.5", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } +} diff --git a/mcp-docker-server/src/index.ts b/mcp-docker-server/src/index.ts new file mode 100644 index 0000000..b193933 --- /dev/null +++ b/mcp-docker-server/src/index.ts @@ -0,0 +1,533 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import express from 'express'; +import { z } from 'zod'; +import Docker from 'dockerode'; + +// Inicializar Docker +const docker = new Docker({ socketPath: '/var/run/docker.sock' }); + +// Crear el servidor MCP +const server = new McpServer({ + name: 'docker-server', + version: '1.0.0' +}); + +// Tool: Listar contenedores +server.registerTool( + 'docker_ps', + { + title: 'Listar Contenedores', + description: 'Lista todos los contenedores Docker (en ejecución o todos)', + inputSchema: { + all: z.boolean().optional().describe('Mostrar todos los contenedores (incluidos detenidos)') + }, + outputSchema: { + containers: z.array(z.object({ + id: z.string(), + name: z.string(), + image: z.string(), + state: z.string(), + status: z.string() + })) + } + }, + async ({ all = false }) => { + try { + const containers = await docker.listContainers({ all }); + const output = { + containers: containers.map(c => ({ + id: c.Id.substring(0, 12), + name: c.Names[0].replace('/', ''), + image: c.Image, + state: c.State, + status: c.Status + })) + }; + return { + content: [{ type: 'text', text: JSON.stringify(output, null, 2) }], + structuredContent: output + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Error desconocido'; + return { + content: [{ type: 'text', text: `Error: ${errorMsg}` }], + isError: true + }; + } + } +); + +// Tool: Ver logs de un contenedor +server.registerTool( + 'docker_logs', + { + title: 'Ver Logs de Contenedor', + description: 'Obtiene los logs de un contenedor específico', + inputSchema: { + container_id: z.string().describe('ID o nombre del contenedor'), + tail: z.number().optional().describe('Número de líneas desde el final (default: 100)'), + follow: z.boolean().optional().describe('Seguir logs en tiempo real (no recomendado para MCP)') + }, + outputSchema: { + logs: z.string() + } + }, + async ({ container_id, tail = 100, follow = false }) => { + try { + const container = docker.getContainer(container_id); + const logs = await container.logs({ + stdout: true, + stderr: true, + tail, + follow + }); + const output = { logs: logs.toString('utf-8') }; + return { + content: [{ type: 'text', text: output.logs }], + structuredContent: output + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Error desconocido'; + return { + content: [{ type: 'text', text: `Error: ${errorMsg}` }], + isError: true + }; + } + } +); + +// Tool: Inspeccionar contenedor +server.registerTool( + 'docker_inspect', + { + title: 'Inspeccionar Contenedor', + description: 'Obtiene información detallada de un contenedor', + inputSchema: { + container_id: z.string().describe('ID o nombre del contenedor') + }, + outputSchema: { + info: z.any() + } + }, + async ({ container_id }) => { + try { + const container = docker.getContainer(container_id); + const info = await container.inspect(); + const output = { info }; + return { + content: [{ type: 'text', text: JSON.stringify(info, null, 2) }], + structuredContent: output + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Error desconocido'; + return { + content: [{ type: 'text', text: `Error: ${errorMsg}` }], + isError: true + }; + } + } +); + +// Tool: Ver estadísticas de un contenedor +server.registerTool( + 'docker_stats', + { + title: 'Estadísticas de Contenedor', + description: 'Obtiene estadísticas de uso de recursos de un contenedor', + inputSchema: { + container_id: z.string().describe('ID o nombre del contenedor') + }, + outputSchema: { + stats: z.any() + } + }, + async ({ container_id }) => { + try { + const container = docker.getContainer(container_id); + const stats = await container.stats({ stream: false }); + const output = { stats }; + return { + content: [{ type: 'text', text: JSON.stringify(stats, null, 2) }], + structuredContent: output + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Error desconocido'; + return { + content: [{ type: 'text', text: `Error: ${errorMsg}` }], + isError: true + }; + } + } +); + +// Tool: Ver procesos de un contenedor +server.registerTool( + 'docker_top', + { + title: 'Procesos del Contenedor', + description: 'Lista los procesos en ejecución dentro de un contenedor', + inputSchema: { + container_id: z.string().describe('ID o nombre del contenedor') + }, + outputSchema: { + processes: z.any() + } + }, + async ({ container_id }) => { + try { + const container = docker.getContainer(container_id); + const processes = await container.top(); + const output = { processes }; + return { + content: [{ type: 'text', text: JSON.stringify(processes, null, 2) }], + structuredContent: output + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Error desconocido'; + return { + content: [{ type: 'text', text: `Error: ${errorMsg}` }], + isError: true + }; + } + } +); + +// Tool: Reiniciar contenedor +server.registerTool( + 'docker_restart', + { + title: 'Reiniciar Contenedor', + description: 'Reinicia un contenedor específico', + inputSchema: { + container_id: z.string().describe('ID o nombre del contenedor') + }, + outputSchema: { + success: z.boolean(), + message: z.string() + } + }, + async ({ container_id }) => { + try { + const container = docker.getContainer(container_id); + await container.restart(); + const output = { success: true, message: `Contenedor ${container_id} reiniciado exitosamente` }; + return { + content: [{ type: 'text', text: output.message }], + structuredContent: output + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Error desconocido'; + return { + content: [{ type: 'text', text: `Error: ${errorMsg}` }], + isError: true + }; + } + } +); + +// Tool: Detener contenedor +server.registerTool( + 'docker_stop', + { + title: 'Detener Contenedor', + description: 'Detiene un contenedor en ejecución', + inputSchema: { + container_id: z.string().describe('ID o nombre del contenedor') + }, + outputSchema: { + success: z.boolean(), + message: z.string() + } + }, + async ({ container_id }) => { + try { + const container = docker.getContainer(container_id); + await container.stop(); + const output = { success: true, message: `Contenedor ${container_id} detenido exitosamente` }; + return { + content: [{ type: 'text', text: output.message }], + structuredContent: output + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Error desconocido'; + return { + content: [{ type: 'text', text: `Error: ${errorMsg}` }], + isError: true + }; + } + } +); + +// Tool: Iniciar contenedor +server.registerTool( + 'docker_start', + { + title: 'Iniciar Contenedor', + description: 'Inicia un contenedor detenido', + inputSchema: { + container_id: z.string().describe('ID o nombre del contenedor') + }, + outputSchema: { + success: z.boolean(), + message: z.string() + } + }, + async ({ container_id }) => { + try { + const container = docker.getContainer(container_id); + await container.start(); + const output = { success: true, message: `Contenedor ${container_id} iniciado exitosamente` }; + return { + content: [{ type: 'text', text: output.message }], + structuredContent: output + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Error desconocido'; + return { + content: [{ type: 'text', text: `Error: ${errorMsg}` }], + isError: true + }; + } + } +); + +// Tool: Listar imágenes +server.registerTool( + 'docker_images', + { + title: 'Listar Imágenes', + description: 'Lista todas las imágenes Docker disponibles', + inputSchema: {}, + outputSchema: { + images: z.array(z.object({ + id: z.string(), + tags: z.array(z.string()), + size: z.number() + })) + } + }, + async () => { + try { + const images = await docker.listImages(); + const output = { + images: images.map(img => ({ + id: img.Id.substring(7, 19), + tags: img.RepoTags || [], + size: img.Size + })) + }; + return { + content: [{ type: 'text', text: JSON.stringify(output, null, 2) }], + structuredContent: output + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Error desconocido'; + return { + content: [{ type: 'text', text: `Error: ${errorMsg}` }], + isError: true + }; + } + } +); + +// Tool: Listar redes +server.registerTool( + 'docker_networks', + { + title: 'Listar Redes', + description: 'Lista todas las redes Docker', + inputSchema: {}, + outputSchema: { + networks: z.array(z.object({ + id: z.string(), + name: z.string(), + driver: z.string() + })) + } + }, + async () => { + try { + const networks = await docker.listNetworks(); + const output = { + networks: networks.map(net => ({ + id: net.Id.substring(0, 12), + name: net.Name, + driver: net.Driver + })) + }; + return { + content: [{ type: 'text', text: JSON.stringify(output, null, 2) }], + structuredContent: output + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Error desconocido'; + return { + content: [{ type: 'text', text: `Error: ${errorMsg}` }], + isError: true + }; + } + } +); + +// Tool: Listar volúmenes +server.registerTool( + 'docker_volumes', + { + title: 'Listar Volúmenes', + description: 'Lista todos los volúmenes Docker', + inputSchema: {}, + outputSchema: { + volumes: z.array(z.object({ + name: z.string(), + driver: z.string(), + mountpoint: z.string() + })) + } + }, + async () => { + try { + const result = await docker.listVolumes(); + const output = { + volumes: (result.Volumes || []).map(vol => ({ + name: vol.Name, + driver: vol.Driver, + mountpoint: vol.Mountpoint + })) + }; + return { + content: [{ type: 'text', text: JSON.stringify(output, null, 2) }], + structuredContent: output + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Error desconocido'; + return { + content: [{ type: 'text', text: `Error: ${errorMsg}` }], + isError: true + }; + } + } +); + +// Tool: Información del sistema Docker +server.registerTool( + 'docker_info', + { + title: 'Información del Sistema', + description: 'Obtiene información general del sistema Docker', + inputSchema: {}, + outputSchema: { + info: z.any() + } + }, + async () => { + try { + const info = await docker.info(); + const output = { info }; + return { + content: [{ type: 'text', text: JSON.stringify(info, null, 2) }], + structuredContent: output + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Error desconocido'; + return { + content: [{ type: 'text', text: `Error: ${errorMsg}` }], + isError: true + }; + } + } +); + +// Tool: Ejecutar comando en contenedor +server.registerTool( + 'docker_exec', + { + title: 'Ejecutar Comando', + description: 'Ejecuta un comando dentro de un contenedor', + inputSchema: { + container_id: z.string().describe('ID o nombre del contenedor'), + command: z.array(z.string()).describe('Comando a ejecutar como array (ej: ["ls", "-la"])') + }, + outputSchema: { + output: z.string() + } + }, + async ({ container_id, command }) => { + try { + const container = docker.getContainer(container_id); + const exec = await container.exec({ + Cmd: command, + AttachStdout: true, + AttachStderr: true + }); + const stream = await exec.start({ Detach: false }); + + // Recolectar la salida del stream + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.from(chunk)); + } + const outputBuffer = Buffer.concat(chunks); + const outputText = outputBuffer.toString('utf-8'); + + const output = { output: outputText }; + return { + content: [{ type: 'text', text: outputText }], + structuredContent: output + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Error desconocido'; + return { + content: [{ type: 'text', text: `Error: ${errorMsg}` }], + isError: true + }; + } + } +); + +// Configurar el servidor HTTP +const app = express(); +app.use(express.json()); + +// Health check +app.get('/health', (req, res) => { + res.json({ status: 'ok', service: 'mcp-docker-server' }); +}); + +// Endpoint MCP +app.post('/mcp', async (req, res) => { + try { + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true + }); + + res.on('close', () => { + transport.close(); + }); + + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error('Error handling MCP request:', error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32603, + message: 'Internal server error' + }, + id: null + }); + } + } +}); + +const port = parseInt(process.env.PORT || '3000'); +app.listen(port, '0.0.0.0', () => { + console.log(`MCP Docker Server corriendo en http://0.0.0.0:${port}/mcp`); + console.log(`Health check disponible en http://0.0.0.0:${port}/health`); +}).on('error', error => { + console.error('Error del servidor:', error); + process.exit(1); +}); diff --git a/mcp-docker-server/tsconfig.json b/mcp-docker-server/tsconfig.json new file mode 100644 index 0000000..6582474 --- /dev/null +++ b/mcp-docker-server/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "lib": ["ES2022"], + "moduleResolution": "node", + "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"] +}