Skip to main content
Glama
ravi-03

request-finder

by ravi-03
README.md
# Netscribe

A Chrome (Manifest V3) extension that captures network traffic across your tabs
and lets you search it with a query language — without opening DevTools.

> **Netscribe was formerly Request Finder — they're the same project.** The old
> name is still the internal identifier throughout, and that's deliberate: it's
> what the MCP server is registered as and where the daemon keeps its state, so
> renaming it would break existing setups. Wherever you see `request-finder`,
> read *Netscribe*:
>
> | Where | Value |
> |-------|-------|
> | MCP server id | `request-finder` (in `claude mcp add`) |
> | Daemon state | `~/.request-finder/` (token, `history.db`, logs) |
> | npm package / scripts | `request-finder`, `npm run bridge:uninstall` |
> | Extension name in Chrome | Request Finder |

**What it captures**, across every tab, all the time:

- **HTTP API calls** — XHR/fetch and ordinary page loads: URL, method, status,
  headers, and timing.
- **GraphQL** — captured like any other HTTP call (typically `POST /graphql`).
  Under Deep Capture its query and variables are searchable body text, so
  `body:` will match an operation name.
- **RPC** — JSON-shaped RPC (JSON-RPC, tRPC, Connect in JSON mode) is captured
  in full, bodies included. Protobuf-shaped RPC (gRPC-Web, Connect in proto
  mode) is captured too — URL, status, headers, timing — but its body is binary
  protobuf, so `body:` won't match it.
- **WebSockets** — the handshake plus the individual frames sent and received
  (ADR 0006), including binary ones. RPC tunneled over a socket is searchable
  frame by frame.

**Ask Claude about a request.** With the bridge daemon running, Netscribe
exposes your captured traffic to Claude Code over MCP — so you can ask "why did
this call 401?" or "what changed between these two requests?" and Claude can
read the real headers and bodies instead of guessing. Each request has a
**Request Ref** you can paste to point Claude at one exact call. See
[Claude Code integration](#claude-code-integration-bridge-daemon) below.

The search UI lives in a dedicated full-tab page — **click the Request Finder
toolbar icon** to open it.

> **Privacy note:** Netscribe records request/response headers (including
> `Authorization`, `Cookie`, API keys) and — under Deep Capture — request and
> response bodies, in plaintext IndexedDB on your machine. It is a personal
> debugging tool for your own traffic. Use the **Discard list** (domains never
> captured — the button right of Purge all), **Pause**, and **Purge all**
> guardrails, and the secret-masking toggle. Purge all clears the extension's
> history and, if you tick the box, the Claude/MCP daemon's history too
> (otherwise the daemon keeps its own copy — ADR 0008). See `docs/adr/0003`.

## Install & build

A `Makefile` wraps the npm scripts, so building the extension is one command:

```bash
make               # fresh build → dist/, ready to load unpacked
```

`make` wipes `dist/`, installs dependencies if a lockfile has moved, then runs
the typecheck and production build. Other targets (`make help` lists them all):

```bash
make dev           # Vite + CRXJS watch build
make test          # Vitest unit suite (parser, evaluator, index, IDB)
make test-e2e      # Playwright smoke (builds first — it needs dist/)
make check         # unit suite + a fresh build
make clean         # drop build output; `make distclean` also drops node_modules
```

The underlying `npm run build` / `npm test` scripts still work if you prefer them.

Load the unpacked extension: `chrome://extensions` → Developer mode → **Load
unpacked** → select `dist/`.

## Capture model

| Path | API | Scope | Captures | Cost |
|------|-----|-------|----------|------|
| **Metadata Capture** | `chrome.webRequest` | all tabs, always on | URL, method, headers, status, timing | silent |
| **Deep Capture** | `chrome.debugger` | opt-in, per tab | the above **+ request/response bodies** | shows a debugging banner; can't coexist with DevTools |

`body:` and `response:` only match Deep-Captured requests and degrade
gracefully on the rest (ADR 0001). Storage is a ring buffer (default 50k,
configurable) — the oldest request is evicted past the cap (ADR 0002).

<img width="2880" height="1534" alt="Screenshot_2026-07-20_blurred" src="https://github.com/user-attachments/assets/7a000901-479d-4369-8ae5-5119521c767f" />


## Query language

```
method:POST has:authorization          # space = AND
status:500 OR status:401               # explicit OR (binds looser than AND)
domain:slack.com -status:200           # '-' negates
(status:500 OR status:401) method:POST # parentheses group
```

| Operator | Meaning |
|----------|---------|
| `has:<name>` | header present (request or response) |
| `header:<name>` | header present (alias of `has:`) |
| `header:<name>=<value>` | header value contains `<value>` |
| `method:<m>` | exact method |
| `status:<spec>` | `500`, class `5xx`, or comparison `>=400` / `<500` |
| `url:<substr>` | substring of the full request URL |
| `page:<substr>` | substring of the page URL the request fired from (live SPA route) |
| `domain:<host>` | host or any subdomain (`slack.com` matches `api.slack.com`) |
| `cookie:<name>` | cookie present in the request `Cookie` header |
| `tab:<substr>` | substring of the originating tab's title (as captured) |
| `is:ws` / `is:sent` / `is:received` / `is:http` | WebSocket frames (any / sent / received) or HTTP records |
| `body:"…"` | substring of the **request** body (HTTP, Deep Capture only) or a **sent** WS frame's payload (always captured) |
| `response:"…"` | substring of the **response** body (HTTP, Deep Capture only) or a **received** WS frame's payload (always captured) |
| bare word | substring across URL, headers, bodies, and frame payloads |

WebSocket **messages are captured by default** — a content script wraps
`window.WebSocket` (ADR 0006/0007), so frames are recorded with no debugger, no
banner, and **even while DevTools is open**. Deep Capture is *not* involved in
WS frames at all (it stays HTTP-only, for response bodies).

The Finder Page has a top-level **Requests | WebSockets** switch. The
**WebSockets** view shows frames only — `▲ WS` sent / `▼ WS` received — and the
search box takes the same operators: `response:"chat_message"` (received
payload), `is:sent`, `domain:hiver.space`, `tab:outlook`. The handshake itself
appears in the **Requests** view as a `websocket` request (recorded at the
`101`, since a live socket never "completes").

> The content script must be in place before the page opens its socket, so
> **reload the page once** after installing/reloading the extension. Frames have
> their own ring-buffer budget (`maxFrames`, default **100k**) so a chatty
> socket can't evict HTTP history (`maxRequests`, default 50k).
>
> Heartbeat frames (JSON `{"type":"ping"}` / `"pong"`) are **dropped at capture
> by default** to cut noise and save the frame budget. The WebSockets toolbar
> has a **Record pings** checkbox to keep them.
>
> Binary frames are **size-only by default**. The **Log binary** checkbox
> captures their payloads — decoded as text when the bytes are valid UTF-8 (so
> text-as-Blob stays searchable), else base64 — capped at `maxBodyBytes`. When
> off, the bytes aren't even read/encoded.

Matching is case-insensitive. Use `"quotes"` for values with spaces.

<img width="2880" height="1534" alt="Screenshot_2026-07-20_1525_blurred" src="https://github.com/user-attachments/assets/705d09f6-97af-4a86-b497-97b31aec654e" />
<img width="1440" height="767" alt="Screenshot 2026-07-20 at 15 21 39" src="https://github.com/user-attachments/assets/05702f6c-85a7-4270-9e71-ae9f7d6240b0" />


## Architecture

```
service worker ── webRequest / debugger ──▶ IndexedDB (source of truth, ring buffer)
      │                                            ▲
      └── live feed (port) ──▶ Finder Page ── load ┘
                                   │
                                   ├─ Web Worker: in-memory index (URL + headers
                                   │   + structured fields), evaluates queries.
                                   │   Body-dependent matches come back as
                                   │   "needs body scan" …
                                   └─ … which the page resolves (Pass 2) by
                                       reading full records from IndexedDB.
```

The query evaluator is one three-valued (Kleene) function run twice: in the
Worker against in-RAM data (body text → `MAYBE`), then against full records from
IndexedDB to resolve the `MAYBE`s.

### Layout

```
src/
  background/   service worker: webRequest + debugger capture, batched IDB writes
  shared/       types, IndexedDB layer, query engine (tokenizer→parser→evaluate)
  worker/       in-memory RequestIndex + Worker entry
  finder/       React full-tab UI (search, virtualized list, detail panel)
docs/adr/       architecture decision records
CONTEXT.md      glossary (ubiquitous language)
```

## Claude Code integration (bridge daemon)

A small always-on local daemon (`bridge/`) lets Claude Code **watch traffic
live, query history, and act** (toggle Deep Capture, pause, purge) over MCP.
The extension streams captures to it over a localhost WebSocket; the daemon
keeps its own durable SQLite history and exposes an MCP server. See ADR 0004.

```
extension SW ═══WS═══▶ bridge daemon ◀═══MCP/HTTP═══ Claude Code
             ◀══WS═══  (actions: deep-capture, pause, purge)
```

### Setup

```bash
cd bridge && npm install            # native better-sqlite3 build
cd .. && npm run bridge             # starts the daemon on 127.0.0.1:8787
```

The daemon prints a **token** on first run (and on every start; it's stable,
stored at `~/.request-finder/token`). Then:

1. In the Finder Page → **⚙ Bridge** → paste the token, set the URL
   (`ws://127.0.0.1:8787/ws`), and check **Enable bridge**.
2. Register with Claude Code (the daemon prints this exact line):
   ```bash
   claude mcp add --transport http request-finder http://127.0.0.1:8787/mcp \
     --header "Authorization: Bearer <token>"
   ```
   Or use the committed `.mcp.json` with `export RF_BRIDGE_TOKEN=<token>`.

### MCP tools

| Tool | What it does |
|------|--------------|
| `requests_search` | search history with the query language (newest-first) |
| `request_get` | full record by Request Ref `rid` (the `rf_…` the UI's "Copy for Claude" yields) or `seq` |
| `requests_tail` | cursor long-poll for live watching (call in a loop) |
| `tabs_list` | tabs the extension sees + Deep Capture state |
| `stats` | counts, time range, connection + Deep Capture status |
| `deep_capture_set` | turn Deep Capture on/off for a tab (to see bodies) |
| `capture_set_paused` | pause/resume Metadata Capture |
| `history_purge` | wipe daemon + extension history (`confirm: true`) |

**Security (ADR 0004):** loopback bind only, a shared token on both the
WebSocket and every MCP call, and an Origin check that rejects web-page
WebSocket connections. The daemon serves captured secrets — keep the token
private; `history_purge` clears everything.

### Always-on (macOS LaunchAgent)

Instead of `npm run bridge` each time, run the daemon at login and keep it
alive (`deploy/com.requestfinder.bridge.plist`, launched via
`bridge/run-daemon.sh` which resolves your nvm Node):

```bash
cp deploy/com.requestfinder.bridge.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/com.requestfinder.bridge.plist   # starts now + every login
# stop/uninstall:
launchctl unload ~/Library/LaunchAgents/com.requestfinder.bridge.plist
```

Logs: `~/.request-finder/daemon.log` (and `daemon.err.log`). On another machine,
edit the two absolute paths in the plist.

### The `watch-requests` skill

`.claude/skills/watch-requests/SKILL.md` teaches Claude Code the common
workflows over these MCP tools — "tail any 5xx on the gmail tab", "find the
auth-bearing calls to api.slack.com and show me one", "deep-capture tab 42 and
inspect the failing response". It activates automatically when you ask for that
kind of thing (the `request-finder` MCP server must be registered).

## Running it day-to-day

| Part | When | How |
|------|------|-----|
| **Extension** | always (whenever Chrome runs) | Load unpacked from `dist/` **once**; Chrome remembers it across reboots. After `make`, click **↻** on the extension card. The Finder Page (toolbar icon) works on its own. |
| **Bridge daemon** | only for Claude | LaunchAgent (above) runs it at login; or `npm run bridge` on demand. |
| **Claude registration** | once | `claude mcp add …` + paste the token into the extension's ⚙ Bridge panel. Persists. |

## Uninstall / cleanup

Two separate stores:

- **Extension data** — cleared **automatically** by Chrome when you remove the
  extension (its IndexedDB + settings). Nothing to do.
- **Bridge daemon** — a separate local process + SQLite at `~/.request-finder`,
  independent of the extension; uninstalling the extension does **not** touch
  it. The extension can't purge it on uninstall (no code runs then). Clean it
  one of two ways:
  - **Before uninstalling:** Purge all → tick **Claude/MCP (daemon) history**.
  - **Any time:** `npm run bridge:uninstall` — removes **only** Netscribe's
    own artifacts: its LaunchAgent, its running daemon (only if the process on
    :8787 is Netscribe's), and `~/.request-finder` (history.db, token,
    logs). It touches nothing else.