Servidor MCP
Espejo expone sus grabaciones como herramientas MCP para agentes de IA (Claude Code,
claude.ai, cualquier cliente MCP). Un agente se conecta a POST /mcp (Streamable
HTTP, stateless) y obtiene cinco herramientas de solo lectura, siempre acotadas al
tenant y a los proyectos que una persona autorizó manualmente.
Reemplaza el MCP del recorder single-tenant de Angirú, que autenticaba con una machine key y veía el bucket completo. Acá cada conexión lleva una cuenta, un consentimiento y un scope detrás.
agent (Claude Code / claude.ai) Espejo API browser ┌──────────────────────────────────┐ ┌───────────────────────────┐ ┌────────────────────┐ │ POST /mcp ────────── 401 ───────▶│ │ WWW-Authenticate points │ │ │ │ GET /.well-known/* ─────────────▶│──▶│ at the metadata │ │ │ │ POST /mcp/oauth/register ───────▶│ │ stores the client │ │ │ │ GET /mcp/oauth/authorize ──────▶│──▶│ stores the request, 302 ─│──▶│ /oauth/authorize │ │ │ │ │ │ console session │ │ │ │ POST /v1/mcp/oauth/ │◀──│ + which projects │ │ (browser) ◀──── redirect_to ───│───│ consent → grant + code │ │ │ │ POST /mcp/oauth/token (PKCE) ───▶│──▶│ access + refresh │ │ │ │ POST /mcp (Bearer) ─────────────▶│──▶│ tools run against the grant│ │ │ └──────────────────────────────────┘ └───────────────────────────┘ └────────────────────┘1. Superficie HTTP
Sección titulada «1. Superficie HTTP»| Endpoint | Montado en | Qué hace |
|---|---|---|
POST /mcp | mcp.setup.ts | El servidor MCP. JSON-RPC 2.0, stateless. GET/DELETE → 405. CORS abierto (claude.ai lo requiere). |
GET /.well-known/oauth-authorization-server[/mcp] | mcp.setup.ts | Metadata del issuer (RFC 8414). |
GET /.well-known/oauth-protected-resource[/mcp] | mcp.setup.ts | Metadata del recurso (RFC 9728) — a donde apunta el WWW-Authenticate del 401. |
POST /mcp/oauth/register | mcp.setup.ts | Registro dinámico de cliente (RFC 7591). |
GET /mcp/oauth/authorize | mcp.setup.ts | Inicia el flujo. Persiste el request, redirige al consentimiento. |
POST /mcp/oauth/token | mcp.setup.ts | Intercambio de código (con PKCE) y refresh rotativo. |
POST /mcp/oauth/revoke | mcp.setup.ts | Revoca un token suelto (RFC 7009). |
GET /mcp/media/:id/:kind | mcp.setup.ts | Video o replay de DOM, mediante un link firmado de corta duración. |
GET /oauth/authorize | mcp.setup.ts | La pantalla de consentimiento que ve la persona. HTML renderizado en servidor. |
GET /v1/mcp/oauth/request/:id | mcp.controller.ts | Lo que esa app está pidiendo, para renderizar la pantalla. Sesión de usuario. |
POST /v1/mcp/oauth/consent | mcp.controller.ts | Aprueba (con selección de proyectos) o deniega. Devuelve redirect_to. |
GET /v1/mcp/grants | mcp.controller.ts | Las conexiones activas del tenant. |
DELETE /v1/mcp/grants/:id | mcp.controller.ts | Revoca la conexión y todos sus tokens. |
Todo lo que no es /v1/* se monta directamente en Express, fuera del
router de Nest, y antes del catch-all de la SPA de la consola — montado
después, el catch-all respondería /oauth/authorize con el HTML de la consola
en lugar de la pantalla de consentimiento.
Los endpoints que maneja una persona (/v1/mcp/*) usan JwtAuthGuard, no
SessionOrAdminKeyGuard — ver la advertencia más arriba.
2. Las cuatro tablas
Sección titulada «2. Las cuatro tablas»Ningún valor de credencial se almacena jamás — solo su SHA-256. El texto plano existe una vez, en la respuesta que lo entrega.
| Tabla | Qué contiene |
|---|---|
mcp_oauth_client | La aplicación que se conecta, no un tenant. Registro dinámico: client_id (mcpc_…), redirect_uris exactas, nombre, método de auth (none = cliente público con PKCE). Registrarse no otorga nada por sí solo. |
mcp_oauth_request | Un request esperando que una persona lo revise: id (mcpr_…, viaja en la URL de la pantalla de consentimiento), code_challenge, redirect_uri, state, scopes. Vive 10 minutos, se consume una vez resuelto. |
mcp_oauth_grant | El consentimiento — la unidad que se muestra y se revoca: qué app, sobre qué tenant_id, autorizado por qué user_id, con qué scopes y qué proyectos (project_mode + project_ids). |
mcp_oauth_token | Códigos, access tokens y refresh tokens para un grant. Los códigos también llevan su code_challenge y redirect_uri para cerrar el PKCE. |
3. El flujo, y dónde queda fijo el scope
Sección titulada «3. El flujo, y dónde queda fijo el scope»-
Discovery y registro. El cliente recibe
401de/mcpconWWW-Authenticate, lee/.well-known/*y se registra. -
Authorize.
GET /mcp/oauth/authorizecon uncode_challengeS256. Elredirect_urise compara exactamente contra el registrado — nunca por prefijo ni por host, ya que un chequeo constartsWithdejaría pasarhttps://app.com.attacker.io. Un mismatch responde el error directamente y no redirige — redirigir allí le entregaría el error (y luego el código) a un tercero. -
Consentimiento. La pantalla lee la sesión que la consola ya dejó en
localStorage['espejo_session']— mismo origen, mismo token, sin segundo formulario de contraseña. La persona elige todos los proyectos (incluyendo los futuros) o unos específicos.El scope queda fijo acá y nunca puede ampliarse después. El
tenant_idviene deuser.tenant.idy de ningún otro lado — no de un body param, no de un URL param. Los ids de proyecto sí se eligen, y se validan contra ese tenant: un uuid ajeno falla el consentimiento completo en lugar de descartarse silenciosamente. -
Token.
POST /mcp/oauth/tokencon el código y elcode_verifier. Access token: 1 hora. Refresh: 30 días. El código es de un solo uso y se quema antes de emitir cualquier cosa. -
Refresh rotativo. Cada refresh emite un par nuevo y revoca el usado. Reutilizar un refresh token ya rotado revoca toda la familia del grant — un refresh token que vuelve dos veces implica que existen dos portadores, y no hay forma de saber cuál no debería tenerlo.
-
Revocación.
DELETE /v1/mcp/grants/:idelimina el grant y todos sus tokens. El grant se vuelve a leer en cada request a/mcp, así que el corte es inmediato — no hay que esperar a que expire el access token.
4. Las herramientas
Sección titulada «4. Las herramientas»Cinco, todas de solo lectura:
| Herramienta | Qué devuelve |
|---|---|
list_recordings | El listado, más reciente primero. Filtra por proyecto, rango de fechas, errors/video/dom, texto libre. Paginado por cursor. |
get_recording | Todo lo que Espejo sabe sobre una grabación: metadata, estado, qué objetos almacenó y links de media. La object key del bucket nunca viaja. |
recording_events | Primero el resumen, luego red, consola, interacciones y navegaciones. Las secciones largas se truncan y lo dicen. |
recording_frames | Links firmados al video y al replay de DOM. |
recording_transcript | El transcript almacenado, si existe. Espejo no produce ninguno todavía, así que hoy lo dice y apunta al video. Nunca dispara una transcripción — una herramienta de lectura que gasta dinero sola es una que alguien va a llamar por accidente. |
El scope no puede filtrarse
Sección titulada «El scope no puede filtrarse»Cada herramienta pasa por findSession o scopedWhere — los únicos dos
lugares en el archivo que escriben una cláusula where sobre sesiones. El
filtro viaja por la relación (session.project.tenant_id), no por una lista
de ids resuelta de antemano: una lista resuelta se vuelve obsoleta — un
proyecto creado después de emitir el token quedaría fuera de un grant all —
mientras que la relación se evalúa contra el estado real en cada consulta.
Una grabación fuera del scope devuelve el mismo “no existe” que una que genuinamente no existe. Nunca “existe pero no podés” — eso ya le entrega la mitad de lo que busca alguien que prueba ids.
La política de scope
Sección titulada «La política de scope»mcp.policy.ts es una tabla que mapea cada nombre de herramienta al scope
que requiere, y una herramienta sin entrada ahí no se ejecuta. Hoy todo
necesita mcp:read, lo que puede parecer decorativo — no lo es. Lo que
protege es el mañana: alguien agrega una herramienta de escritura y se olvida
de declararla acá. Con la tabla, esa herramienta simplemente no se ejecuta.
export const TOOL_POLICY: Record<string, ToolPolicy> = { list_recordings: { scope: 'mcp:read' }, get_recording: { scope: 'mcp:read' }, recording_events: { scope: 'mcp:read' }, recording_frames: { scope: 'mcp:read' }, recording_transcript: { scope: 'mcp:read' },};Y mcp:write no está en MCP_ALLOWED_SCOPES: aunque un cliente lo pida,
nunca se otorga. Una herramienta de escritura que llega antes de que alguien
agregue el scope a propósito falla de forma cerrada y visible.
5. La media nunca sale del bucket
Sección titulada «5. La media nunca sale del bucket»El bucket es privado y sus objetos nunca se sirven directamente.
get_recording y recording_frames devuelven una URL que es nuestra, firmada
con HMAC sobre sessionId | kind | exp, válida diez minutos:
https://dbuger.dnh.ar/mcp/media/<sessionId>/video?exp=…&sig=…Cambiar cualquiera de los tres campos firmados invalida el link, así que no
se puede “apuntar” a la grabación de otro tenant editando la URL. La clave de
firma deriva de JWT_SECRET pero no es JWT_SECRET — si fueran la misma,
una firma de media y una firma de sesión serían intercambiables.
Es una credencial bearer — quien la tenga puede usarla — así que vive poco.
Un link expirado responde 410; uno falsificado responde 403. Ninguno dice
si la grabación existe.
6. Configuración
Sección titulada «6. Configuración»| Variable | Para | Default |
|---|---|---|
MCP_ENABLED | Kill switch (false deshabilita todo). | habilitado |
PUBLIC_BASE_URL | Issuer OAuth, URI del recurso y base de los links de media. El mismo valor que ya usa el lado de ingest. | http://localhost:3000 |
JWT_SECRET | La sesión de la consola y la firma de los links de media. | — |
Sin JWT_SECRET o DATABASE_URL, el servidor MCP no se monta —
se loguea, no falla silenciosamente. Deliberado: un servidor OAuth que no puede
firmar ni recordar a quién autorizó no es medio servidor, es una puerta que
dice sí a todo. El resto de la API (ingest, consola) sigue funcionando igual —
misma regla que las migraciones, aplicada a la configuración.
TTLs, en mcp.config.ts: request 10 min, código 5 min, access 1 h, refresh 30
días, link de media 10 min.
7. Qué falta
Sección titulada «7. Qué falta»- Una pantalla de conexiones en la consola. Los endpoints existen
(
GET /v1/mcp/grants,DELETE /v1/mcp/grants/:id); ninguna pantalla los muestra todavía, así que revocar es concurl. - Transcripts.
recording_transcriptya sabe leer el objetotranscriptde una sesión; nada lo escribe todavía. - Herramientas de escritura (eliminar, renombrar). Cuando lleguen: declararlas
en
mcp.policy.tsconmcp:writey agregar ese scope aMCP_ALLOWED_SCOPES, que deliberadamente no lo tiene hoy.