Ir al contenido

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│ │ │
└──────────────────────────────────┘ └───────────────────────────┘ └────────────────────┘
EndpointMontado enQué hace
POST /mcpmcp.setup.tsEl servidor MCP. JSON-RPC 2.0, stateless. GET/DELETE405. CORS abierto (claude.ai lo requiere).
GET /.well-known/oauth-authorization-server[/mcp]mcp.setup.tsMetadata del issuer (RFC 8414).
GET /.well-known/oauth-protected-resource[/mcp]mcp.setup.tsMetadata del recurso (RFC 9728) — a donde apunta el WWW-Authenticate del 401.
POST /mcp/oauth/registermcp.setup.tsRegistro dinámico de cliente (RFC 7591).
GET /mcp/oauth/authorizemcp.setup.tsInicia el flujo. Persiste el request, redirige al consentimiento.
POST /mcp/oauth/tokenmcp.setup.tsIntercambio de código (con PKCE) y refresh rotativo.
POST /mcp/oauth/revokemcp.setup.tsRevoca un token suelto (RFC 7009).
GET /mcp/media/:id/:kindmcp.setup.tsVideo o replay de DOM, mediante un link firmado de corta duración.
GET /oauth/authorizemcp.setup.tsLa pantalla de consentimiento que ve la persona. HTML renderizado en servidor.
GET /v1/mcp/oauth/request/:idmcp.controller.tsLo que esa app está pidiendo, para renderizar la pantalla. Sesión de usuario.
POST /v1/mcp/oauth/consentmcp.controller.tsAprueba (con selección de proyectos) o deniega. Devuelve redirect_to.
GET /v1/mcp/grantsmcp.controller.tsLas conexiones activas del tenant.
DELETE /v1/mcp/grants/:idmcp.controller.tsRevoca 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.

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.

TablaQué contiene
mcp_oauth_clientLa 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_requestUn 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_grantEl 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_tokenCó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.
  1. Discovery y registro. El cliente recibe 401 de /mcp con WWW-Authenticate, lee /.well-known/* y se registra.

  2. Authorize. GET /mcp/oauth/authorize con un code_challenge S256. El redirect_uri se compara exactamente contra el registrado — nunca por prefijo ni por host, ya que un chequeo con startsWith dejaría pasar https://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.

  3. 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_id viene de user.tenant.id y de ningún otro lado — no de un body param, no de un URL param. Los ids de proyecto se eligen, y se validan contra ese tenant: un uuid ajeno falla el consentimiento completo en lugar de descartarse silenciosamente.

  4. Token. POST /mcp/oauth/token con el código y el code_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.

  5. 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.

  6. Revocación. DELETE /v1/mcp/grants/:id elimina 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.

Cinco, todas de solo lectura:

HerramientaQué devuelve
list_recordingsEl listado, más reciente primero. Filtra por proyecto, rango de fechas, errors/video/dom, texto libre. Paginado por cursor.
get_recordingTodo 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_eventsPrimero el resumen, luego red, consola, interacciones y navegaciones. Las secciones largas se truncan y lo dicen.
recording_framesLinks firmados al video y al replay de DOM.
recording_transcriptEl 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.

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.

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.

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.

VariableParaDefault
MCP_ENABLEDKill switch (false deshabilita todo).habilitado
PUBLIC_BASE_URLIssuer 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_SECRETLa 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.

  • 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 con curl.
  • Transcripts. recording_transcript ya sabe leer el objeto transcript de una sesión; nada lo escribe todavía.
  • Herramientas de escritura (eliminar, renombrar). Cuando lleguen: declararlas en mcp.policy.ts con mcp:write y agregar ese scope a MCP_ALLOWED_SCOPES, que deliberadamente no lo tiene hoy.