Skip to main content
Glama
Biz2Gig

clarity-mcp

by Biz2Gig
README.md
# clarity-mcp

A production-capable **MCP server** (Model Context Protocol, official
`@modelcontextprotocol/sdk`, Streamable HTTP transport) that exposes:

1. **Microsoft Clarity aggregate analytics** — cached, quota-managed access to
   the [Clarity Data Export API](https://learn.microsoft.com/en-us/clarity/setup-and-installation/clarity-data-export-api).
2. **Clarity recording metadata** — import from the CSV you export in the
   Clarity dashboard (metadata only; see the limitation below).
3. **Video analysis** — ingest a video from an HTTPS URL, transcribe it,
   extract and analyze keyframes, and answer timestamp-grounded questions.

Stack (unchanged from the prototype): **React Router 7 + TypeScript + Prisma +
PostgreSQL**, plus a separate **worker** process and **FFmpeg** for video.

---

## Critical limitation: Clarity session replays are NOT available

Microsoft's documented Clarity Data Export API exposes **aggregate metrics
only**. It does **not** return session replays, and Clarity "recordings" are
DOM/event reconstructions, not MP4 files. This server:

- **Does not** scrape Clarity, call undocumented endpoints, or bypass auth.
- Imports recording **metadata** from the CSV a user exports in the Clarity
  dashboard (**Recordings → Export**). Each `recordingUrl` is the Clarity
  **dashboard deep-link**, preserved as-is — not a downloadable media file.
- Never claims a replay can be retrieved through the API.

To analyze actual video, use the video tools with a real video URL (a screen
recording, an uploaded MP4, etc.) — not a Clarity link.

---

## Architecture

```
        ┌──────────────┐        POST /mcp (JSON-RPC 2.0, Streamable HTTP)
 MCP ───▶  React Router │◀─── bearer auth, Origin/Host check, body limit
 client │  app/routes   │
        │   /mcp.ts     │──▶ @modelcontextprotocol/sdk  (McpServer, stateless
        └──────┬───────┘      WebStandard transport, JSON responses)
               │
       ┌───────┴───────────────────────────────────────────┐
       │ tools                                             │
       │  • Clarity aggregate  → app/clarity.server.ts      │
       │      cache + atomic quota ledger + snapshots       │
       │  • Clarity recordings → app/clarity/*              │
       │      tolerant CSV parser + Prisma                  │
       │  • Video               → app/video/*, enqueue only │
       └───────────────┬───────────────────────────────────┘
                       │  ProcessingJob rows (PostgreSQL)
                       ▼
              ┌──────────────────┐   FOR UPDATE SKIP LOCKED
              │  worker (npm run │   claims one job at a time
              │  worker)         │   stages: downloading → probing →
              │                  │   extracting_audio → transcribing →
              │  FFmpeg + providers│  extracting_frames → analyzing_frames →
              └────────┬─────────┘   synthesizing → completed
                       ▼
        storage (filesystem volume)   +   Prisma models
        source video, JPEG keyframes      TranscriptSegment, ExtractedFrame,
                                          VideoAnalysis, provenance
```

**Providers** are behind interfaces (`app/providers/types.ts`):
speech-to-text, vision, synthesis/QA. The shipped implementation is OpenAI
(`OPENAI_API_KEY`). Missing credentials produce a **clear configuration
error** — never a fabricated transcript or analysis. A `mock` provider exists
for tests only.

**Storage** is behind `StorageProvider` (`app/storage/types.ts`). Only a
filesystem driver ships; an S3-compatible driver can be added without touching
callers. Full video binaries are never stored in PostgreSQL.

### MCP protocol notes

- Transport: the SDK's `WebStandardStreamableHTTPServerTransport` in
  **stateless JSON mode**. `POST /mcp` is preserved. `GET /mcp` returns `405`
  (no standalone SSE stream in stateless mode — permitted by the spec).
  `DELETE /mcp` is accepted.
- **Protocol version is negotiated by the SDK** (currently up to
  `2025-11-25`). The server never falsely advertises `2024-11-05`.
- Provided by the SDK: protocol negotiation, tool discovery/among invocation,
  JSON-RPC framing and error codes, input validation against each tool's Zod
  schema, structured tool results (`structuredContent`), and text/image content
  blocks (`get_video_frame` returns an image block).
- Added by this server: bearer auth (`MCP_BRIDGE_SECRET`, constant-time
  compare), Origin/Host allow-lists, request-body size limit, and a
  compatibility shim that normalizes a lenient `Accept` header for simple
  one-shot JSON-RPC clients (disable with `MCP_STRICT_ACCEPT=true`).

---

## Environment variables

See [`.env.example`](./.env.example) for the annotated full list. Summary:

| Variable | Default | Purpose |
| --- | --- | --- |
| `DATABASE_URL` | — | Postgres connection string (**required**) |
| `MCP_BRIDGE_SECRET` | — | Bearer secret for every MCP call (**required**) |
| `MCP_ALLOWED_ORIGINS` | *(all)* | Comma list of allowed browser Origins |
| `MCP_ALLOWED_HOSTS` | *(all)* | Comma list of allowed Host headers |
| `MCP_STRICT_ACCEPT` | `false` | Require spec-strict `Accept` header |
| `MCP_MAX_BODY_BYTES` | `5242880` | Max JSON-RPC body size |
| `CLARITY_API_TOKEN` | — | Clarity Data Export API JWT |
| `CLARITY_CACHE_TTL_MS` | `21600000` | Freshness window for cached responses |
| `CLARITY_REFRESH_POLICY` | `revalidate` | `revalidate` \| `stale` (see below) |
| `CLARITY_CSV_MAX_BYTES` / `_MAX_ROWS` | `25MiB` / `200000` | CSV import limits |
| `STORAGE_DRIVER` / `STORAGE_DIR` | `filesystem` / `./storage` | Object storage |
| `VIDEO_MAX_BYTES` | `524288000` | Max downloaded video size |
| `VIDEO_MAX_DURATION_SEC` | `10800` | Max video duration |
| `VIDEO_KEYFRAME_INTERVAL_SEC` | `15` | Forced max interval between keyframes |
| `VIDEO_MAX_KEYFRAMES` | `120` | Hard cap on keyframes per video |
| `VIDEO_SCENE_THRESHOLD` | `0.4` | FFmpeg scene-change sensitivity |
| `DOWNLOAD_CONNECT_TIMEOUT_MS` / `_TOTAL_TIMEOUT_MS` | `15000` / `600000` | Download timeouts |
| `DOWNLOAD_MAX_REDIRECTS` | `3` | Redirect limit (re-validated each hop) |
| `ALLOW_INSECURE_URLS` | off-prod: `true` | Permit `http://` + private IPs (dev) |
| `FFMPEG_PATH` / `FFPROBE_PATH` | `ffmpeg` / `ffprobe` | Binary locations |
| `WORKER_ID` | `worker-<pid>` | Worker identity in job locks |
| `WORKER_POLL_INTERVAL_MS` | `3000` | Idle poll interval |
| `WORKER_JOB_TIMEOUT_MS` | `1800000` | Per-job wall-clock timeout |
| `WORKER_STALE_LOCK_MS` | `900000` | Reclaim a crashed worker's job after |
| `STT_PROVIDER` / `VISION_PROVIDER` / `SYNTHESIS_PROVIDER` | `openai` | `openai` \| `mock` |
| `OPENAI_API_KEY` / `OPENAI_BASE_URL` | — | OpenAI credentials |
| `STT_MODEL` / `VISION_MODEL` / `SYNTHESIS_MODEL` | `whisper-1` / `gpt-4o-mini` / `gpt-4o-mini` | Model IDs |
| `PROVIDERS_ALLOW_MOCK` | `false` | Force mock providers outside tests |
| `TEST_DATABASE_URL` | — | Enables DB-backed test suites |

---

## Local development

Requires **Node 20** (`.nvmrc`), **PostgreSQL**, and **FFmpeg** (for video).

```bash
npm install
cp .env.example .env          # fill DATABASE_URL, MCP_BRIDGE_SECRET, CLARITY_API_TOKEN
npx prisma migrate deploy      # or: npx prisma migrate dev
npm run dev                    # http://localhost:3000  (MCP at POST /mcp)

# in a second terminal, for video jobs:
npm run worker
```

## Database migrations

A real initial migration is committed at
`prisma/migrations/20260906000000_init/`. On a **fresh database**:

```bash
npx prisma migrate deploy      # creates every table + enum
```

All `DateTime` columns are `timestamptz` so job scheduling and quota logic are
correct regardless of the DB server's local time zone.

## Docker

```bash
export MCP_BRIDGE_SECRET=$(openssl rand -hex 32)
export CLARITY_API_TOKEN=...          # Clarity Data Export token
export OPENAI_API_KEY=...             # only needed for real video analysis
docker compose up --build
```

Brings up `db` (Postgres), `app` (web/MCP, runs `prisma migrate deploy` on
start), and `worker`. They share a `clarity_storage` volume mounted at
`/data/storage`. Scale workers:

```bash
docker compose up --scale worker=3
```

The Dockerfile is a **multi-stage** build: a full `npm ci` for the build
stage, then `npm prune --omit=dev`; the runtime image is `node:20-bookworm-slim`
with `ffmpeg` installed, prod dependencies only, running as the non-root `node`
user. `NODE_ENV=production` is set only in the runtime stage.

## Worker

```bash
npm run worker            # tsx app/worker.ts
```

- Claims jobs with `SELECT ... FOR UPDATE SKIP LOCKED` — **run as many as you
  like**; a job is processed by exactly one worker.
- A crashed worker's job is reclaimed after `WORKER_STALE_LOCK_MS`.
- Failures keep their error message and retry with exponential backoff up to
  `maxAttempts` (3), then the job and video are marked `failed`.
- Graceful `SIGINT`/`SIGTERM` (waits briefly for an in-flight job).

## MCP client configuration

Streamable HTTP endpoint, bearer auth:

```jsonc
{
  "mcpServers": {
    "clarity": {
      "type": "http",
      "url": "https://your-host.example/mcp",
      "headers": { "Authorization": "Bearer <MCP_BRIDGE_SECRET>" }
    }
  }
}
```

---

## Tools

### Clarity aggregate analytics (unchanged from the prototype)

`clarity_live_insights`, `get_traffic`, `get_engagement_time`,
`get_scroll_depth`, `get_popular_pages`, `get_dead_clicks`, `get_rage_clicks`,
`get_quickback_clicks`, `get_excessive_scroll`, `get_script_errors`,
`get_error_clicks`, `clarity_history`, `clarity_quota_status`,
`list_dimensions`.

- The 10 metric tools share **one cached fetch** per `(numOfDays + dimensions)`
  — asking for all of them costs 0 extra API calls.
- Dimensions are de-duplicated (a repeated dimension is dropped;
  `meta.droppedDuplicateDimensions` reports it). Max 3 distinct.
- **Quota reservation is atomic**: a single conditional `UPDATE` increments the
  per-UTC-day counter only while it is below 10, so concurrent refreshes can
  never exceed the daily allowance.

**Cache refresh policy** (`CLARITY_REFRESH_POLICY`):

| Situation | Result |
| --- | --- |
| Fresh cache (age < TTL) | returned immediately, `meta.stale=false` |
| Stale + quota remains, `revalidate` (default) | refresh now, return fresh data |
| Stale + quota remains, `stale` | stale data + `meta.warning`; refresh only with `forceRefresh` |
| Stale + quota exhausted | stale data + `meta.warning` naming the reset time |
| No cache + quota exhausted | `quota_exhausted` error |
| `forceRefresh: true` | always calls the API if any quota remains |

Stale data is **never** returned with `meta.stale=false`.

**`clarity_history`** returns snapshots of **rolling** 1–3 day aggregate
windows (`fetchedAt` labels each). Overlapping snapshots are point-in-time
captures — **do not sum them** as daily data. Every response repeats this note.

### Clarity recording metadata

**`import_clarity_recordings`** — provide **exactly one** of `csvText` /
`csvUrl` (both or neither is rejected). `csvUrl` gets the same SSRF and
size checks as video URLs. Rows are deduplicated by recording URL, else
session ID, else a hash of the row. `replaceExisting: true` wipes the table
first. Returns `{ inserted, updated, skipped, invalidRows, duplicatesInFile,
totalRows, headerMap, note }`. The parser tolerates Clarity column-name
variations (e.g. `Recording link` / `Recording URL` / `Replay URL`;
`Duration` / `Duration (s)` / `Duration ms`; `Country` / `Country/Region` /
`Geo`). The complete original row is stored in a JSON `raw` field.

**`search_clarity_recordings`** — filters: `from`/`to` (start time),
`entryUrl`, `exitUrl`, `url` (either), `device`, `browser`, `country`,
`minDurationMs`, `hasRageClicks`, `hasDeadClicks`, `hasScriptErrors`; paginate
with `limit`/`offset` (returns `nextOffset`). Each result carries the original
`recordingUrl`.

**`get_clarity_recording`** — by `id`, `sessionId`, or `recordingUrl` (exactly
one). Includes the full original CSV row.

### Video

| Tool | Purpose |
| --- | --- |
| `submit_video` | Queue a video from an HTTPS `videoUrl`. Returns `{ videoId, jobId, status }` immediately. Options: `title`, `language`, `analysisPrompt`, `extractVisuals`. |
| `get_video_status` | Stage (`queued`…`completed`/`failed`), progress %, step count, error, video metadata, timestamps. |
| `get_video_analysis` | Executive summary, full description, transcript summary, important events, visible text/OCR, detected objects/interfaces, problems, recommendations, timestamp citations, model provenance. `detail: "compact" \| "detailed"`. |
| `get_video_transcript` | Paginated segments (`startMs`, `endMs`, `text`, `speaker?`, `confidence?`). Optional `startTime`/`endTime` window (seconds, `MM:SS`, `HH:MM:SS.mmm`, `1m30s`, `1500ms`). |
| `query_video` | Answers **only** from stored transcript + keyframes + analysis. Every claim carries supporting timestamps; states explicitly when evidence is insufficient. Optional `startTime`/`endTime`. |
| `get_video_frame` | Nearest extracted JPEG keyframe to `timestamp`, returned as an MCP **image content block** plus exact timestamp + description. |

Video is **never** sent inline as base64. `submit_video` returns before any
processing; the worker does the work.

### Supported formats & limits

- Video containers: whatever the target FFmpeg build accepts (mp4/mov/webm/
  mkv/mpeg/avi/…). Response `Content-Type` must be a video type or
  `application/octet-stream`; FFprobe must find a positive duration.
- Size ≤ `VIDEO_MAX_BYTES` (500 MB default), duration ≤
  `VIDEO_MAX_DURATION_SEC` (3 h default).
- Keyframes: scene-change detection combined with a forced maximum interval;
  hard cap `VIDEO_MAX_KEYFRAMES` (interval auto-widens so the cap holds).
- CSV ≤ `CLARITY_CSV_MAX_BYTES` (25 MB), ≤ `CLARITY_CSV_MAX_ROWS`.

---

## Security & privacy

**Remote URLs** (`videoUrl`, `csvUrl`):

- HTTPS required (plain `http://` only for `localhost` when
  `ALLOW_INSECURE_URLS=true`).
- DNS is resolved and **every** answer checked; private, loopback, link-local,
  CGNAT and cloud-metadata ranges (IPv4 + IPv6) are blocked in production.
- The policy is **re-applied after every redirect**; redirects are capped.
- Connection + total-download timeouts; byte cap enforced from both
  `Content-Length` and the live stream.
- Response `Content-Type` is validated; FFprobe validates the actual media.
- Filenames are sanitized; temp files are deleted after processing.
- Authorization headers and secrets are never logged. Bearer comparison is
  constant-time.

**Personal data.** Clarity applies masking to recordings, but imported
metadata, and especially **video transcripts and extracted frames**, may still
contain personal or sensitive information (names, emails on screen, faces,
voices, internal URLs). Treat the database and the storage volume as
sensitive:

- Restrict `MCP_BRIDGE_SECRET` to trusted callers; put the endpoint behind TLS
  and, ideally, network controls.
- Set a retention policy: periodically delete old `VideoAsset` rows (cascades
  to segments/frames/analysis) and their storage prefixes; prune
  `ClarityRecording` and `InsightsSnapshot`.
- Keep presigned `videoUrl` TTLs short. Presigned/credentialed URLs are stored
  to allow asynchronous processing but are **redacted** from all tool output.
- The storage volume holds the original video and JPEG keyframes — encrypt it
  at rest and limit access.

---

## Example JSON-RPC calls

All calls: `POST /mcp`, headers `Content-Type: application/json` and
`Authorization: Bearer $MCP_BRIDGE_SECRET`.

```jsonc
// initialize (the SDK negotiates the protocol version)
{"jsonrpc":"2.0","id":1,"method":"initialize",
 "params":{"protocolVersion":"2025-06-18","capabilities":{},
           "clientInfo":{"name":"my-client","version":"1.0.0"}}}

// discover tools
{"jsonrpc":"2.0","id":2,"method":"tools/list"}

// --- Clarity aggregate ---
{"jsonrpc":"2.0","id":3,"method":"tools/call",
 "params":{"name":"get_traffic","arguments":{"numOfDays":2,"dimension1":"OS","dimension2":"Country/Region"}}}

{"jsonrpc":"2.0","id":4,"method":"tools/call",
 "params":{"name":"clarity_live_insights","arguments":{"numOfDays":3,"forceRefresh":true}}}

{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"clarity_quota_status"}}

// --- Clarity recordings ---
{"jsonrpc":"2.0","id":6,"method":"tools/call",
 "params":{"name":"import_clarity_recordings",
           "arguments":{"csvUrl":"https://example.com/exports/clarity-recordings.csv"}}}

{"jsonrpc":"2.0","id":7,"method":"tools/call",
 "params":{"name":"import_clarity_recordings",
           "arguments":{"csvText":"Recording link,Session ID,Duration,Device\nhttps://clarity.microsoft.com/player/p/abc/s1,s1,00:01:30,Desktop","replaceExisting":false}}}

{"jsonrpc":"2.0","id":8,"method":"tools/call",
 "params":{"name":"search_clarity_recordings",
           "arguments":{"from":"2026-02-01T00:00:00Z","device":"Desktop","hasRageClicks":true,"limit":25,"offset":0}}}

{"jsonrpc":"2.0","id":9,"method":"tools/call",
 "params":{"name":"get_clarity_recording","arguments":{"sessionId":"s1"}}}

// --- Video ---
{"jsonrpc":"2.0","id":10,"method":"tools/call",
 "params":{"name":"submit_video",
           "arguments":{"videoUrl":"https://example.com/screen-recording.mp4",
                        "title":"Checkout walkthrough","language":"en",
                        "analysisPrompt":"focus on checkout friction","extractVisuals":true}}}

{"jsonrpc":"2.0","id":11,"method":"tools/call",
 "params":{"name":"get_video_status","arguments":{"videoId":"<videoId>"}}}

{"jsonrpc":"2.0","id":12,"method":"tools/call",
 "params":{"name":"get_video_analysis","arguments":{"videoId":"<videoId>","detail":"detailed"}}}

{"jsonrpc":"2.0","id":13,"method":"tools/call",
 "params":{"name":"get_video_transcript","arguments":{"videoId":"<videoId>","startTime":"1:30","endTime":"2:15","limit":100,"offset":0}}}

{"jsonrpc":"2.0","id":14,"method":"tools/call",
 "params":{"name":"query_video",
           "arguments":{"videoId":"<videoId>","question":"When does the user hit an error at checkout?"}}}

{"jsonrpc":"2.0","id":15,"method":"tools/call",
 "params":{"name":"get_video_frame","arguments":{"videoId":"<videoId>","timestamp":"00:01:42.000"}}}
```

### End-to-end video flow

```bash
SECRET=your_mcp_bridge_secret
call() { curl -s localhost:3000/mcp -H "Authorization: Bearer $SECRET" \
              -H 'Content-Type: application/json' -d "$1"; }

# 1. submit -> returns videoId + jobId, immediately
VID=$(call '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"submit_video","arguments":{"videoUrl":"https://example.com/clip.mp4"}}}' \
      | jq -r '.result.structuredContent.videoId')

# 2. poll status until "completed" (the worker must be running)
call "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"get_video_status\",\"arguments\":{\"videoId\":\"$VID\"}}}" | jq '.result.structuredContent.stage'

# 3. read the analysis
call "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"get_video_analysis\",\"arguments\":{\"videoId\":\"$VID\"}}}" | jq '.result.structuredContent'

# 4. ask a grounded question
call "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\",\"params\":{\"name\":\"query_video\",\"arguments\":{\"videoId\":\"$VID\",\"question\":\"What products appear on screen?\"}}}" | jq '.result.structuredContent'
```

---

## Testing

```bash
npm test                 # unit + contract suites (no services needed;
                         # DB / ffmpeg suites self-skip with a notice)

npm run test:integration # boots a throwaway embedded Postgres, applies the
                         # migration, and runs EVERY suite including the
                         # end-to-end pipeline (uses bundled static ffmpeg)
```

To run the DB suites against your own Postgres:

```bash
TEST_DATABASE_URL=postgres://... npx prisma migrate deploy
TEST_DATABASE_URL=postgres://... npm test
```

Coverage: MCP init/discovery/list/call, JSON-RPC + auth + origin errors,
existing Clarity tools, CSV parsing + column variations + dedupe, search
filters + pagination, atomic quota reservation under concurrency, stale-cache
behavior, video job state machine (exclusive claim, retry/backoff, permanent
fail), transcript pagination + windowing, timestamp conversion, nearest-frame,
missing provider credentials, download size + MIME enforcement, SSRF + redirect
protection, and a full download→transcribe→frames→analyze→query pipeline. All
provider calls are mocked; no test makes a paid API call. The test video is
generated with ffmpeg at runtime — no binary fixture is committed.

## License

MIT — see [LICENSE](./LICENSE).