Skip to main content
Glama
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.