Skip to content

MCP server

Espejo exposes its recordings as MCP tools for AI agents (Claude Code, claude.ai, any MCP client). An agent connects to POST /mcp (Streamable HTTP, stateless) and gets five read-only tools, always scoped to the tenant and the projects a person authorized by hand.

It replaces Angirú’s old single-tenant recorder MCP, which authenticated with a machine key and saw the entire bucket. Here every connection carries an account, a consent, and a scope behind it.

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│ │ │
└──────────────────────────────────┘ └───────────────────────────┘ └────────────────────┘
EndpointMounted inWhat it does
POST /mcpmcp.setup.tsThe MCP server. JSON-RPC 2.0, stateless. GET/DELETE405. CORS open (claude.ai requires it).
GET /.well-known/oauth-authorization-server[/mcp]mcp.setup.tsIssuer metadata (RFC 8414).
GET /.well-known/oauth-protected-resource[/mcp]mcp.setup.tsResource metadata (RFC 9728) — where the 401’s WWW-Authenticate points.
POST /mcp/oauth/registermcp.setup.tsDynamic client registration (RFC 7591).
GET /mcp/oauth/authorizemcp.setup.tsStarts the flow. Persists the request, redirects to consent.
POST /mcp/oauth/tokenmcp.setup.tsCode exchange (with PKCE) and rotating refresh.
POST /mcp/oauth/revokemcp.setup.tsRevokes one loose token (RFC 7009).
GET /mcp/media/:id/:kindmcp.setup.tsVideo or DOM replay, via a signed, short-lived link.
GET /oauth/authorizemcp.setup.tsThe consent screen a person sees. Server-rendered HTML.
GET /v1/mcp/oauth/request/:idmcp.controller.tsWhat that app is asking for, to render the screen. User session.
POST /v1/mcp/oauth/consentmcp.controller.tsApproves (with project selection) or denies. Returns redirect_to.
GET /v1/mcp/grantsmcp.controller.tsThe tenant’s live connections.
DELETE /v1/mcp/grants/:idmcp.controller.tsRevokes the connection and all its tokens.

Everything that isn’t /v1/* mounts directly on Express, outside Nest’s router, and before the console’s SPA catch-all — mounted after, the catch-all would answer /oauth/authorize with the console’s HTML instead of the consent screen.

The endpoints a person drives (/v1/mcp/*) use JwtAuthGuard, not SessionOrAdminKeyGuard — see the caution above.

No credential value is ever stored — only its SHA-256. The plaintext exists once, in the response that hands it out.

TableWhat it holds
mcp_oauth_clientThe application connecting, not a tenant. Dynamic registration: client_id (mcpc_…), exact redirect_uris, name, auth method (none = public client with PKCE). Registering grants nothing by itself.
mcp_oauth_requestA request waiting for a person to look at it: id (mcpr_…, travels in the consent screen’s URL), code_challenge, redirect_uri, state, scopes. Lives 10 minutes, consumed once resolved.
mcp_oauth_grantThe consent — the unit that’s shown and revoked: which app, over which tenant_id, authorized by which user_id, with which scopes and which projects (project_mode + project_ids).
mcp_oauth_tokenCodes, access and refresh tokens for a grant. Codes also carry their code_challenge and redirect_uri to close out PKCE.
  1. Discovery and registration. The client gets 401 from /mcp with WWW-Authenticate, reads /.well-known/*, and registers itself.

  2. Authorize. GET /mcp/oauth/authorize with an S256 code_challenge. redirect_uri is compared exactly against what’s registered — never by prefix or host, since a startsWith check would let https://app.com.attacker.io through. A mismatch answers the error directly and does not redirect — redirecting there would hand the error (and later, the code) to a third party.

  3. Consent. The screen reads the session the console already left in localStorage['espejo_session'] — same origin, same token, no second password form. The person picks all projects (including future ones) or specific ones.

    Scope is fixed here and can never be widened later. tenant_id comes from user.tenant.id and nowhere else — not a body param, not a URL param. Project ids are chosen, and are validated against that tenant: a foreign uuid fails the whole consent instead of silently being dropped.

  4. Token. POST /mcp/oauth/token with the code and code_verifier. Access token: 1 hour. Refresh: 30 days. The code is single-use and burned before anything is issued.

  5. Rotating refresh. Every refresh issues a new pair and revokes the one used. Reusing an already-rotated refresh token revokes the entire family of the grant — a refresh token coming back twice means two holders exist, and there’s no way to know which one shouldn’t.

  6. Revocation. DELETE /v1/mcp/grants/:id kills the grant and every one of its tokens. The grant is re-read on every /mcp request, so the cut is immediate — no waiting for the access token to expire.

Five, all read-only:

ToolWhat it returns
list_recordingsThe listing, newest first. Filters by project, date range, errors/video/dom, free text. Cursor-paginated.
get_recordingEverything Espejo knows about one: metadata, status, which objects it stored, and media links. The bucket object key never travels.
recording_eventsThe summary first, then network, console, interactions and navigations. Long sections are truncated and say so.
recording_framesSigned links to the video and the DOM replay.
recording_transcriptThe stored transcript, if there is one. Espejo doesn’t produce any yet, so today it says so and points at the video instead. Never triggers a transcription — a read tool that spends money on its own is one somebody will call by accident.

Every tool goes through findSession or scopedWhere — the only two places in the file that write a where clause over sessions. The filter rides the relation (session.project.tenant_id), not a list of ids resolved ahead of time: a resolved list goes stale — a project created after the token was issued would fall outside an all grant — while the relation is evaluated against real state on every query.

A recording outside scope returns the same “doesn’t exist” as one that genuinely doesn’t. Never “exists but you can’t” — that already hands over half of what an id-prober is looking for.

mcp.policy.ts is a table mapping every tool name to the scope it requires, and a tool with no entry there doesn’t run. Today everything needs mcp:read, which can look decorative — it isn’t. What it guards against is tomorrow: someone adds a write tool and forgets to declare it here. With the table, that tool simply doesn’t execute.

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' },
};

And mcp:write is not in MCP_ALLOWED_SCOPES: even if a client asks for it, it’s never granted. A write tool that ships before someone adds the scope on purpose fails closed, visibly.

The bucket is private and its objects are never served directly. get_recording and recording_frames return a URL that’s ours, HMAC-signed over sessionId | kind | exp, valid ten minutes:

https://dbuger.dnh.ar/mcp/media/<sessionId>/video?exp=…&sig=…

Changing any of the three signed fields invalidates the link, so it can’t be “pointed” at another tenant’s recording by editing the URL. The signing key derives from JWT_SECRET but is not JWT_SECRET — if they were the same, a media signature and a session signature would be interchangeable.

It’s a bearer credential — whoever has it can use it — so it lives briefly. An expired link answers 410; a forged one answers 403. Neither says whether the recording exists.

VariableForDefault
MCP_ENABLEDKill switch (false disables everything).enabled
PUBLIC_BASE_URLOAuth issuer, resource URI, and the base of media links. Same value the ingest side already uses.http://localhost:3000
JWT_SECRETThe console session and the media link signature.

Without JWT_SECRET or DATABASE_URL, the MCP server does not mount — logged, not silent. Deliberate: an OAuth server that can’t sign or remember who it authorized isn’t a half server, it’s a door that says yes to everything. The rest of the API (ingest, console) keeps working regardless — same rule as migrations, applied to configuration.

TTLs, in mcp.config.ts: request 10 min, code 5 min, access 1 h, refresh 30 days, media link 10 min.

  • A connections screen in the console. The endpoints exist (GET /v1/mcp/grants, DELETE /v1/mcp/grants/:id); no screen shows them yet, so revoking is curl.
  • Transcripts. recording_transcript already knows how to read a session’s transcript object; nothing writes one yet.
  • Write tools (delete, rename). When they arrive: declare them in mcp.policy.ts with mcp:write and add that scope to MCP_ALLOWED_SCOPES, which deliberately doesn’t have it today.