Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 16 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,28 @@ Guidance for Claude working in this repo.

Browser-relay bridge that lets a Node-side MCP make authenticated
HTTP fetches through the user's signed-in browser tab, plus read
declared cookie / localStorage / sessionStorage / IndexedDB scopes and
capture per-request headers. Concentrator architecture: the first MCP
to boot binds `127.0.0.1:37149`, subsequent MCPs dial in as peers, the
host multiplexes all of them through one WebSocket to one browser
declared cookie / localStorage / sessionStorage / IndexedDB scopes,
capture per-request headers, and invoke a declared, page-owned
GraphQL operation through the tab's own Apollo client (`graphql`
capability). Concentrator architecture: the first MCP to boot binds
`127.0.0.1:37149`, subsequent MCPs dial in as peers, the host
multiplexes all of them through one WebSocket to one browser
extension. Each MCP ↔ extension session has its own AES-256-GCM key
derived via X25519 ECDH at handshake. Trust is identity-keyed
(Ed25519) with a 6-digit pair code the user confirms on first contact.

Current line: **1.x** (mutual auth + JSON-pointer storage extraction
+ MV3 SW keepalive + storageDomain selector + host-or-subdomain tab
matching). All packages stay in lockstep on one version (see root
matching + `graphql` capability for MAIN-world Apollo-client
invocation). All packages stay in lockstep on one version (see root
`package.json` → `version`).

## Workspaces

| Package | What it does |
|---|---|
| `@fetchproxy/protocol` | Wire format: frame validators, crypto wrappers (X25519, Ed25519, HKDF, AES-GCM, SHA-256), mcp-id parsing, pair-code derivation, JSON-pointer evaluator. Pure functions, no I/O. Smallest dep surface — every other workspace depends on it. |
| `@fetchproxy/server` | MCP-side WebSocket bridge. `FetchproxyServer` class with `listen()`, `request()`, `fetch()`, `readCookies()`, `readLocalStorage()`, `readSessionStorage()`, `captureRequestHeader()`, `readIndexedDb()`. Handles concentrator role-election (host vs peer), identity loading, session-key derivation. Persists per-MCP identity to `~/.fetchproxy/identity/<server-name>.json`. |
| `@fetchproxy/server` | MCP-side WebSocket bridge. `FetchproxyServer` class with `listen()`, `request()`, `fetch()`, `readCookies()`, `readLocalStorage()`, `readSessionStorage()`, `captureRequestHeader()`, `readIndexedDb()`, `graphqlQuery()`. Handles concentrator role-election (host vs peer), identity loading, session-key derivation. Persists per-MCP identity to `~/.fetchproxy/identity/<server-name>.json`. |
| `@fetchproxy/bootstrap` | One-shot helper: declare scope → spin up `FetchproxyServer` → read everything in one call → close. Used by Pattern A MCPs (HoneyBook, OFW, Resy auth-refresh path) that just need a session blob then operate from Node. `storageDomain` selector for multi-domain MCPs. |
| `@fetchproxy/extension-core` | Pure-ish business logic of the browser extension: `handleServerHello` (security-critical pair/auto-trust decision), trust-store, session-keys, popup rendering, badge logic. Designed to be testable under vitest with mocked `chrome.*` globals. `private` (not published). |
| `@fetchproxy/extension-chrome` | Thin Chrome-MV3 wrapper around extension-core. Just bundling, manifest, icons. Produces `packages/extension-chrome/dist/` for unpacked sideload + GitHub-release `.zip`. `private` (not published). |
Expand Down Expand Up @@ -77,7 +80,13 @@ the bind fails with `EADDRINUSE`, the MCP dials the existing host as a
pose as the extension to a real MCP (or vice versa). 0.4.0+.
4. **Capabilities** declared in hello frame, approved at pair time,
stored in the trust record. Tightening (or widening) the
capability set forces a re-pair with diff UI.
capability set forces a re-pair with diff UI. `graphql` is one
such capability — it invokes a page-declared GraphQL operation
through the tab's own Apollo client (MAIN world), gated by an
`graphqlOps: [{ name, operationName }]` allowlist approved at pair
time. It does NOT add arbitrary page-JS execution — only
operations the page already exposes are reachable. See
`docs/SECURITY.md` §T-graphql-misuse.
5. **Domain allowlist** — per-MCP `domains: string[]`. Every fetch
URL, cookie origin, captureHeader URL, storage tab match has
to be on a declared domain (or subdomain of one).
Expand Down
64 changes: 63 additions & 1 deletion docs/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,25 @@ Each MCP sends one of these as the very first frame after connecting.

- `"fetch"` — issue HTTP requests against the user's signed-in tab. Default; if `capabilities` is omitted, the extension treats it as `["fetch"]`.
- `"read_cookies"` — read non-HttpOnly `document.cookie` from a matching tab. Strictly opt-in; the popup shows a visible warning so the user notices the elevated trust.
- `"graphql"` — invoke a declared GraphQL operation through the matched tab's OWN Apollo client (`window.__APOLLO_CLIENT__`) in the page MAIN world, reusing the live `DocumentNode` the page already observed for the declared `operationName`. This runs the exact code path the page itself uses, so it carries whatever per-request bot-telemetry the page's Apollo link injects — clearing edge bot-protection (e.g. Akamai) that the isolated-world `fetch` path cannot. The MCP declares an allowlist of operations in `graphqlOps` (see below); a per-call request references one by `name` and supplies its own `variables`. Strictly opt-in; elevated; the popup shows the declared operations verbatim. It does NOT add arbitrary page-JS execution — only the declared operations, through the page's own client, are reachable.

Unknown values are rejected at validation time. The trust record stores the approved capability set; if the same MCP later declares a different set (upgrade or downgrade), the extension treats it as a re-pair and prompts the user again. The check is order-insensitive — `["fetch", "read_cookies"]` and `["read_cookies", "fetch"]` are equivalent.

`graphqlOps` is an optional array declared alongside `capabilities` — required (non-empty) for the `'graphql'` capability to do anything; empty/absent means no GraphQL operations are permitted even when `'graphql'` is declared:

```jsonc
"graphqlOps": [
{ "name": "restaurantsAvailability", "operationName": "RestaurantsAvailability" }
]
```

Each entry is `{ name, operationName }`:

- `name` — the logical handle the MCP references per-call (`GraphqlQueryInit.name`). `[A-Za-z0-9_.\-]`, 1-256 chars, unique within `graphqlOps`.
- `operationName` — the GraphQL operation name whose live `DocumentNode` the page's Apollo client already owns (standard GraphQL `Name` grammar, `[_A-Za-z][_0-9A-Za-z]*`, ≤128 chars). The extension carries no query text or hash of its own — it resolves `name` → `operationName` → the DocumentNode captured off the page's own `client.link.request`, so it auto-adapts when the site revises the query.

`graphqlOps` is approved at pair time (the popup lists every declared `operationName` verbatim) and diffed on change like every other declared scope — widening or altering the set forces a re-pair.

The signature lets the extension prove the connecting process holds the Ed25519 private key. Re-pair only happens on first sight of a new identity key; subsequent sessions just verify the signature against the stored `identityEd25519Pub`. The trust record also stores the approved `domains` set — a server that later widens the set (or changes `serverName`) is refused auto-trust and falls back to a re-pair prompt.

#### `hello` (extension → host)
Expand Down Expand Up @@ -236,6 +252,52 @@ Semantics:
- `tabUrl`: required. Same matching rules as `fetch`; must also map to one of the MCP's declared `domains` (or a subdomain of one). No other `init` fields are permitted.
- The MCP must have declared `"read_cookies"` in its hello `capabilities` AND the user must have approved that set at pair time. Otherwise the response is `{ok: false, op: "read_cookies", error: "capability ... not granted ..."}`.

##### `op: "graphql_query"`

The extension invokes a declared GraphQL operation through the matched tab's OWN `window.__APOLLO_CLIENT__`, in the page MAIN world, using the live `DocumentNode` the page's client already captured for that operation.

```jsonc
{
"type": "request",
"id": 3,
"op": "graphql_query",
"init": {
"name": "restaurantsAvailability", // must match a declared graphqlOps[].name
"variables": { // the MCP's full GraphQL variables object
"restaurantIds": ["1175428"],
"date": "2026-07-31",
"time": "17:00",
"partySize": 2,
"databaseRegion": "NA"
},
"tabUrl": "https://www.opentable.com/" // optional; same matching as fetch/read_cookies
}
}
```

Semantics:

- `name`: required, non-empty string. Must match a `name` in the MCP's declared `graphqlOps`. The extension resolves `name` → `operationName` → the cached `DocumentNode`, then calls `client.query({ query, variables, fetchPolicy: 'no-cache' })`.
- `variables`: required. A plain (non-array, non-null) object passed straight through to `client.query`; may be empty. The extension does not inspect or transform it.
- `tabUrl`: optional. Same host-or-subdomain matching as other verbs; must map to one of the MCP's declared `domains`. Omitted ⇒ the extension picks a tab on the MCP's declared domain.
- The MCP must have declared `"graphql"` in its hello `capabilities` AND the specific `name` must be one of the declared `graphqlOps` — both gates are checked on every call, not just at pair time.
- If the page's Apollo client has not yet observed the declared `operationName` (its `DocumentNode` isn't cached — e.g. the user hasn't loaded the relevant page in this tab), the response is a typed failure: `{ok: false, op: "graphql_query", error: "operation not yet observed on this tab — open <hint> and retry"}` (exact wording may vary).

Response:

```jsonc
// Success
{
"type": "response",
"id": 3,
"ok": true,
"op": "graphql_query",
"data": { "availability": [ /* ... */ ] } // the GraphQL response's `data` object, verbatim
}
```

`data` is exactly the `data` field of the GraphQL response the page's own Apollo client received — no envelope, no `errors` passthrough (a GraphQL-level error surfaces as an `ok: false` protocol failure instead). The MCP reads whatever fields its declared operation returns.

#### `response` (extension → server)

Successful responses carry an `op` discriminator that matches the request. Existing 0.1.x senders that omit `op` are still accepted by the validator for the fetch shape (back-compat) — but new senders always set it.
Expand Down Expand Up @@ -372,7 +434,7 @@ Host shutdown: peers see WS close and re-race the port. Whoever wins becomes the

## What's not in the protocol (closed by design)

- `eval_js`, `inject_script` — no arbitrary JS execution in tabs.
- `eval_js`, `inject_script` — no arbitrary JS execution in tabs. `graphql` does not add this: it can only invoke an operation the MCP declared in `graphqlOps`, through the page's own Apollo client, and only once the page's client has organically observed that operation.
- `read_storage` (localStorage, IndexedDB) — no general exfiltration primitives. `read_cookies` is a deliberate, narrow exception: the user explicitly opts in at pair time, and only non-HttpOnly cookies are visible to page JS.
- `click`, `navigate` — no UI automation. Use claude-in-chrome for that.
- Wildcard MCPs — the declared `domains` set must be enumerated explicitly. No `*.com` or "any domain" wildcards.
Expand Down
28 changes: 27 additions & 1 deletion docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ This document tracks 0.2.0. Two structural changes vs. 0.0.x / 0.1.x bear on the
| Host MCP reading peer MCP traffic on the shared bridge | End-to-end AES-256-GCM between each MCP and the extension. Host routes by `mcpId` but never holds the session key. |
| A webpage you visit connecting to the WS | WS binds `127.0.0.1`; the upgrade handler rejects non-extension origins; Chrome Private Network Access blocks public-origin preflights. |
| MCP silently expanding its powers post-pair | The trust record stores the approved `domains` AND `capabilities` set. Any change → re-pair prompt. |
| Arbitrary JS execution in your tabs | The protocol has no `eval_js`, `inject_script`, or equivalent. Closed by design. |
| Arbitrary JS execution in your tabs | The protocol has no `eval_js`, `inject_script`, or equivalent. `graphql` is NOT this — it can only invoke a page-declared GraphQL operation through the page's own Apollo client, never arbitrary page code. See [§T-graphql-misuse](#t-graphql-misuse--graphql-capability-misuse). |
| Storage exfiltration | `read_storage`, `read_indexeddb`, etc. don't exist. `read_cookies` is a deliberate, narrow opt-in. |
| Multi-user machine sniffing | Out of scope. Localhost binding only. |

Expand Down Expand Up @@ -105,6 +105,25 @@ If an MCP legitimately needs more than one domain (rare), it enumerates them: `d

**Residual risk:** A user who approves a pair with `read_cookies` is giving the MCP a powerful read primitive for the declared domains. The popup tries to make that visible; the trust record forces re-approval on change. There is no further defense — if you don't trust the MCP, don't approve the pair.

### T-graphql-misuse — `graphql` capability misuse

`graphql` invokes a **page-declared GraphQL operation through the page's OWN Apollo client** (`window.__APOLLO_CLIENT__`), in the page's MAIN world. This exists because some endpoints (OpenTable's `RestaurantsAvailability`) reject the isolated-world `fetch` path at the edge — the bot-detection sensor telemetry lives inside the page's own Apollo link chain, not on `window.fetch` — so the only way to clear it is to run the request through the exact code path the page itself uses.

**What it is NOT:** general MAIN-world JS execution. There is no `page_eval`, no arbitrary function call, no way to reach any object other than the page's Apollo client, and no way to run any operation the page hasn't already defined.

**Defenses:**

1. **It can only run operations the page already exposes.** The extension carries NO hardcoded query text and NO persisted-query hash. It hooks `client.link.request` to capture the live `DocumentNode` the page's own Apollo client observed for a given `operationName`, then reuses that exact object via `client.query(...)`. If the page's client has never observed the operation (e.g. the user hasn't loaded the relevant page yet), the bridge returns a typed "operation not yet observed on this tab" error rather than inventing a query.
2. **Declared-operation allowlist, approved at pair time.** The MCP declares a `graphqlOps: [{ name, operationName }]` list in its hello. Only operations in this list can ever be invoked; the popup surfaces the exact `operationName` values verbatim so the user sees precisely what will run. Widening or changing the declared set forces a re-pair with a diff, same as every other capability.
3. **Capability-gated.** `'graphql'` must be declared in `capabilities` and approved at pair time — same opt-in mechanics as `read_cookies` / `read_dom`. The popup labels it with a warning marker.
4. **Domain allowlist + host-or-subdomain tab match.** Same as every other verb: the tab the query runs against must be on one of the MCP's declared `domains` (or a subdomain of one).
5. **Returns only the GraphQL response `data`.** The response is `{ ok: true, data }` where `data` is the parsed GraphQL response body — no page state, no DOM, no other globals, no ability to read anything the operation itself didn't return.
6. **Per-call `variables` are supplied by the MCP, not the page.** The extension never inspects or mutates them — it passes the MCP's object straight to `client.query({ query, variables })`.

**Residual risk:** If the operation the MCP declared genuinely returns sensitive data (e.g. a booking-availability query that also echoes account details), that's the same tradeoff as any declared `fetch` endpoint — the user is trusting the MCP with what it's declared, not with arbitrary access. The mechanism cannot be used to invoke an operation the user hasn't implicitly exposed by using the page normally.

**Known limitation (tracked, not yet fixed):** the MAIN-world bridge script (`capture-logger.ts`) wraps `client.link.request` on **every** page that exposes an Apollo client, regardless of whether any MCP has declared the `graphql` capability — the captured `DocumentNode`s stay in an in-process `Map` and are never exfiltrated, so this is a wider MAIN-world footprint (not a data leak) than the read-only CSRF sync this file previously did. It also polls for `window.__APOLLO_CLIENT__` every 500ms for the lifetime of any tab that never gets one, with no give-up cap. Neither is a security hole, but both are worth tightening — see the PR #178 auto-review follow-up issue.

### T-host-MITM — Host MCP reading peer traffic

In the 0.2.0 concentrator model, one MCP wins the WS port and acts as the host. Other MCPs on the same machine dial it as peers and tunnel their traffic through. A backdoored host MCP could read or tamper with peer traffic, exfiltrating their fetches or rewriting responses.
Expand All @@ -128,6 +147,13 @@ Replay protection: receivers track the highest seen `seq` per direction per sess

**Residual risk:** The host can drop or delay peer traffic (denial of service against peers). It cannot read or modify it. If the host crashes, peers race the port and one wins; the takeover is invisible to peers because trust + session derivation are stateless given the identity keys.

**A malformed-but-legitimate response degrades gracefully, not fatally.** A peer's incoming `frame` can fail to open in two very different ways, and the code distinguishes them (`openEncryptedFrameDetailed` in `packages/protocol/src/seal.ts`):

- **Decrypt failure** (AES-GCM authentication fails) — the wrong session key or genuinely tampered ciphertext. Nothing about the plaintext can be trusted; `peer.ts` drops it silently, same as before (typically a straggler frame from a session that already rotated).
- **Validation failure** *after a successful decrypt* — the ciphertext authenticated fine under the *current* session key (so this really is the live host forwarding the live extension's bytes), but the plaintext is malformed JSON or fails the wire schema. This is a genuine protocol bug, not a stale-key symptom, so `peer.ts` logs it loudly (`console.error`) and, when the malformed response's `id` is recoverable, routes a synthetic `ok:false` through the normal id-keyed dispatch — failing just that one pending call immediately instead of leaving it to hang until its own timeout with zero diagnostic signal, and without tearing down the connection over one bad response.

`host.ts` — the concentrator's single physical connection to the extension, multiplexing every MCP's traffic — still closes the whole WS on ANY validation failure (its message handler wraps everything in one try/catch; see `host.ts:344-351`). That remains a broader-blast-radius reaction than the peer path now has, but the concrete triggers found for it in this PR (the graphql errorPolicy bug, the download `bytes:-1` sentinel, the graphql_query op-echo gap) were each fixed at the SOURCE — the extension no longer produces a response that fails validation for those cases — rather than by changing what `host.ts` does when one does. A future op-specific bug could still trip the same host.ts-side "close everything" behavior; this is a known, accepted broader risk, not one this PR closes generically.

### T4 — User installs unknown MCP via Claude Code or similar

A user runs `npx some-mcp-server` from a random GitHub. It registers with fetchproxy declaring `domains: ["yourbank.com"]` and possibly `capabilities: ["fetch", "read_cookies"]`.
Expand Down
Loading