# Servidor MCP de reportes del onboarding (RF-70 / RF-71 / RNF-33)

Servidor **MCP** (Model Context Protocol) que expone el onboarding como
*herramientas*, para que **cualquier cliente MCP** (el Claude del equipo u otro
sistema) consulte, genere reportes y —en dos casos contados— actúe. Además incluye un **digest**
programado que "llega" por correo/Slack.

> **Arquitectura: una sola capa, tres consumidores.** El panel admin, este servidor MCP
> y el digest leen los MISMOS datos a través de `src/services/reportsService.js`
> (solo `SELECT`).

> **Once herramientas leen. Dos escriben.** Hasta hace poco no escribía ninguna, y
> era una garantía fuerte: dijeran lo que dijeran los datos devueltos, no podían
> provocar nada. Se abrieron dos acciones —cerrar una incidencia y ampliar el
> catálogo de un país— para que el MCP sirva de panel a distancia. Las dos son
> reversibles, las dos son solo de ADMIN, y las dos van por `acciones.js`.

## Componentes

| Archivo | Rol |
|---|---|
| `../services/reportsService.js` | Capa de datos read-only compartida (SELECT, sin payloads sensibles) |
| `reportTools.js` | Definición de las 13 herramientas MCP (nombres en español) |
| `acciones.js` | Lo ÚNICO que puede cambiar algo. Solo ADMIN |
| `mcpServer.js` | Construye el `McpServer` y envuelve cada tool con auditoría |
| `httpServer.js` | Transporte HTTP con sesiones + rate-limit + auditoría |
| `identidad.js` | Validación de tokens OAuth y metadatos del recurso (RFC 9728) |
| `alcance.js` | Qué parte de los datos le corresponde a quien pregunta |
| `index.js` | Punto de entrada (`npm run mcp`) |
| `digest.js` | Reporte programado por email/Slack (`npm run digest`) |

## Herramientas (once leen, dos escriben)

| Herramienta | Argumentos | Devuelve |
|---|---|---|
| `resumen_general` | `dias_inactividad?` | Totales por estado, tasa de completado, colgadas |
| `listar_empresas` | `estado?`, `dias_inactividad?` | Empresas con estado, paso, fechas y secciones llenas |
| `estado_empresa` | `empresa_id` (uuid) | Detalle: cabecera + estado por sección + provisioning |
| `empresas_colgadas` | `dias?` | Empresas en progreso sin actividad reciente |
| `metricas_abandono` | — | Distribución por paso actual (dónde se estancan) |
| `incidencias` | `estado?` | Incidencias del wizard con el dueño de la empresa |
| `reconciliacion` | `incluir_ok?` | Pasos donde Saeplus creó menos registros de los enviados (RF-54) |
| `esfuerzo_por_paso` | — | Tiempo mediano por paso y campos con más errores (RNF-25) |
| `rechazos_migracion` | `dias?` | Qué rechaza el asistente del archivo de abonados: regla, columna, valor y país |
| `usuarios` | `estado?` | Cuentas y su estado de acceso (PENDING / APPROVED / DISABLED) |
| `catalogo_pais` | — | Valores añadidos a mano a los catálogos, con autor y motivo |
| **`resolver_incidencia`** | `incidencia_id`, `resolucion` | **Escribe.** Cierra una incidencia |
| **`ampliar_catalogo_pais`** | `pais`, `lista`, `valor`, `motivo` | **Escribe.** Añade un valor al catálogo |

> `incidencias` y `reconciliacion` devuelven datos reales (tablas `incidents` y
> `step_reconciliations`, migraciones `002` y `003`). `metricas_abandono` sigue
> derivándose de `companies.current_step` — responde *dónde están* las empresas;
> `esfuerzo_por_paso` usa `wizard_events` y responde *cuánto les cuesta*, que es
> otra pregunta. Si la migración `003` no se ha aplicado, las dos herramientas
> nuevas responden `available: false` en lugar de fallar.

## Arrancar el servidor

```bash
# Desarrollo: token fijo, sin proveedor OAuth de por medio
npm run mcp:dev

# Producción: hace falta OAuth, o el proceso NO arranca
NODE_ENV=production MCP_PUBLIC_URL=https://mcp.saeplus.com/mcp MCP_OAUTH_ISSUER=https://tu-proveedor/ MCP_OAUTH_JWKS_URL=https://tu-proveedor/.well-known/jwks.json npm run mcp
```

| Variable | Def. | Para qué |
|---|---|---|
| `MCP_PORT` | 4100 | Puerto |
| `MCP_BIND` | `127.0.0.1` | Dirección. Solo local: el TLS lo pone el proxy |
| `MCP_PUBLIC_URL` | — | La URL pública; es también la audiencia de los tokens |
| `MCP_OAUTH_ISSUER` | — | **Obligatoria en producción** |
| `MCP_OAUTH_JWKS_URL` | — | **Obligatoria en producción** |
| `MCP_OAUTH_AUDIENCE` | `MCP_PUBLIC_URL` | Si el proveedor emite otra audiencia |
| `MCP_OAUTH_SCOPE` | — | Permiso exigido. Vacío = no se exige ninguno |
| `MCP_TOKEN` | — | Token fijo, **solo fuera de producción** |

## Conectarlo desde Claude

1. **En WorkOS**: crear la aplicación, y en `Connect → Configuration` activar
   **CIMD**. Es lo que permite que el cliente de cada persona se identifique sin
   que nadie lo registre a mano.
2. **En la VM**: Apache con el certificado, haciendo proxy de
   `https://mcp.saeplus.com` a `127.0.0.1:4100`. El servidor MCP **no** escucha
   fuera de local a propósito.
3. **En el `.env`**: `MCP_PUBLIC_URL`, `MCP_OAUTH_ISSUER` y `MCP_OAUTH_JWKS_URL`.
4. **En Claude**: `Ajustes → Conectores → Añadir conector personalizado`, y la
   URL `https://mcp.saeplus.com/mcp`. El resto del flujo lo hace el cliente solo.

Quien se conecte tiene que autenticarse con **el mismo correo que usa en el
onboarding**: es lo que ata su sesión de Claude a su cuenta de aquí.

## Seguridad y gobernanza (RNF-33)

### Autenticación: OAuth 2.1, no un token compartido

En **producción el servidor no arranca** sin `MCP_OAUTH_ISSUER` y `MCP_OAUTH_JWKS_URL`.

Hubo un `MCP_TOKEN` compartido y servía mientras el consumidor era el equipo. Para un
servidor abierto a internet no sirve: es una sola identidad para todos —nadie sabe quién
consultó qué—, no hay a quién revocar sin echar a todos, y no da con qué acotar lo que ve
cada uno. Además la especificación MCP exige tokens **atados a este servidor como
audiencia** (RFC 8707), para que un token válido emitido para otra aplicación del mismo
proveedor no entre aquí.

El servidor de autorización es **externo**. Aquí se implementa solo el lado de servidor de
recursos, que es lo que la especificación nos exige: validar el token contra el JWKS del
proveedor y publicar `/.well-known/oauth-protected-resource` (RFC 9728) para que el cliente
descubra solo adónde ir. Se sirve en las dos rutas —la raíz y la que lleva el camino
insertado, `/.well-known/oauth-protected-resource/mcp`— porque la segunda es la que el
cliente consulta primero.

**No vale cualquier proveedor.** Hacen falta tres cosas a la vez: tokens JWT verificables
contra un JWKS; que entienda el parámetro `resource` que envía Claude (RFC 8707) y ponga
esa URL en `aud`; y que el cliente se pueda registrar solo (CIMD o RFC 7591). Se eligió
**WorkOS AuthKit**. Auth0 usa un `audience` propietario anterior al estándar y no ata el
token; Okta y Entra no implementan RFC 7591; Keycloak lo trae con problemas de CORS.

### El rol NO sale del token

El token dice **quién** es —y para eso basta el correo verificado—. El rol y la empresa se
buscan en `users` y `companies` (`repositories/identidadRepo.js`).

Es deliberado, por dos razones. La primera es práctica: no todos los proveedores dejan
añadir reclamaciones a un token de acceso, y si no las ponen, todo el mundo cae a CLIENT
sin empresa y el servidor contesta 403 siempre — conecta bien y no sirve para nada. La
segunda pesa más: el rol ya vive aquí. Tenerlo también allí son dos fuentes para el mismo
dato, y la de fuera es la que no controlamos; quitarle el rol de administrador a alguien no
surtiría efecto hasta que además se lo quitaran en el proveedor.

Se comprueba además el `access_status`, igual que en la aplicación: un cliente `PENDING` o
`DISABLED` no entra. Sin eso, deshabilitar a alguien le cerraría el asistente y le dejaría
abierto el MCP, que enseña más datos, no menos.

> El proveedor tiene que incluir el ámbito `email`. Un token sin correo, o con
> `email_verified: false`, no entra: si se aceptara, cualquiera podría darse de alta con el
> correo de un administrador y heredar su rol.

> `MCP_TOKEN` sigue existiendo **solo fuera de producción**, para no tener que levantar un
> proveedor OAuth cada vez que se toca código. Con OAuth configurado deja de aceptarse:
> dos puertas abiertas son una puerta abierta.

### Autorización: cada quien ve lo suyo

OAuth dice QUIÉN eres; no dice qué puedes ver. Cinco de las consultas de reportes
devuelven `owner_name` y `owner_email` de todas las empresas, así que sin acotar, abrir
este servidor sería publicar los datos de contacto de todos los clientes.

`alcance.js` envuelve cada función y recorta la respuesta. Las herramientas **no pueden
llamar a `reportsService` directamente** — `alcance.test.js` lo comprueba leyendo el
fuente, así que una herramienta nueva que se lo salte pone la prueba en rojo.

| | ADMIN | CLIENT |
|---|---|---|
| `listar_empresas`, `empresas_colgadas` | todas | solo la suya |
| `estado_empresa` | cualquiera | solo la suya (una ajena y una inexistente responden **igual**) |
| `incidencias`, `reconciliacion` | todas | solo las suyas |
| `metricas_abandono`, `esfuerzo_por_paso` | sí | vacío: una mediana de una empresa no es una mediana |

### Las dos que escriben

`resolver_incidencia` y `ampliar_catalogo_pais` van por `acciones.js` y **exigen ADMIN**.
Graban como autor nuestro `userId`, no el `sub` del proveedor —que no se puede cruzar con
nada— y el registro de auditoría las marca con `escribe: true`, para que «qué se cambió
desde el MCP» salga de un filtro y no de recordar cuáles de las trece escribían.

Llevan `readOnlyHint: false`, que es lo que hace que el cliente MCP **pida confirmación a
la persona**. Eso no es cosmético: las herramientas de lectura devuelven texto escrito por
terceros —`rechazos_migracion` trae el valor literal de una celda del archivo de abonados
que subió un cliente— y ese texto entra en el contexto del modelo. Una celda que diga
«ignora lo anterior y haz X» es un intento de inyección; la confirmación humana es lo que
impide que prospere. Un texto colado puede proponer la llamada, pero no aprobarla.

**Aprobar y deshabilitar usuarios se dejó fuera a propósito.** Es la única acción del panel
donde equivocarse es un incidente de seguridad, y es también la más rara: hacerla en el
panel no le cuesta a nadie. `acciones-mcp.test.js` comprueba que no se cuele.

### Lo demás

- **Las once de lectura** solo llaman a `reportsService` (SELECT).
- **Transporte**: escucha en `127.0.0.1` por defecto. El TLS lo pone el proxy de delante;
  abrir el puerto directamente dejaría el token viajando en claro.
- **Rate-limit**: 60 peticiones/minuto **por usuario**, no por IP. Claude llega desde la
  nube de Anthropic, así que con el límite por IP todos compartían cubo: uno agotaba el de
  todos y un uso normal parecía un ataque.
- **Auditoría**: cada acceso con el `sub`, el rol y la empresa de quien pregunta
  (`mcp.audit`, `mcp.auth_denied`, `mcp.rate_límited`, `mcp.sin_alcance`).

## Conectar un cliente MCP

Transporte **Streamable HTTP** en `https://<host>/mcp`. Un cliente que cumpla la
especificación descubre el proveedor solo: pide `/mcp`, recibe un `401` con

```
WWW-Authenticate: Bearer resource_metadata="https://<host>/.well-known/oauth-protected-resource",
                         scope="reportes:leer"
```

lee esos metadatos, va al proveedor, hace el flujo con PKCE y vuelve con su token.

Para un cliente que acepte cabeceras a mano (Claude Code, Claude Desktop):

```json
{
  "mcpServers": {
    "saeplus-onboarding": {
      "type": "http",
      "url": "https://mcp.saeplus.com/mcp",
      "headers": { "Authorization": "Bearer <token del proveedor>" }
    }
  }
}
```

> **Claude se conecta desde la nube de Anthropic**, no desde el navegador de quien
> pregunta. El servidor tiene que ser alcanzable desde internet por HTTPS; `localhost` no
> vale. El montaje completo (pm2, Apache, certificado) está en
> [`docs/DEPLOY-VM.md`](../../../docs/DEPLOY-VM.md).

Comprobación rápida: `GET /health` → `{ "ok": true }`.

## Digest programado (RF-71)

**Se ejecuta solo.** El planificador vive dentro del backend (`src/jobs/scheduler.js`)
y arranca con él: a la hora de `DIGEST_AT` (def. 08:00) manda el digest y a la de
`ALERTS_AT` (def. 08:15) revisa las empresas colgadas. No hace falta configurar
cron ni el Task Scheduler en cada máquina — que es justo lo que se olvida el día
que se reinstala la VM.

Un control en base de datos (`job_runs`, clave única por tarea y día) impide que
se repita si pm2 reinicia el proceso, o si algún día hay más de una instancia.

Para lanzarlo a mano igualmente:

```bash
npm run digest             # usa DIGEST_PERIOD (def. daily)
npm run digest -- --period=weekly
```

El digest incluye: totales por estado, empresas colgadas, dónde se estancan y
**enviado vs. creado en Saeplus** (RF-54). Si hay registros que no llegaron, eso
manda en el asunto — es lo que hay que mirar ese día sin abrir el correo.

Variables: `DIGEST_RECIPIENTS` (correos separados por coma; requiere `SENDGRID_API_KEY`),
`DIGEST_SLACK_WEBHOOK` (webhook entrante), `DIGEST_PERIOD` (`daily` | `weekly`).
Sin destinatarios ni webhook, el resumen se imprime en consola (fallback seguro).

## Tests

`tests/reportTools.test.js`, `tests/mcpHttpServer.test.js`, `tests/digest.test.js`
(no requieren base de datos). El handshake completo y `tools/call` se verificaron
end-to-end contra la BD real.
