Skip to main content
Glama
meovan07
by meovan07
README.md
# mcp-evidence

An MCP server that drives a real Chromium browser (via Playwright) and packages
the run into **evidence** — screenshots, a video, and a Playwright trace —
proving a feature works. Meant for an agent to call after implementing
something, as a verification step.

Generic and reusable: no assumptions about any particular app's routes or
auth. You pass a `baseUrl` per session, and evidence is written into the
*consuming* project's working directory at
`.evidence/<featureName>/<timestamp>/`.

## Install

Browsers aren't bundled — install Chromium once per machine:

```bash
npx playwright install chromium
```

### Register with Claude Code

Per-user (available in every project):

```bash
claude mcp add --scope user evidence -- npx -y github:meovan07/mcp-evidence
```

Or per-project, add to `.mcp.json`:

```json
{
  "mcpServers": {
    "evidence": {
      "command": "npx",
      "args": ["-y", "github:meovan07/mcp-evidence"]
    }
  }
}
```

This repo is public, so no GitHub credentials are needed on the machine
running `npx`.

Consuming projects should add `.evidence/` to their own `.gitignore`.

## Tools

| Tool | Purpose |
|---|---|
| `start_evidence_session({ featureName, baseUrl?, browser?, storageStatePath?, device?, displayMode? })` | Launches a browser context with video + trace recording on. Returns `sessionId`. |
| `navigate({ sessionId, url })` | Goes to `url` (resolved against `baseUrl` if relative). |
| `click({ sessionId, selector? , role?, name?, timeout? })` | Clicks an element, located by CSS/text `selector` or ARIA `role`/`name`. |
| `fill({ sessionId, selector, value, timeout? })` | Fills a form field. |
| `drag({ sessionId, sourceSelector/Role/Name, targetSelector/Role/Name, timeout? })` | Drags a source element onto a target, firing native HTML5 drag events. |
| `drag_by_offset({ sessionId, selector, dx, dy, steps? })` | Drags an element by a pixel distance — no drop target. For resize handles, sliders, swipe gestures. |
| `wait_for({ sessionId, selector?, text?, state?, timeout? })` | Waits for an element to reach a state (default `visible`). |
| `set_network({ sessionId, offline })` | Simulates losing (`offline: true`) or restoring (`offline: false`) internet connectivity, for testing error banners/retry/reconnect behavior. |
| `set_display_mode({ sessionId, mode })` | Mid-session version of `displayMode` above — see PWA section below. |
| `evaluate({ sessionId, script })` | Runs a JS expression in the page, returns the (JSON-serializable) result. |
| `snapshot({ sessionId, selector?, boxes? })` | Returns the accessibility tree as YAML (role, name, ref, bounding box) — see below. |
| `screenshot({ sessionId, name, fullPage?, returnImage? })` | Saves a PNG immediately into the evidence dir. Survives even if the session later errors. Pass `returnImage: true` to also get it back inline. |
| `finish_evidence_session({ sessionId, summary? })` | Closes the context, finalizes `video.webm`, stops tracing (`trace.zip`), writes `network.json` and `manifest.json`, returns counts of console/page/network errors seen. |

A session left open for 10 minutes with no tool calls is auto-finished. The
server also flushes any open sessions on `SIGINT`/`SIGTERM` so evidence isn't
lost if the process is killed mid-run.

### Diagnostics on failure

`click`, `fill`, `drag`, `drag_by_offset`, and `wait_for` are the tools most
likely to fail while you're still figuring out an unfamiliar page — wrong
selector, element not visible yet, wrong assumption about the DOM. Rather
than returning a bare Playwright timeout, the error has a compact
accessibility snapshot of the page auto-attached, so you can usually see
what actually happened and correct course in the same round-trip instead of
needing a follow-up `snapshot()`/`evaluate()` call just to find out why.

Playwright's own error text is also cleaned up before it reaches you:
terminal ANSI color codes are stripped (pure noise outside a terminal), and
the actionability retry log — which can run to dozens of near-identical
"waiting for element to be visible, enabled and stable" lines on a slow or
animating element — is capped at the first few lines plus a count of how
many were omitted.

### Browser choice

`start_evidence_session` defaults to Chromium. Pass `browser: "firefox"` or
`browser: "webkit"` to use a different engine — `webkit` is the open-source
engine behind Safari, the closest available option for catching
Safari-specific bugs (though not a literal Safari build; some macOS-only
behavior like Intelligent Tracking Prevention may differ slightly). Install
the extra engines once per machine:

```bash
npx playwright install webkit firefox
```

`manifest.json` records which engine a session used (`browserEngine`).

Every session also passively records, into `manifest.json`, anything a
screenshot or video wouldn't show: browser console errors and uncaught page
exceptions. `finish_evidence_session` reports the counts directly so a
silent failure doesn't slip through unnoticed.

Every request the page makes — not just failures — is also logged to
`network.json` (method, url, resource type, status, timing), the same data
Chrome DevTools' Network tab shows. `manifest.json` keeps a filtered
`networkIssues` list (non-2xx/failed only) plus a `networkRequestCount` for a
quick read without opening the full log. (The `trace.zip` already had this
same data in Playwright's own trace viewer; this just makes it directly
readable by the calling agent too, not only a human opening the trace UI.)

### Reusing an authenticated session (`storageStatePath`)

Every session starts from a completely clean browser context by default —
good for testing a fresh visit, but it means any auth-gated flow (email OTP,
password login) has to be redone from scratch on every single run. Pass
`storageStatePath` to skip that:

```
start_evidence_session({ featureName: "checkout", baseUrl, storageStatePath: ".evidence/.auth/test-user.json" })
```

- **First run**: the file doesn't exist yet, so the session starts fresh
  (logged out) as usual. Log in normally with `navigate`/`fill`/`click`
  (using `wait_for_email` from `mcp-evidence-api` if it's an OTP flow). When
  `finish_evidence_session` runs, the session's cookies + localStorage are
  saved to that path.
- **Every subsequent run** that passes the *same* `storageStatePath`: the
  file exists, so the session loads it and starts already signed in —
  `start_evidence_session`'s response says
  `[loaded existing storage state, likely pre-authenticated]`. The state is
  refreshed on every `finish_evidence_session` too, so rotating session
  tokens stay current.

**This file contains live session credentials** — treat it exactly like a
password. Store it under `.evidence/` (already covered by the gitignore
guidance below) or another path you've confirmed is gitignored, never commit
it, and use a separate path per test account/user if you're testing more
than one identity.

### Mobile / tablet emulation (`device`)

Pass a Playwright device name to emulate a real device's viewport, user
agent, touch support, and pixel ratio instead of a generic desktop window:

```
start_evidence_session({ featureName: "checkout mobile", baseUrl, device: "iPhone 13" })
```

Common values: `"iPhone 13"`, `"Pixel 5"`, `"iPad Pro 11"` — see
[Playwright's device list](https://github.com/microsoft/playwright/blob/main/packages/playwright-core/src/server/deviceDescriptorsSource.json)
for the full set (includes landscape variants, older devices, etc.). An
invalid name throws immediately with that link. `manifest.json` records
which device a session used.

### PWA testing

Two genuinely different things fall under "PWA testing," with different
support levels here:

- **Offline / service-worker-cache behavior** — fully supported today via
  `set_network({ sessionId, offline: true })`. This is the actual mechanism
  a PWA is tested against: does it serve cached content, show a fallback
  UI, sync back up when reconnected.
- **"Installed" look/behavior** (`display-mode: standalone`) — **partially**
  supported, and it's worth understanding the real limitation rather than
  assuming it's fully covered. There is no browser API — CDP or otherwise —
  that can force the native CSS `@media (display-mode: standalone)` feature
  to match (confirmed by testing every parameter combination of Chrome
  DevTools Protocol's media-emulation command, and cross-checked against
  Puppeteer's docs, which list only `prefers-color-scheme`,
  `prefers-reduced-motion`, and `color-gamut` as supported — not
  `display-mode`). What `displayMode`/`set_display_mode` actually do is
  override the JS `window.matchMedia()` function, so an app's **JS**
  install-detection logic (`matchMedia('(display-mode: standalone)').matches`
  — a common real pattern, e.g. to hide an "Install app" banner once
  running standalone) reports the requested mode correctly. Any **CSS**
  written as `@media (display-mode: standalone) { ... }` will not be
  affected — the browser's CSS engine evaluates that independently of the
  JS function, and nothing in Playwright or CDP can override it.

```
start_evidence_session({ featureName: "pwa install banner", baseUrl, displayMode: "standalone" })
// or mid-session:
set_display_mode({ sessionId, mode: "standalone" })
```

For a full pixel-accurate "how does this look actually installed" check —
CSS included — there's no automatable substitute for genuinely installing
it (desktop: Chrome's `--app=<url>` launch mode; mobile: an actual
home-screen install on a real or emulated device), which is outside this
tool's scope.

### Accessibility snapshot (`snapshot`)

Reach for this before reaching for `evaluate` or trial-and-error `click`
calls to figure out what's on a page. It returns Playwright's aria snapshot
— a YAML tree of role, accessible name, a stable `ref`, and (by default) a
bounding box for every element:

```
snapshot({ sessionId })
  -> - generic [ref=e1] [box=0,0,393,727]:
       - button "Ask Coach..." [ref=e45] [box=40,412,321,24]
       - button [disabled] [box=337,775,40,40]:
           - img [box=347,785,20,20]
```

Two things this surfaces for free that a screenshot or DOM query won't:
elements with **missing accessible names** (the second button above is
icon-only with no `aria-label` — a real accessibility gap, not just
inconvenient for automation), and **disabled state** (explains why a filled
form's submit button won't respond, immediately, instead of via minutes of
click-timeout debugging). Pass `selector` to scope it to part of the page
instead of the whole thing, or `boxes: false` to drop the bounding boxes
if you only need structure.

### Distance-based drag (`drag_by_offset`)

`drag()` is element-to-element (drag *this* onto *that*) — built for
reordering/drop-target interactions. Some gestures have no target element:
a bottom-sheet resize handle, a slider, swipe-to-reveal. `drag_by_offset`
grabs `selector` and moves the mouse by `(dx, dy)` pixels instead:

```
drag_by_offset({ sessionId, selector: "#resize-handle", dx: 0, dy: -250 })
```

It uses Playwright's real mouse API, dispatched via CDP as trusted OS-level
input — not JS-synthesized `PointerEvent`s. That distinction matters: some
UI libraries (Radix, among others) ignore untrusted synthetic events
entirely, so a `dispatchEvent(new PointerEvent(...))` approach can silently
no-op even though the element clearly has a `pointerdown` listener attached.
Validated against both a native `<input type="range">` (jumped straight to
its max when dragged past the track) and a custom `pointerdown`/
`pointermove`-based drag handle (moved exactly the requested distance) —
the same pattern that didn't respond to synthetic events in earlier testing.

### Example

```
start_evidence_session({ featureName: "checkout flow", baseUrl: "http://localhost:3000" })
  -> sessionId, evidenceDir

navigate({ sessionId, url: "/cart" })
click({ sessionId, role: "button", name: "Checkout" })
fill({ sessionId, selector: "#email", value: "test@example.com" })
wait_for({ sessionId, selector: "#confirmation", state: "visible" })
screenshot({ sessionId, name: "confirmation" })
finish_evidence_session({ sessionId, summary: "Checkout completes and shows confirmation" })
```

Resulting evidence directory:

```
.evidence/checkout-flow/2026-07-08T07-52-21-042Z/
  0-confirmation.png
  video.webm
  trace.zip
  network.json
  manifest.json
```

View the trace with `npx playwright show-trace trace.zip`.

## Known limitations

- If the process is killed within roughly a second of a session starting or
  navigating (tracing/video still warming up), the video or trace for that
  session may be incomplete or missing. Screenshots and `manifest.json` are
  written eagerly and always survive. This doesn't affect the normal flow of
  calling `finish_evidence_session` at the end of a run.
- One Chromium process per session. Idle sessions are reaped after 10 minutes
  to avoid orphaned processes.

## Development

```bash
npm install
npm run build   # tsc -> dist/
npm run dev     # tsc --watch
npm start        # node dist/index.js
```

TDQS

A3.8/5.0

Scored across 7 tools

Disambiguation5/5

Each tool has a distinct purpose: session management (start/finish), navigation, page interaction (click/fill/wait), and evidence capture (screenshot). No overlap in functionality.

Naming Consistency5/5

All tools use snake_case with a verb_noun pattern (e.g., start_evidence_session, finish_evidence_session, wait_for), and the naming is clear and predictable.

Tool Count5/5

Seven tools cover the essential workflow for evidence gathering without being excessive or insufficient. Each tool earns its place.

Completeness4/5

The set covers the full lifecycle: launch, navigate, interact (click, fill, wait), capture (screenshot), and finalize. Minor omissions like scroll or keyboard input are not critical for the primary use case.

Maintenance

ActivityStale
ResponsivenessNo issues