mcp-server
by ElJijuna
README.md
# mcp-server
MCP Server en TypeScript que expone las herramientas de ingeniería del equipo —**Bitbucket, Jira, Confluence y ArgoCD**— a los editores con soporte MCP (VS Code + Copilot, Claude Code, etc.).
Es una **capa delgada (thin wrapper)**: no habla con Bitbucket/Jira/Confluence/ArgoCD, ni guarda credenciales de esos servicios. Todo lo traduce a llamadas HTTP contra el backend interno **eng-api**, que ya tiene resueltas conexiones y credenciales.
```
VS Code (dev A) ─┐
VS Code (dev B) ─┼─► MCP Server ───► eng-api ───► Bitbucket / Jira / Confluence / ArgoCD
VS Code (dev C) ─┘ (este repo) (credenciales viven aquí)
Streamable HTTP HTTP
+ API key por dev
```
**Ventaja:** ningún dev necesita tokens personales de Bitbucket/Jira/Confluence/ArgoCD. Solo una API key de este MCP Server, revocable individualmente.
---
## 1. Requisitos
- Node.js **≥ 22**
- Acceso de red a `ENG_API_BASE_URL` (la URL de eng-api)
## 2. Correr localmente
```bash
npm ci
cp .env.example .env # y rellena los valores (ver sección 3)
npm run dev # hot-reload, lee .env automáticamente
```
Otros comandos:
| Comando | Qué hace |
|---|---|
| `npm run dev` | Arranca en modo watch leyendo `.env` |
| `npm run build` | Compila TypeScript a `dist/` |
| `npm run typecheck` | Type-check sin emitir |
| `npm start` | Arranca lo compilado (usa variables del entorno; es lo que corre en el pod) |
| `npm run start:local` | Arranca lo compilado leyendo `.env` |
Comprobación rápida de que está vivo:
```bash
curl http://localhost:3000/healthz
# {"status":"ok","server":"mcp-server","version":"0.1.0"}
```
## 3. Configuración (`.env`)
Todas las variables se leen de `process.env`. **Si falta una obligatoria o tiene un valor inválido, el proceso no arranca** y explica exactamente qué corregir.
| Variable | Obligatoria | Default | Descripción |
|---|:---:|---|---|
| `ENG_API_BASE_URL` | ✅ | — | URL base de eng-api, sin barra final. Debe ser `http(s)://…` |
| `MCP_DEV_API_KEYS` | ✅ | — | API keys válidas de los devs contra **este** MCP Server (ver §4) |
| `ENG_API_TIMEOUT_MS` | — | `10000` | Timeout por llamada a eng-api (1000–120000) |
| `ENG_API_MAX_RETRIES` | — | `2` | Reintentos adicionales ante 5xx/429/timeout (0–5) |
| `PORT` | — | `3000` | Puerto HTTP del MCP Server |
| `LOG_LEVEL` | — | `info` | `debug` \| `info` \| `warn` \| `error` |
Ejemplo de arranque fallido (a propósito):
```
Configuración inválida: el MCP Server no puede arrancar.
- Falta la variable obligatoria ENG_API_BASE_URL. Debe apuntar a la URL base de eng-api, ej. https://eng-api.internal.example/api/v1
Revisa tu archivo .env (usa .env.example como plantilla) o el ConfigMap/Secret del Deployment.
```
## 4. Autenticación: una API key por dev
Esta capa de auth es **propia del MCP Server** e independiente de la que eng-api use hacia los servicios finales.
### Generar keys
```bash
openssl rand -hex 32 # una por cada persona del equipo
```
### Configurarlas
`MCP_DEV_API_KEYS` acepta cuatro formatos (mínimo 24 caracteres por key, sin duplicados):
```bash
MCP_DEV_API_KEYS=<key1>,<key2> # CSV simple
MCP_DEV_API_KEYS=alice:<key1>,bob:<key2> # CSV etiquetado ← recomendado
MCP_DEV_API_KEYS=["<key1>","<key2>"] # JSON array
MCP_DEV_API_KEYS={"alice":"<key1>","bob":"<key2>"} # JSON objeto
```
Usa el formato **etiquetado**: la etiqueta aparece en los logs del MCP Server y se propaga a eng-api en el header `X-Mcp-Dev`, así que se puede auditar quién disparó cada operación (por ejemplo, un `argocd_sync_app`) sin exponer la key.
### Usarlas
El cliente MCP debe enviar en cada petición:
```
Authorization: Bearer <API_KEY>
```
(o, como alternativa, `x-api-key: <API_KEY>`). La comparación es **timing-safe** sobre digests SHA-256.
| Situación | Respuesta |
|---|---|
| Sin key | `401` + mensaje indicando qué header falta |
| Key inválida/revocada | `403` + mensaje indicando qué revisar |
| `/healthz`, `/readyz` | Sin auth (para los probes de Kubernetes) |
**Revocar a alguien** = quitar su key de `MCP_DEV_API_KEYS` y reiniciar el Deployment. Como cada dev tiene la suya, no afecta al resto. En producción, guarda el valor en un `Secret` de Kubernetes, nunca en un `ConfigMap`.
## 5. Catálogo de tools
Los nombres llevan prefijo del servicio y son orientados a acción. Todos soportan paginación donde aplica (`page`, `pageSize` de 1 a 100, por defecto 25).
### Bitbucket (solo lectura)
| Tool | Argumentos | Endpoint eng-api |
|---|---|---|
| `bitbucket_list_prs` | `workspace`, `repoSlug`, `state?` (`OPEN`\|`MERGED`\|`DECLINED`\|`ALL`), `author?`, `page?`, `pageSize?` | `GET /bitbucket/repositories/{ws}/{repo}/pull-requests` |
| `bitbucket_get_pr` | `workspace`, `repoSlug`, `pullRequestId` | `GET /bitbucket/repositories/{ws}/{repo}/pull-requests/{id}` |
| `bitbucket_get_commits` | `workspace`, `repoSlug`, `branch`, `sinceCommit?`, `sinceDate?`, `page?`, `pageSize?` | `GET /bitbucket/repositories/{ws}/{repo}/commits` |
### Jira
| Tool | Argumentos | Endpoint eng-api |
|---|---|---|
| `jira_search_issues` | `jql?` **o** filtros simples (`projectKey?`, `status?`, `assignee?`, `labels?`), `fields?`, `page?`, `pageSize?` | `POST /jira/issues/search` |
| `jira_get_issue` | `issueKey` (formato `PLAT-4821`), `fields?`, `includeComments?` | `GET /jira/issues/{key}` |
| `jira_create_issue` ✍️ | `projectKey`, `issueType`, `summary`, `description?`, `assignee?`, `labels?`, `priority?`, `parentKey?`, `extraFields?` | `POST /jira/issues` |
### Confluence (solo lectura)
| Tool | Argumentos | Endpoint eng-api |
|---|---|---|
| `confluence_search_pages` | `query`, `spaceKey?`, `page?`, `pageSize?` | `GET /confluence/pages/search` |
| `confluence_get_page` | `pageId`, `format?` (`plain`\|`storage`\|`view`) | `GET /confluence/pages/{id}` |
### ArgoCD
| Tool | Argumentos | Endpoint eng-api |
|---|---|---|
| `argocd_list_apps` | `project?`, `namespace?`, `syncStatus?`, `healthStatus?`, `page?`, `pageSize?` | `GET /argocd/applications` |
| `argocd_get_app_status` | `appName` | `GET /argocd/applications/{name}` |
| `argocd_sync_app` ⚠️ | `appName` (**exacto, sin default**), `revision?`, `prune?`, `dryRun?`, `resources?` | `POST /argocd/applications/{name}/sync` |
### Anotaciones (hints para el cliente MCP)
| Tool | `readOnlyHint` | `destructiveHint` | `idempotentHint` | `openWorldHint` |
|---|:---:|:---:|:---:|:---:|
| Todos los de lectura | ✅ | ❌ | ✅ | ✅ |
| `jira_create_issue` ✍️ | ❌ | ❌ | ❌ | ✅ |
| `argocd_sync_app` ⚠️ | ❌ | ✅ | ❌ | ✅ |
`argocd_sync_app` exige el nombre exacto de la app (sin comodines ni valores por defecto) y `prune`/`dryRun` van en `false` salvo que se pidan explícitamente.
> Las rutas de eng-api viven **todas** en [src/client/routes.ts](src/client/routes.ts). Si eng-api cambia un path, se toca solo ese archivo.
## 6. Configurar VS Code (cada dev, con su propia key)
Crea `.vscode/mcp.json` en tu workspace (o el `mcp.json` de usuario, si lo quieres en todos los proyectos):
```jsonc
{
"inputs": [
{
"type": "promptString",
"id": "eng-mcp-api-key",
"description": "Tu API key personal del MCP Server de ingeniería",
"password": true
}
],
"servers": {
"eng": {
"type": "http",
"url": "https://<host-del-mcp-server>/mcp",
"headers": {
"Authorization": "Bearer ${input:eng-mcp-api-key}"
}
}
}
}
```
VS Code pedirá la key la primera vez y la guardará cifrada; **no se commitea nunca**. Después, abre el chat en modo Agent y verás los 11 tools bajo el servidor `eng`.
Para **Claude Code** (CLI), el equivalente es:
```bash
claude mcp add --transport http eng https://<host-del-mcp-server>/mcp \
--header "Authorization: Bearer <TU_API_KEY>"
```
En local, sustituye la URL por `http://localhost:3000/mcp`.
## 7. Probarlo con MCP Inspector
```bash
npm run build && npm run start:local # en una terminal
npx @modelcontextprotocol/inspector # en otra
```
En la UI del Inspector:
1. **Transport Type**: `Streamable HTTP`
2. **URL**: `http://localhost:3000/mcp`
3. En **Authentication**, pon *Header Name* `Authorization` y el *Bearer Token* con tu API key
4. **Connect** → pestaña **Tools** → **List Tools** → prueba cualquiera
También se puede probar con `curl` directamente (útil en CI o desde un pod):
```bash
KEY=<tu-api-key>
curl -s -X POST http://localhost:3000/mcp \
-H "Accept: application/json, text/event-stream" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $KEY" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq '.result.tools[].name'
```
Llamar a un tool:
```bash
curl -s -X POST http://localhost:3000/mcp \
-H "Accept: application/json, text/event-stream" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $KEY" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{
"name":"bitbucket_list_prs",
"arguments":{"workspace":"acme","repoSlug":"web-frontend","state":"OPEN","pageSize":10}}}'
```
## 8. Docker
```bash
docker build -t mcp-server:0.1.0 .
docker run --rm -p 3000:3000 \
-e ENG_API_BASE_URL="https://<eng-api>/api/v1" \
-e MCP_DEV_API_KEYS="alice:<key1>,bob:<key2>" \
mcp-server:0.1.0
```
Imagen multi-stage sobre `node:22-alpine`: la final solo lleva `dist/` + dependencias de producción, corre como usuario `node` (sin root) e incluye un `HEALTHCHECK` que golpea `/healthz` con el propio Node (sin curl/wget).
**Para Kubernetes** (los manifiestos no están en este repo):
- El servidor es **stateless**: no guarda sesiones en memoria, así que escala a N réplicas **sin sticky sessions**.
- Probes: `livenessProbe` → `GET /healthz`, `readinessProbe` → `GET /readyz` (ambos sin auth).
- `MCP_DEV_API_KEYS` va en un `Secret`; `ENG_API_BASE_URL` y los timeouts pueden ir en un `ConfigMap`.
- Maneja `SIGTERM` cerrando el servidor HTTP con gracia (drenaje de 10 s como máximo).
## 9. Cómo añadir un servicio o tool nuevo
El patrón está pensado para que añadir un servicio **no toque nada existente**. Ejemplo con un hipotético *Grafana*:
**1. Añade sus rutas** en [src/client/routes.ts](src/client/routes.ts):
```ts
grafana: {
listDashboards: (): string => "/grafana/dashboards",
getDashboard: (uid: string): string => `/grafana/dashboards/${seg(uid)}`,
},
```
**2. Crea `src/tools/grafana.ts`** siguiendo el mismo molde que los demás:
```ts
export function registerGrafanaTools(server: McpServer, deps: ToolDeps): void {
registerEngTool(server, deps, {
name: "grafana_list_dashboards", // prefijo de servicio + acción
title: "Grafana: listar dashboards",
description: "Qué hace y cuándo usarlo.",
inputSchema: { query: z.string().optional().describe('Texto a buscar. Ejemplo: "latencia checkout".'),
...paginationShape },
annotations: readOnlyAnnotations("Grafana: listar dashboards"),
describeOperation: (args) => `listar dashboards de Grafana`, // encaja tras "al …"
execute: (args, { client, context }) =>
client.get(engApiRoutes.grafana.listDashboards(), {
query: { query: args.query, ...paginationQuery(args) },
context,
}),
});
}
```
**3. Regístralo** en `TOOL_REGISTRARS` de [src/server.ts](src/server.ts):
```ts
const TOOL_REGISTRARS = [ …, registerGrafanaTools ];
```
Eso es todo. `registerEngTool` ya te da gratis: validación Zod, formateo de la respuesta, truncado de payloads enormes, captura de errores y traducción a mensajes accionables, y logging con `requestId`.
**Reglas de estilo para tools nuevos:**
- Nombre `servicio_accion_objeto`, en minúsculas.
- Cada campo del schema **con `.describe()` y un ejemplo concreto** — es lo único que el modelo lee para decidir cómo llamarlo.
- Anotaciones honestas: si escribe, `readOnlyHint: false`; si puede borrar algo, `destructiveHint: true`.
- Nada de defaults peligrosos en operaciones destructivas: exige identificadores exactos.
- Paginación (`...paginationShape` + `paginationQuery(args)`) en todo lo que devuelva listas.
- Nunca construyas URLs a mano en `tools/`: siempre a través de `engApiRoutes`.
## 10. Manejo de errores
Ningún tool devuelve un "Error 500" pelado. Cada error incluye **qué falló, qué revisar** y un `requestId` para cruzarlo con los logs de eng-api. Ejemplo real:
```
No existe el recurso al obtener el estado de la aplicación boom (404). Verifica los identificadores
exactos (workspace/repo, key de issue, id de página, nombre de app) — distinguen mayúsculas. Si los
identificadores son correctos, la ruta de eng-api puede haber cambiado (src/client/routes.ts).
[requestId=8a4bf9e6-…, intentos=1, upstream=GET /argocd/applications/boom]
Respuesta de eng-api: {"error":"application not found"}
```
| Situación | Qué hace el MCP Server |
|---|---|
| Timeout / error de red | Reintenta con backoff exponencial + jitter (`ENG_API_MAX_RETRIES`), luego explica que revises `ENG_API_BASE_URL` / la latencia |
| `429`, `5xx` | Reintenta (respeta `Retry-After` si viene) y, si persiste, apunta a los logs de eng-api |
| `400` / `422` | **No** reintenta: los parámetros son inválidos |
| `401` / `403` de eng-api | Aclara que no es tu API key del MCP, sino las credenciales/permisos de eng-api |
| `404` | Sugiere verificar identificadores exactos y las rutas de `routes.ts` |
| `409` | Conflicto de estado (p. ej. un sync de ArgoCD ya en curso): consulta el estado y reintenta luego |
| Respuesta no-JSON | Suele ser un proxy devolviendo HTML: la ruta probablemente no existe |
| Payload gigante | Se trunca a 120 000 caracteres con un aviso para reducir `pageSize` o acotar filtros |
## 11. Estructura del proyecto
```
src/
├── index.ts # entrypoint: Express + Streamable HTTP (stateless), /healthz, /readyz
├── config.ts # lectura y validación de env vars, fail-fast
├── auth.ts # middleware de API key (timing-safe)
├── logger.ts # logs JSON de una línea, aptos para Cloud Logging
├── server.ts # createMcpServer(): registra todas las familias de tools
├── client/
│ ├── routes.ts # ÚNICO sitio con las rutas de eng-api
│ ├── errors.ts # EngApiError → mensajes accionables
│ └── engApiClient.ts # fetch + timeout + retry con backoff
└── tools/
├── shared.ts # registerEngTool(), paginación, formateo, errores
├── bitbucket.ts ├── jira.ts ├── confluence.ts └── argocd.ts
```
**Decisiones de diseño:**
- **Streamable HTTP en modo stateless** (`sessionIdGenerator: undefined`, `enableJsonResponse: true`): se crea un `McpServer` + transport por petición. Sin estado compartido entre devs, sin sticky sessions, escala horizontalmente y las respuestas son JSON plano (más amables con ingress/proxies que SSE).
- **Solo `POST /mcp`**: `GET`/`DELETE` responden `405`, porque en stateless no hay stream servidor→cliente ni sesión que cerrar.
- **Trazabilidad**: cada petición lleva un `X-Request-Id` (se respeta el del cliente si lo manda) y un `X-Mcp-Dev` con la etiqueta del dev, ambos propagados a eng-api.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues