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│ │ │ └──────────────────────────────────┘ └───────────────────────────┘ └────────────────────┘1. HTTP surface
Section titled “1. HTTP surface”| Endpoint | Mounted in | What it does |
|---|---|---|
POST /mcp | mcp.setup.ts | The MCP server. JSON-RPC 2.0, stateless. GET/DELETE → 405. CORS open (claude.ai requires it). |
GET /.well-known/oauth-authorization-server[/mcp] | mcp.setup.ts | Issuer metadata (RFC 8414). |
GET /.well-known/oauth-protected-resource[/mcp] | mcp.setup.ts | Resource metadata (RFC 9728) — where the 401’s WWW-Authenticate points. |
POST /mcp/oauth/register | mcp.setup.ts | Dynamic client registration (RFC 7591). |
GET /mcp/oauth/authorize | mcp.setup.ts | Starts the flow. Persists the request, redirects to consent. |
POST /mcp/oauth/token | mcp.setup.ts | Code exchange (with PKCE) and rotating refresh. |
POST /mcp/oauth/revoke | mcp.setup.ts | Revokes one loose token (RFC 7009). |
GET /mcp/media/:id/:kind | mcp.setup.ts | Video or DOM replay, via a signed, short-lived link. |
GET /oauth/authorize | mcp.setup.ts | The consent screen a person sees. Server-rendered HTML. |
GET /v1/mcp/oauth/request/:id | mcp.controller.ts | What that app is asking for, to render the screen. User session. |
POST /v1/mcp/oauth/consent | mcp.controller.ts | Approves (with project selection) or denies. Returns redirect_to. |
GET /v1/mcp/grants | mcp.controller.ts | The tenant’s live connections. |
DELETE /v1/mcp/grants/:id | mcp.controller.ts | Revokes 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.
2. The four tables
Section titled “2. The four tables”No credential value is ever stored — only its SHA-256. The plaintext exists once, in the response that hands it out.
| Table | What it holds |
|---|---|
mcp_oauth_client | The 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_request | A 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_grant | The 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_token | Codes, access and refresh tokens for a grant. Codes also carry their code_challenge and redirect_uri to close out PKCE. |
3. The flow, and where scope gets fixed
Section titled “3. The flow, and where scope gets fixed”-
Discovery and registration. The client gets
401from/mcpwithWWW-Authenticate, reads/.well-known/*, and registers itself. -
Authorize.
GET /mcp/oauth/authorizewith an S256code_challenge.redirect_uriis compared exactly against what’s registered — never by prefix or host, since astartsWithcheck would lethttps://app.com.attacker.iothrough. A mismatch answers the error directly and does not redirect — redirecting there would hand the error (and later, the code) to a third party. -
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_idcomes fromuser.tenant.idand 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. -
Token.
POST /mcp/oauth/tokenwith the code andcode_verifier. Access token: 1 hour. Refresh: 30 days. The code is single-use and burned before anything is issued. -
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.
-
Revocation.
DELETE /v1/mcp/grants/:idkills the grant and every one of its tokens. The grant is re-read on every/mcprequest, so the cut is immediate — no waiting for the access token to expire.
4. The tools
Section titled “4. The tools”Five, all read-only:
| Tool | What it returns |
|---|---|
list_recordings | The listing, newest first. Filters by project, date range, errors/video/dom, free text. Cursor-paginated. |
get_recording | Everything Espejo knows about one: metadata, status, which objects it stored, and media links. The bucket object key never travels. |
recording_events | The summary first, then network, console, interactions and navigations. Long sections are truncated and say so. |
recording_frames | Signed links to the video and the DOM replay. |
recording_transcript | The 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. |
Scope can’t leak
Section titled “Scope can’t leak”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.
The scope policy
Section titled “The scope policy”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.
5. Media never leaves the bucket
Section titled “5. Media never leaves the bucket”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.
6. Configuration
Section titled “6. Configuration”| Variable | For | Default |
|---|---|---|
MCP_ENABLED | Kill switch (false disables everything). | enabled |
PUBLIC_BASE_URL | OAuth issuer, resource URI, and the base of media links. Same value the ingest side already uses. | http://localhost:3000 |
JWT_SECRET | The 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.
7. What’s missing
Section titled “7. What’s missing”- 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 iscurl. - Transcripts.
recording_transcriptalready knows how to read a session’stranscriptobject; nothing writes one yet. - Write tools (delete, rename). When they arrive: declare them in
mcp.policy.tswithmcp:writeand add that scope toMCP_ALLOWED_SCOPES, which deliberately doesn’t have it today.