import type { Capability, CaptureHeaderDecl, IndexedDbScopeDecl, DomSelectorDecl, GraphqlOpDeclaration, StoragePointerDecl, FetchInit, DownloadResult } from '@fetchproxy/protocol'; import { type FetchErrorKind } from './error-kind.js'; import { type BridgeError } from './classify-bridge-error.js'; export interface FetchproxyServerOpts { /** * Localhost TCP port the concentrator binds to. The first MCP to call * a verb races the bind; if it wins it becomes the `'host'` and the * extension dials this port. Subsequent MCPs that lose the race * become `'peer'` and tunnel through the existing host on the same * port. The browser extension's connect target is hard-coded to the * default in its manifest, so you should only override this for * local development or test isolation — production MCPs all need to * share one port for the concentrator to work. * * Omitted, the `FETCHPROXY_WS_PORT` environment variable is consulted * before the default. It exists for consumers that never see this option at * all — `@fetchproxy/bootstrap` constructs the server itself — and it moves * the port the same way this option does, with the same constraint: on one * machine every MCP shares the concentrator, so the value has to be the SAME * for all of them or the ones that differ sit unreachable on a port nothing * dials. * * The one place a port PER MCP is meaningful is a hosted deployment, where * the thing dialling a child is a relay agent on the same machine rather * than the browser (chrischall/mcp-host, docs/BROWSER-BRIDGE.md): each child * is its own concentrator on its own port, and the browser reaches them * through a configured remote target instead of loopback. That is a * different topology, not a way to run several concentrators on a laptop. * * An explicit value here is a decision made in code and beats the * environment. * * @default `FETCHPROXY_WS_PORT`, else 37149 */ port?: number; /** * Localhost interface to bind. Always keep this on a loopback address * — fetchproxy's threat model assumes single-user, single-host * trust; binding a public address would expose the bridge (and via * it, the user's signed-in cookies) to the network. Override only * for test setups that need a non-`127.0.0.1` loopback alias. * * @default '127.0.0.1' * @see https://github.com/chrischall/fetchproxy/blob/main/docs/SECURITY.md */ host?: string; /** * Stable identifier for this MCP — typically the npm package name * (`'opentable-mcp'`, `'honeybook-mcp'`). Surfaced in three places: * the extension UI's pair popup, the identity-key filename * (`~/.fetchproxy/identity/.json`), and the computed * `mcpId`. Changing it after first pair means the user re-pairs * (different identity file path) — pick a name and keep it. No * default; required. */ serverName: string; /** * Your MCP's package version (`'0.10.0'`). Surfaced in the pair popup * alongside `serverName` so the user can see what they're approving. * Pure label — does not gate any behavior in the bridge or the * extension. No default; required. */ version: string; /** * Trust boundary. Non-empty array of hostnames. The extension refuses * any fetch outside these domains (or any subdomain of them). * Pair-code trust is keyed off the MCP's cryptographic identity together * with this set. * * Single-domain ergonomics: with `domains: ['opentable.com']`, all * convenience-method calls default to `https://opentable.com` and * `{ subdomain: 'www' }` targets `https://www.opentable.com`. * * Multi-domain MCPs (e.g. `domains: ['honeybook.com', 'hbsplit.com']`) * must specify `{ domain: 'honeybook.com' }` on every per-call request * so the resolver knows which base to use. * * No default; required. Changing the set after first pair forces the * user to re-approve in the extension popup with a diff UI. */ domains: string[]; /** * Optional non-empty list of inner-verb capabilities this MCP wants * to use. Defaults to `['fetch']`. Including `'read_cookies'` unlocks * `FetchproxyServer.readCookies()` but surfaces a warning in the pair * popup — only declare it if the MCP genuinely needs a cookie snapshot. * Changing the set after a pair forces the user to re-approve. */ capabilities?: Capability[]; /** * 0.3.0+: declared cookie names the MCP is allowed to read via * `readCookies({ keys })`. Each call's `keys` is checked subset-of this * list at the call site (gate #1); the extension re-checks against the * pair-approved trust record (gate #2). Empty/absent means cookie * reads with explicit keys are not permitted — back-compat * `readCookies()` without `keys` arg still works. */ cookieKeys?: string[]; /** 0.3.0+: declared localStorage keys for `readLocalStorage`. */ localStorageKeys?: string[]; /** 0.3.0+: declared sessionStorage keys for `readSessionStorage`. */ sessionStorageKeys?: string[]; /** 0.3.0+: declared (host, path?, headerName) tuples for `captureRequestHeader`. */ captureHeaders?: CaptureHeaderDecl[]; /** * 0.4.0+: declared IndexedDB scopes for `readIndexedDb()`. Each * entry is `{ origin, database, store, keys }`. Per-call requests * must subset-match a declared scope. */ indexedDbScopes?: IndexedDbScopeDecl[]; /** * 0.4.0+: declared JSON-pointer extractions over localStorage * values. Each entry `{ key, jsonPointer }` binds an existing * `localStorageKeys` entry to a pointer. Per-call `readLocalStorage` * requests must use a declared pair. */ localStoragePointers?: StoragePointerDecl[]; /** 0.4.0+: same shape against sessionStorage. */ sessionStoragePointers?: StoragePointerDecl[]; /** * 1.4.0+: declared DOM selectors for `readDom()`. Each entry is * `{ name, selector, attribute? }`. Per-call requests reference these * by `name` (subset-match). Reads a value from the matched tab's DOM * (isolated-world `querySelector`), e.g. a Cloudflare Turnstile token * a page writes into a hidden input that a POST body must carry. */ domSelectors?: DomSelectorDecl[]; /** * 1.x+: declared GraphQL operations for `graphqlQuery()`. Each entry * is `{ name, operationName }`. Requires `'graphql'` in `capabilities`. * Per-call `graphqlQuery` requests reference these by `name` * (subset-match). The extension resolves `name` → `operationName` → * the live DocumentNode the page's Apollo client already observed, then * invokes `client.query(...)` in the page's MAIN world — the same path * the site itself uses, so per-request bot telemetry (Akamai etc.) runs * automatically. Empty/absent ⇒ no GraphQL operations permitted even if * `'graphql'` is declared. */ graphqlOps?: GraphqlOpDeclaration[]; /** * Override the on-disk directory that holds this MCP's long-term * identity keypair (`/.json`, mode 0600). * The identity is what makes pair trust survive restarts; rotating * it forces the user to re-pair. Override for tests (point at a * tmpdir) or for sandboxed deployments where `$HOME` isn't writable. * Production MCPs running as the user should leave this alone. * * @default '~/.fetchproxy/identity/' */ identityDir?: string; /** * 1.12.0+ (#208): accept an extension identity that is NOT the one this MCP * pinned, replacing the pin with it. * * The MCP commits to the first extension that completes a handshake and * refuses a different one afterwards — the mirror of the extension's own * `trustedMcps`. A legitimate re-install mints a new identity, so there has * to be a way to say "yes, that new browser is mine": this is it. * * Leave it undefined and the `FETCHPROXY_TRUST_NEW_EXTENSION=1` environment * variable answers instead, which is the only lever that reaches an MCP * whose source you are not editing. Setting it explicitly wins over the * environment in both directions. * * @default false (or the environment variable, when unset) */ allowNewExtensionIdentity?: boolean; /** * 1.12.0+ (#208): when this MCP is a PEER rather than the concentrator, * refuse to open a session unless the concentrator forwards the extension's * identity so it can be verified and pinned. * * A peer only sees what the concentrator relays, and concentrators before * 1.12.0 relay no identity at all. Because the port election picks the * concentrator arbitrarily, defaulting this to `true` would break a * mixed-version fleet at random, so the default warns loudly instead. Set it * where the concentrator is not simply another MCP on the same laptop. * * @default false */ requireExtensionIdentity?: boolean; /** * 0.4.0+: invoked once on receipt of the extension hello, with the * joint pair code derived from `SHA256(mcpPub || extPub)`. Used by * MCPs that need to surface the code on stderr or similar for the * user to verify against the browser popup. Optional — fetch-only * MCPs that don't need to print can omit it. (Off by default.) * * Override when the MCP host runs in an environment where the user * can't see stdout/stderr by default (e.g. Claude Desktop), so the * code can be surfaced via an MCP logging notification or similar * out-of-band channel. */ onPairCode?: (code: string) => void; /** * 0.8.0+: per-request timeout (ms) for `fetch()`. The bridge has * no native timeout, so without this a frozen tab / dropped * extension would hang the call indefinitely. When the timer fires, * `fetch()` returns `{ ok: false, kind: 'timeout', error: '…' }` * (back-compat result shape); convenience methods (`get`/`post`/ * `request` etc.) throw `FetchproxyTimeoutError`. * * Override to tighten for latency-sensitive call sites (e.g. interactive * tool calls where the user is waiting), or loosen for endpoints * known to be slow (large file downloads, long-running search). Pass * `0` to opt back into the legacy hang-forever behavior. * * @default 30_000 * @see https://github.com/chrischall/fetchproxy/issues/58 */ fetchTimeoutMs?: number; /** * 0.8.0+: delay (ms) before the one-shot retry on the SW-eviction * cold-start symptom. `captureRequestHeader()` revives on * `content_script_unreachable`; `fetch()` revives on that AND on a * server-side `timeout` (#90 — a fully-cold worker often surfaces the * first post-idle `fetch()` as a `timeout` while Chrome is still * spinning the SW up). Chrome MV3 evicts extension service workers * after ~30s idle; this gives Chrome a moment to wake the SW on the * next inbound frame. The default `2_000` is the same value the * realty/dining cohort had been hand-rolling in their transport * adapters. * * Override to lengthen for slow machines where 2s isn't enough for * the SW to wake, or shorten if the caller is willing to surface * the bridge-down error sooner. On retry-exhaustion, convenience * methods + capture throw `FetchproxyBridgeDownError` (or * `FetchproxyTimeoutError` when the cold-start was a timeout) with * `retryAttempted: true`. Pass `0` to disable the retry entirely * (errors surface on the first attempt with `retryAttempted: false`). * * @default 2_000 * @see https://github.com/chrischall/fetchproxy/issues/58 * @see https://github.com/chrischall/fetchproxy/issues/90 */ bridgeReviveDelayMs?: number; /** * Server-initiated keep-alive ping interval (ms). The bridge fires * a no-op `{ type: 'ping' }` inner frame to the extension every * `keepAliveIntervalMs` while the MCP has been active (fetch / * capture success or failure, or `markActive()`) within * `keepAliveMaxIdleMs`. The extension responds with `pong`, which * resets Chrome's MV3 service-worker idle timer (any frame in/out * resets it). * * Comfortably under Chrome's ~30s eviction threshold. Override only * if you've measured a specific eviction pattern that needs a * different cadence. Set to `0` to disable (no pings). * * The extension-side `chrome.alarms` keepalive still runs * independently; this is the belt-and-braces server-initiated arm * that addresses the round-3 #23 feedback (alarm-only was * insufficient because the alarm doesn't re-arm while the SW is * evicted). * * @default 20_000 (since #90 — tightened from the 0.10.0 default of * 25_000, which left too little margin under Chrome's ~30s eviction * window and still lost the cold-start race; 25_000 itself was the * round-3 #71 cohort value promoted from off-by-default) * @see https://github.com/chrischall/fetchproxy/issues/67 * @see https://github.com/chrischall/fetchproxy/issues/71 * @see https://github.com/chrischall/fetchproxy/issues/90 */ keepAliveIntervalMs?: number; /** * 0.8.1+: how long after the most-recent user-visible activity * (fetch / capture success or failure, or `markActive()`) to keep * firing keep-alive pings. Paired with `keepAliveIntervalMs` — * without an idle gate, a long-running but dormant MCP would ping * forever and waste the SW's wake-budget on nothing. * * Override to lengthen for MCPs whose user-visible activity is * spread across long gaps (e.g. multi-step workflows where the user * thinks for a while between tool calls). Has no effect when * `keepAliveIntervalMs` is `0`. * * @default 300_000 (5 minutes) * @see https://github.com/chrischall/fetchproxy/issues/67 */ keepAliveMaxIdleMs?: number; } /** * The successful arm of the `fetch()` discriminated union. Any * upstream HTTP status (2xx, 3xx, 4xx, 5xx) returns this shape — * `ok: true` means "the bridge delivered a response", not "the * upstream returned 2xx". Inspect `status` for HTTP-level success. */ export interface FetchResult { /** Discriminator for the union with `FetchResultError`. */ ok: true; /** Upstream HTTP status code (any 1xx-5xx). */ status: number; /** Final URL after redirects, as observed by the browser. */ url: string; /** Raw response body as a UTF-8 string. */ body: string; /** * 0.8.0+: true when the server's lazy-revive retry path actually * fired for this call (a `content_script_unreachable` first attempt — * or, since 0.11.0+/#90, a cold-start `timeout` first attempt — * followed by a successful retry). False on the no-retry path. * Always populated by the server in 0.8.0+; declared optional in the * type so downstream test code that constructs envelope literals * directly stays back-compat without code changes. */ retryAttempted?: boolean; } /** * The failure arm of the `fetch()` discriminated union. Only returned * when the bridge itself failed to deliver the request (no signed-in * tab, extension offline, transport timeout, etc.) — upstream HTTP * errors come back as `FetchResult` with a non-2xx `status`. The * convenience methods (`request`/`get`/`post`/…) translate this into * a typed throwable via `_typedErrorFor`. */ export interface FetchResultError { /** Discriminator for the union with `FetchResult`. */ ok: false; /** Human-readable error string from the bridge or extension. */ error: string; /** * Derived categorization of `error` so downstream MCPs can branch * on a small discriminated set rather than grep'ing strings. Always * populated by the server in 0.4.3+. The raw `error` string remains * the source of truth — `kind` is additive guidance. */ kind: FetchErrorKind; /** * 0.8.0+: true when the server's lazy-revive retry path actually * fired AND the retry also failed. False otherwise (retry was * disabled, or this isn't a cold-start symptom so retry didn't * apply). The retry arms on a `content_script_unreachable` failure * or — since 0.11.0+/#90 — a cold-start `timeout`. Always populated * by the server in 0.8.0+; declared optional in the type so * downstream test code that constructs envelope literals directly * stays back-compat. */ retryAttempted?: boolean; /** * 0.8.0+: actual elapsed milliseconds when `kind === 'timeout'` * (the timer firing wins the race). Populated only for the timeout * arm; undefined for other failure kinds. */ elapsedMs?: number; } /** * Public response shape returned by the convenience helpers * (`get`/`post`/`request`/…). Strictly the success path — these * methods throw `FetchproxyHttpError` / `FetchproxyProtocolError` * on failure rather than returning a discriminated union. */ export interface HttpResponse { /** Upstream HTTP status code (any 1xx-5xx). */ status: number; /** Raw response body as a UTF-8 string. */ body: string; /** Final URL after redirects, as observed by the browser. */ url: string; } /** * Options accepted by `request()` and the verb helpers. `subdomain` * controls per-call which host within the declared domain to target; * `domain` picks which base domain (only meaningful when the MCP * declared more than one): * * fp.get('/path') → https://${domains[0]}/path * fp.get('/path', { subdomain: 'www' }) → https://www.${domains[0]}/path * fp.get('/path', { domain: 'b.com', subdomain: 'api' }) → https://api.b.com/path * * `subdomain` must be a single DNS label (or dot-separated labels) * without any URL scheme, path, or slashes. * `domain` must exactly equal one of the entries in * `FetchproxyServerOpts.domains`. */ export interface RequestOpts { /** * Per-call HTTP request headers. Merged into the outbound request * verbatim. JSON shortcuts add `Content-Type: application/json` * unless the caller already provided one. Off by default. */ headers?: Record; /** * Raw request body as a string. The bridge does not transform it — * `JSON.stringify` your own object if you're not using the JSON * shortcuts. Off by default (no body sent). */ body?: string; /** * If provided, throws `FetchproxyHttpError` when the response status * does not match. A number is matched exactly; an array means "must be in this set". * Off by default — the caller inspects `response.status` themselves. */ expectStatus?: number | number[]; /** * Optional subdomain label(s) to prepend to the chosen base domain. * E.g. with base `'opentable.com'`, `subdomain: 'www'` builds the * URL against `https://www.opentable.com`. May be a single label * (`'www'`) or dot-separated labels (`'auth.api'`). Off by default * (uses the apex domain). */ subdomain?: string; /** * Optional base domain selector for multi-domain MCPs. Must match one * of the entries in `FetchproxyServerOpts.domains` exactly. Required * on every per-call request when the MCP declared multiple domains; * may be omitted when only one domain is declared (the lone entry * becomes the implicit default). */ domain?: string; /** * 1.12.0+: the tab to relay this request through, when it isn't the one the * request's own host would imply. * * By default the relay tab is `https://{host-of-the-request}/`, which is * right for app hosts — routing `photos.x.com` through a `www.x.com` tab * would be wrong. But it assumes every host CAN have a tab, and API hosts * cannot. * * The mechanism is worth knowing, because the extension's own advice for * this failure ("refresh the page to inject the content script") cannot * work. An API host typically 404s at `/`, so Chrome renders its OWN * document at `chrome-error://chromewebdata/` — and Chrome never injects * content scripts into `chrome-error://` pages, `` match or not. * `chrome.tabs.query` still reports the tab's URL as the requested https * one, which is why the failure reads "1 URL match, none responded": the URL * matches, the document behind it cannot host a relay, and no amount of * reloading changes that. * * Meanwhile the signed-in `www.example.com` tab can issue that cross-origin * fetch perfectly well — which is exactly what the site's own web app does. * * Naming the relay explicitly keeps the safe default intact while unblocking * that case. The value is matched against open tabs by prefix, so * `https://www.example.com/` accepts any page on the host and a deeper path * pins one specific page. * * Must be inside the declared `domains`: this widens which tab performs the * fetch, never which origins are reachable. The request URL is unaffected. */ viaTab?: string; } /** * Options accepted by JSON/HTML shortcuts (`getJson`/`postJson`/ * `getHtml`/`get`/`delete`/…) — body is provided positionally so it * isn't part of this options object. Otherwise identical to * `RequestOpts`. */ export interface BodylessRequestOpts { /** Same as `RequestOpts.headers`. */ headers?: Record; /** Same as `RequestOpts.expectStatus`. */ expectStatus?: number | number[]; /** Same as `RequestOpts.subdomain`. */ subdomain?: string; /** Same as `RequestOpts.domain`. */ domain?: string; /** Same as `RequestOpts.viaTab`. */ viaTab?: string; } /** * Thrown when the fetchproxy bridge itself failed to relay the request * (e.g. no signed-in tab, extension offline, transport error). */ export declare class FetchproxyProtocolError extends Error { constructor(message: string); } /** * Thrown when the upstream HTTP response did not match an explicit * `expectStatus`. Carries the full response so the caller can inspect * status / body / url. */ export declare class FetchproxyHttpError extends Error { readonly response: HttpResponse; constructor(response: HttpResponse, message?: string); } /** * 0.8.0+: thrown when the extension's MV3 service worker is * unreachable. Subclass of `FetchproxyProtocolError` so callers * already catching the parent still match. * * `retryAttempted: true` means the server's one-shot lazy-revive * retry (`bridgeReviveDelayMs`) already burned and the SW is still * down. `false` means the retry was disabled (`bridgeReviveDelayMs` * unset / 0), so the user could enable it for next time. */ export declare class FetchproxyBridgeDownError extends FetchproxyProtocolError { readonly originalError: string; readonly retryAttempted: boolean; readonly op: 'fetch' | 'capture_request_header' | 'capture_redirect' | 'download'; readonly url?: string; /** 0.8.0+: bridge role at throw time; `null` if listen() hadn't bound yet. */ readonly role: 'host' | 'peer' | null; /** 0.8.0+: bridge port at throw time (the same port `listen()` bound to). */ readonly port: number; readonly hint: string; constructor(args: { originalError: string; retryAttempted?: boolean; op?: 'fetch' | 'capture_request_header' | 'capture_redirect' | 'download'; url?: string; role?: 'host' | 'peer' | null; port?: number; }); } /** * 1.12.0+: a protocol error that knows its own remedy. * * Every consumer that re-wraps bridge errors — the CLI, `@fetchproxy/bootstrap` * callers, MCPs with their own auth copy — needs to answer "and what do I do * about it?". Answering per-consumer is how the guidance goes missing, and * answering per-subclass is how consumers end up branching on * `instanceof FetchproxyScopeError` and inheriting blanket advice for * everything else (the bug behind #204). * * So the shape lives here: catch `FetchproxyHintedError`, render * `originalError — hint`, and every present and future hinted error renders * correctly without a new branch. */ export declare class FetchproxyHintedError extends FetchproxyProtocolError { /** The extension's raw rejection, unmodified. */ readonly originalError: string; /** What the user should actually do, in prose. */ readonly hint: string; constructor(originalError: string, hint: string); } /** * 1.10.0+: the extension rejected a request because its declared scope no * longer covers what was asked for (gate #2). * * The extension gates on the scope approved at pair time, so any MCP that * widens its declarations — adding a cookie key, a DOM selector, a GraphQL op * — is refused until the user re-approves. That is a routine event, not a * fault, and it has exactly one remedy. * * Typed with a `.hint` because the remedy used to be unreachable in practice. * The CLI knew how to explain it, but MCPs consume the bridge through * `@fetchproxy/bootstrap`, catch failures, and re-wrap them in their own * message — so users saw a bare "cookie keys not in declared set: X" bolted * onto unrelated auth-config copy, with no mention of re-pairing. Putting the * guidance on the error itself means every consumer can surface it the way * they already surface {@link FetchproxyBridgeDownError.hint}. */ export declare class FetchproxyScopeError extends FetchproxyHintedError { constructor(originalError: string); } /** * 1.12.0+: no browser tab is open on the host the request needed. * * Typed for the same reason as {@link FetchproxyScopeError}: untyped, the * rejection is a plain `FetchproxyProtocolError`, `classifyBridgeError` * (which dispatches on type, not message) calls it `protocol`, and every * consumer's blanket protocol advice lands on it. In the CLI that advice was * "extension/server version mismatch — update both", so a user whose versions * were entirely current got sent to update them (#204). * * Deliberately NOT applied to the "matched a tab, but its content script never * answered" wording. That has a different remedy — refresh the page rather * than open one — and the extension's own message already spells it out, so * retyping it here would staple contradictory advice onto it. */ export declare class FetchproxyNoTabError extends FetchproxyHintedError { constructor(originalError: string); } /** * Build the right error for an extension rejection. * * Use this instead of `new FetchproxyProtocolError(err)` at every site that * turns an `ok:false` response into a throw, so rejections that know their own * remedy cannot silently lose it again at one forgotten call site. */ export declare function protocolErrorFrom(error: string): FetchproxyProtocolError; /** * 0.8.0+: thrown by convenience methods when `fetchTimeoutMs` fires. * The lower-level `fetch()` returns `{ ok: false, kind: 'timeout' }` * instead (back-compat with its result-envelope shape). Subclass of * `FetchproxyProtocolError` so existing callers still match. * * `retryAttempted: true` (0.11.0+, #90/#91) means the server's one-shot * lazy-revive retry (`bridgeReviveDelayMs`) treated this timeout as the * SW-eviction cold-start symptom, warmed the worker, and the retry also * timed out. `false` means the retry was disabled * (`bridgeReviveDelayMs` unset / 0), so the timeout surfaced on the * first attempt. Mirrors `FetchproxyBridgeDownError.retryAttempted` so * callers can branch identically across both throwable kinds. */ export declare class FetchproxyTimeoutError extends FetchproxyProtocolError { readonly url: string; readonly timeoutMs: number; /** 0.8.0+: bridge role at throw time; `null` if listen() hadn't bound yet. */ readonly role: 'host' | 'peer' | null; /** 0.8.0+: bridge port at throw time. */ readonly port: number; /** 0.8.0+: actual elapsed milliseconds when the timer won the race. */ readonly elapsedMs: number; /** * 0.11.0+ (#90/#91): true when the server's lazy-revive retry path * fired for this timeout (a cold-start `timeout` symptom followed by * a warm-and-retry that also timed out). False when the retry was * disabled (`bridgeReviveDelayMs` unset/0) so the timeout surfaced on * the first attempt. */ readonly retryAttempted: boolean; constructor(args: { url: string; timeoutMs: number; role?: 'host' | 'peer' | null; port?: number; elapsedMs?: number; retryAttempted?: boolean; }); } /** * 0.8.0+: snapshot of the bridge's process-wide freshness counters, * returned by `FetchproxyServer.bridgeHealth()`. Downstream MCPs use * this to power their `healthcheck` tools without re-tracking the * same counters in their transport adapters. */ export interface BridgeHealth { /** Bridge role at snapshot time; `null` if `listen()` never connected. */ role: 'host' | 'peer' | null; /** Localhost port the bridge is bound to (matches `FetchproxyServerOpts.port`). */ port: number; /** 0.8.0+: server version this bridge was constructed with. */ serverVersion: string; /** * 0.8.0+: resolved per-request timeout (ms). Reflects either the * constructor override or the 0.8.0+ default of 30_000. Surfaced * so cohort healthcheck tools don't need a local * `DEFAULT_FETCH_TIMEOUT_MS` constant that risks drifting from the * server when defaults move. `0` means the caller explicitly opted * back into the legacy hang-forever behavior. * * @default 30_000 * @see https://github.com/chrischall/fetchproxy/issues/58 * @see https://github.com/chrischall/fetchproxy/issues/82 */ fetchTimeoutMs: number; /** * 0.8.0+: resolved lazy-revive delay after `content_script_unreachable` * (ms). Reflects either the constructor override or the 0.8.0+ default * of 2_000. Surfaced for the same drift-avoidance reason as * `fetchTimeoutMs`. `0` means the caller explicitly opted out of the * one-shot retry. * * @default 2_000 * @see https://github.com/chrischall/fetchproxy/issues/58 * @see https://github.com/chrischall/fetchproxy/issues/82 */ bridgeReviveDelayMs: number; /** * Wall-clock timestamp of the most recent user-visible `fetch()` / * capture success. Null until the first success lands. */ lastSuccessAt: number | null; /** * Wall-clock timestamp of the most recent user-visible `fetch()` / * capture failure (any kind). Null until the first failure lands. */ lastFailureAt: number | null; /** * `${kind}: ${error}` string from the most recent failure — handy * for surfacing the latest reason in a healthcheck tool. Null until * the first failure lands. */ lastFailureReason: string | null; /** * Failures since the last success. Resets to 0 on any success. Use * as a "soft degraded" signal in a healthcheck tool. */ consecutiveFailures: number; /** * 0.8.0+ (#23 ask 4): wall-clock timestamp of the most recent * inner frame received from the extension (regardless of whether * it was a success or error for the calling MCP). Distinct from * `lastSuccessAt`/`lastFailureAt`, which track *user-visible* * fetch outcomes — `lastExtensionMessageAt` is "is the extension * still answering?" liveness. Null until the first frame arrives. */ lastExtensionMessageAt: number | null; /** * 0.10.0+ (#73): keep-alive observability surface for downstream * healthcheck tools. `enabled` mirrors the `> 0` interval guard; * `intervalMs` / `maxIdleMs` are the resolved option values (20_000 * / 300_000 by default, since #90 tightened the interval from 25_000); * `lastPingAt` / `totalPings` track timer * activity (monotonic, never reset); `idleSinceMs` is the elapsed * time since `lastActiveAt`, or null if no activity has been recorded. */ keepAlive: { enabled: boolean; intervalMs: number; maxIdleMs: number; lastPingAt: number | null; totalPings: number; idleSinceMs: number | null; }; /** * 0.10.0+ (#73): MV3 SW-eviction observability. `lazyReviveAttempts` * increments when the lazy-revive retry path fires (one-shot on the * cold-start symptom — `content_script_unreachable`, or a `fetch()` * `timeout` as of #90); `lazyReviveSuccesses` increments when the * post-revive retry actually succeeds. `lastEvictionDetectedAt` * stamps the most recent time we observed a cold-start symptom (the * canonical sign of Chrome having evicted the SW between bursts) — * overwritten on each detection, so it reflects the latest eviction, * not the first. */ swEviction: { lazyReviveAttempts: number; lazyReviveSuccesses: number; lastEvictionDetectedAt: number | null; }; } /** * 0.11.0+: the typed result of `FetchproxyServer.runProbe()`. The * transport half of the healthcheck loop zillow/redfin/homes had been * duplicating verbatim in `src/tools/healthcheck.ts` — run a probe * fetch, measure elapsed ms, classify any error, and project * `bridgeHealth()` into a `bridge` sub-object. * * The tool registration + the site-specific hint text STAY in the * consumer — `runProbe` only does probe execution + classification + * the bridge projection, so each MCP keeps its own `${Site} redirecting * on login` phrasing. */ export interface BridgeProbeResult { /** True iff the probe `fetchFn` resolved without throwing. */ ok: boolean; /** Wall-clock milliseconds the probe `fetchFn` took (success or failure). */ elapsed_ms: number; /** * Snake-cased projection of `bridgeHealth()` — the subset cohort * healthcheck tools surface. Read after the probe so the freshness * counters reflect this very round-trip. */ bridge: { role: 'host' | 'peer' | null; port: number; server_version: string; fetch_timeout_ms: number; last_success_at: number | null; last_failure_at: number | null; last_failure_reason: string | null; consecutive_failures: number; }; /** * Present only when `ok` is false. `kind` is `classifyBridgeError`'s * verdict over the thrown error (`'timeout' | 'bridge_down' | 'http' | * 'protocol' | 'other'`); `message` is the error's message (or its * `String()` for a non-Error throw). */ error?: { kind: BridgeError; message: string; }; } /** Result of a successful `read_cookies` call. */ export interface ReadCookiesResult { /** Discriminator for the union with `ReadCookiesResultError`. */ ok: true; /** * Raw `document.cookie` value (semicolon-separated `k=v` pairs). * HttpOnly cookies are NOT included — they're invisible to page JS * by design, which is the intentional security boundary of the * `read_cookies` capability. */ cookies: string; } /** Result of a failed `read_cookies` call (transport / capability / no-tab). */ export interface ReadCookiesResultError { /** Discriminator for the union with `ReadCookiesResult`. */ ok: false; /** Human-readable error string from the bridge or extension. */ error: string; } /** * The MCP-facing handle for the fetchproxy bridge. * * `listen()` loads identity and reserves nothing. The first verb call * (or an explicit `connect()`) races the configured port: if the bind * succeeds, the instance becomes the concentrator (role `'host'`) the * extension dials. If the port is already taken by another fetchproxy * host, the instance becomes a peer (role `'peer'`) and tunnels through * that host's existing WebSocket. Either way, callers issue `fetch()` * (or one of the verb shortcuts) and get the response from the user's * signed-in browser tab as if they'd run `window.fetch` there * themselves. * * Behavior is identical between host and peer roles — `role` is * surfaced mostly for testability and metrics. Callers should not * branch on it. */ export declare class FetchproxyServer { /** * Bridge role. `null` until the first verb call (or an explicit * `connect()`) — `listen()` no longer triggers the role election * as of 0.5.3+. Reset to `null` on `close()`. */ role: 'host' | 'peer' | null; private opts; private hostHandle; private peerHandle; private closing; private nextRequestId; private lastSuccessAt; private lastFailureAt; private lastFailureReason; private consecutiveFailures; private lastExtensionMessageAt; private pending; private pendingReadCookies; private pendingStorage; private pendingWriteCookies; private pendingCapture; private pendingRedirect; private pendingIdb; private pendingDownload; private pendingGraphql; private mcpId; private identity; private connectingPromise; private keepAliveTimer; private lastActiveAt; private lastPingAt; private totalPings; private lazyReviveAttempts; private lazyReviveSuccesses; private lastEvictionDetectedAt; constructor(opts: FetchproxyServerOpts); /** * Prepare the bridge for use. Loads the long-term identity keypair * from disk (creating it on first call) and computes this instance's * `mcpId`. Does NOT bind the bridge port or dial any WebSocket — the * connection is established lazily on the first verb call (see * `ensureConnected` / `getOrConnect`). * * Pre-0.5.3 behavior: `listen()` also did role election and started * the host/peer immediately, which meant every configured-but-unused * MCP claimed bridge resources at MCP-client boot. Several MCPs * starting in parallel under Claude Desktop also produced noisy * `ERR_CONNECTION_REFUSED` errors in the extension if it raced ahead * of the first MCP's port bind. Deferring keeps boot quiet and * leaves the port unowned until something actually needs it. * * Calling `listen()` twice without an intervening `close()` is a * no-op (the second call's identity load is idempotent). */ listen(): Promise; /** * Force an eager bridge connection (role-election + host/peer handle * start + listener wiring) without waiting for the first verb call. * Useful for callers that want to surface the role / connection * outcome at boot, or for tests whose harness dials a mock extension * immediately after server construction. Production MCPs that just * answer tool calls should NOT call this — the lazy connect via * `ensureConnected` will do the right thing on first use, keeping * boot cheap and avoiding port-bind contention for MCPs that never * actually get invoked. * * Idempotent: a second call after the first has resolved is a no-op * (the existing handle is reused). Throws if `listen()` was never * called. */ connect(): Promise; /** * Establish the bridge connection (role-election + host/peer handle * start + listener wiring) the first time a verb is invoked. * Idempotent after the connection is up; concurrent first-callers * share the same in-flight promise so only one election happens. * * Throws if `listen()` was never called — the contract is that the * MCP author still must wire `transport.start()` at boot to load * identity / set mcpId, even though the WS doesn't open until a * verb runs. */ private ensureConnected; private doConnect; private pairingErrorMessage; /** * Raw single-shot fetch through the bridge. Most callers should prefer * the verb shortcuts (`get` / `post` / `getJson` / `postJson` / `getHtml`) * — they build the URL from a path, default sensible status checks, and * map non-2xx into typed errors. This entry point is here for the cases * where you already have a `FetchInit` ready (or need to fully control * `tabUrl` independently of the request URL). * * Returns a discriminated union: `{ ok: true, status, url, body }` on a * successful upstream HTTP response (any 2xx/3xx/4xx/5xx — the upstream * STATUS does not turn this into `ok: false`); `{ ok: false, error }` * only when the bridge itself failed (no signed-in tab, extension * offline, etc.). */ fetch(init: FetchInit): Promise; /** * 0.8.0+: snapshot of the bridge's process-wide freshness counters, * suitable for surfacing through a downstream MCP's healthcheck tool. * Counters reset on a success (consecutiveFailures), accumulate * across the process lifetime otherwise. Replaces the per-MCP * duplication the realty cohort had been rolling in their adapters. * `lastExtensionMessageAt` is updated whenever ANY inner frame * arrives from the extension — gives extension-side liveness * distinct from server-side success/failure of the user-visible * call (addresses #23 ask 4). */ bridgeHealth(): BridgeHealth; private recordSuccess; private recordFailure; /** * 0.8.1+ (#67): caller-side hint that work is happening or about to * happen — bumps the keep-alive idle gate so the server keeps pinging * the extension. Useful for MCPs that do a chain of side-effectful * work between bridge calls and don't want the SW to evict in the * gap (e.g. server-side parsing of a previous response that takes * tens of seconds). No-op when `keepAliveIntervalMs` is `0`. */ markActive(): void; /** * #208: this MCP's pin on the extension's identity, stored beside its own * identity key and so following `identityDir` wherever the caller put it. * * `allowNewExtensionIdentity` falls back to an environment variable when the * caller expressed no opinion, because the thirteen MCPs that construct this * class are separate packages: an operator whose extension re-install has * just locked all of them out needs one lever that does not require patching * every one of them. */ private extensionTrust; private noteActivityForKeepalive; private startKeepaliveIfIdle; private sendKeepalivePing; private stopKeepalive; /** * Send an inner request frame via whichever bridge handle is active. If the * send throws (e.g. `FetchproxySessionNotReadyError` — the session never * confirmed), the frame never reached the bridge, so no reply will arrive: * drop the just-registered pending resolver for this id (it lives in exactly * one of the op maps — request ids are unique) so it doesn't leak until the * server closes, then rethrow. */ private sendInnerFrame; /** * Single bridge round-trip, wrapped by `fetchTimeoutMs` when set. * On timeout returns the `{ok:false, kind:'timeout'}` envelope — * the throwing surface is the convenience methods. */ private _fetchOnceWithTimeout; /** * FP-B2: bound a non-`fetch` verb's reply wait by `fetchTimeoutMs`. * * Before this, only `fetch()` raced its `pending` reply against a timer * (`_fetchOnceWithTimeout`); `readCookies`, the storage reads, * `capture_request_header`, `capture_redirect`, `download`, and * `read_indexed_db` awaited their `pending` promise with no race, so a * wedged extension hung the tool call indefinitely. * * `pending` is the already-registered reply promise. `pendingMap`/`id` * point at the op-specific map entry so we can drop it on expiry exactly * as `_fetchOnceWithTimeout` does — otherwise a late bridge reply would * resolve into a stale resolver / leak. On timeout we reject with a * `FetchproxyTimeoutError` (the same throwable the convenience methods * already surface for fetch timeouts). `0`/unset opts out (unbounded), * matching the fetch path. */ private _withVerbTimeout; /** * Map an `ok:false` fetch result to its typed throwable. Centralizes * the kind-to-error-class switch so `request()` and (via the same * logic re-implemented inline) `captureRequestHeader()` agree on what * to throw. */ private _typedErrorFor; /** * Convenience wrapper around `fetch()`. Builds the URL from a path * + optional subdomain + optional domain selector, throws on * protocol errors, optionally asserts on the response status. * * Path resolution: * - Absolute URL (`https://...`) → used as-is (still guarded against * leaving the declared domain set). * - Relative path → joined with `https://${subdomain}.${baseDomain}` * (or `https://${baseDomain}` if no subdomain is given). * * Base-domain selection: * - Single-domain MCP (`domains: ['x.com']`): `opts.domain` is optional; * `domains[0]` is used by default. * - Multi-domain MCP: `opts.domain` is required and must equal one of * the declared domains exactly. */ request(method: string, path: string, opts?: RequestOpts): Promise; /** Issue a GET against `path` (resolved via `request()` rules). */ get(path: string, opts?: BodylessRequestOpts): Promise; /** Issue a POST with optional string body. */ post(path: string, body?: string, opts?: BodylessRequestOpts): Promise; /** Issue a PUT with optional string body. */ put(path: string, body?: string, opts?: BodylessRequestOpts): Promise; /** Issue a PATCH with optional string body. */ patch(path: string, body?: string, opts?: BodylessRequestOpts): Promise; /** Issue a DELETE. No body — bridge does not support DELETE-with-body. */ delete(path: string, opts?: BodylessRequestOpts): Promise; /** * GET a path and parse the response body as JSON. Throws * `FetchproxyHttpError` if the status is outside the default 2xx * happy-path set (`[200, 201, 202, 204]`); pass a custom * `expectStatus` to override. */ getJson(path: string, opts?: BodylessRequestOpts): Promise; /** * POST a JSON body and parse the response body as JSON. The body is * `JSON.stringify`'d; `Content-Type: application/json` is set unless * the caller already provided one. Defaults `expectStatus` to the 2xx * happy-path set. */ postJson(path: string, body?: unknown, opts?: BodylessRequestOpts): Promise; /** * GET a path and return the response body as a string. Throws * `FetchproxyHttpError` if the status is outside the default 2xx * happy-path set. */ getHtml(path: string, opts?: BodylessRequestOpts): Promise; /** * 0.11.0+: method-generic JSON convenience helper. Generalizes the * `fetchJson(path, { method, headers, body })` that * zillow/redfin/compass/homes hand-rolled char-for-char in their * `src/client.ts`: * * - sets `Accept: application/json`; * - adds `Content-Type: application/json` only for a non-GET request * that carries a `body` (and only if the caller didn't set one); * - `JSON.stringify`s the body (GET / no-body sends nothing); * - treats a `204` or an empty body as `data: null` (no parse); * - otherwise `JSON.parse`s the body. * * Scope is serialization + header defaults + 204-handling + * JSON.parse ONLY. It deliberately does NOT assert on the HTTP status * or look for a sign-in interstitial — those guards differ per site * (Zillow's `captcha-delivery`, Redfin's AWS-WAF challenge, …), so it * returns BOTH the parsed `data` and the raw `FetchResult` and leaves * the consumer to run its own `throwIfNotOk` / `throwIfSignInPage` * over `result`. * * Bridge-level failures (no signed-in tab, SW down, timeout) still * throw the typed errors via `request()`, exactly like the verb * helpers — only successful round-trips (any HTTP status) return. */ requestJson(method: string, path: string, opts?: { subdomain?: string; domain?: string; headers?: Record; body?: unknown; }): Promise<{ data: T | null; result: FetchResult; }>; /** * 0.11.0+: run a single healthcheck probe through `fetchFn`, measure * the elapsed round-trip, classify any thrown error, and project the * post-probe `bridgeHealth()` into a snake-cased `bridge` sub-object. * * This is the transport half of the probe loop zillow/redfin/homes * had duplicated verbatim in `src/tools/healthcheck.ts`. The MCP * supplies its own probe call (`(path) => client.fetchHtml(path)`) * and probe path (e.g. `'/robots.txt'`); the tool registration and * the site-specific plain-English hint text STAY in the consumer. * * `bridgeHealth()` is read AFTER the probe so its freshness counters * (`lastSuccessAt` / `consecutiveFailures` / …) reflect this very * round-trip rather than a stale pre-probe snapshot. */ runProbe(fetchFn: (path: string) => Promise, probePath: string): Promise; /** * Snapshot the user's non-HttpOnly cookies for the chosen domain. * * Requires `'read_cookies'` in `FetchproxyServerOpts.capabilities`. * Throws a developer-facing `Error` at the call site if the MCP did * not declare the capability — this is a programming mistake, not a * runtime condition. * * The returned string is the raw `document.cookie` value (semicolon- * separated `k=v` pairs). HttpOnly cookies are NOT visible to page JS * and are therefore not included; the underlying threat model assumes * the cookies that matter for the auth bootstrap (session tokens, csrf * cookies that the page itself reads) are non-HttpOnly. * * Throws `FetchproxyProtocolError` if the bridge could not deliver * the request (no signed-in tab, extension offline, etc.). */ readCookies(opts?: { domain?: string; subdomain?: string; keys?: string[]; path?: string; }): Promise; /** * 1.12.0+: overwrite the value of cookies this MCP already declares. * * The bridge's only write verb, and it exists for one failure class. Sites * that ROTATE a credential cookie hand back a new value on every refresh; if * the MCP refreshes and keeps the result to itself, the copy in the browser's * cookie jar is dead, and the user gets signed out of a tab they never * touched — usually reported to them as "inactivity". Writing the rotated * value back is the only thing that repairs it. * * Requires `'write_cookies'` in capabilities, which the user approves at pair * time as its own line. Every name must ALSO be in declared `cookieKeys`: a * write can never reach a cookie the MCP was not already trusted to read, so * granting it cannot widen which cookies are in play — only what may be done * to the ones already listed. * * The extension refuses the whole request unless every named cookie already * exists; this refreshes a value in place and deliberately cannot author new * cookies. Returns the names actually written. */ writeCookies(opts: { cookies: Record; domain?: string; subdomain?: string; path?: string; }): Promise; /** * 0.3.0+: read declared localStorage keys from the user's signed-in * tab. Requires `'read_local_storage'` in capabilities AND each key * to be in declared `localStorageKeys`. Returns a `Record` * including only keys that exist in storage. * * 0.4.0+: optional `pointers` map. Each entry `{ outputKey: { storageKey, jsonPointer } }` * extracts a node from the JSON-parsed value at `storageKey`. The * `(storageKey, jsonPointer)` pair must match a declared * `localStoragePointers` entry on the server hello. */ readLocalStorage(opts: { domain?: string; subdomain?: string; keys: string[]; pointers?: Record; }): Promise>; /** * 0.3.0+: read declared sessionStorage keys. Identical shape to * `readLocalStorage` but against sessionStorage. */ readSessionStorage(opts: { domain?: string; subdomain?: string; keys: string[]; pointers?: Record; }): Promise>; private readStorageImpl; /** * 0.3.0+: snapshot the next outgoing request's named header. Single- * shot: the extension registers a one-time `webRequest` listener * filtered on `https://${host}${path ?? '/*'}`, captures the named * header on the first match, removes itself, and resolves with the * value. Times out after `timeoutMs` (default 30s on the extension). * * `(host, path?, headerName)` must match a declared entry in * `FetchproxyServerOpts.captureHeaders` (omitted path ≡ `/*`). */ captureRequestHeader(opts?: { host?: string; path?: string; headerName?: string; timeoutMs?: number; }): Promise; private _captureRequestHeaderOnce; /** * Snapshot the redirect target URL of the next request the browser * makes to `(host, path?)`. Single-shot: the extension registers a * one-time `chrome.webRequest.onBeforeRedirect` listener filtered on * `https://${host}${path ?? '/*'}`, captures `details.redirectUrl` on * the first match, removes itself, and resolves with the URL. Times out * after `timeoutMs` (default 30s on the extension). * * Use case: a Cloudflare-walled endpoint that 302-redirects cross-origin * to a presigned URL — a page-level fetch sees only an opaque redirect, * but `onBeforeRedirect` exposes the target. Capture is limited to the * MCP's own declared `domains`; no per-entry declared scope is required. */ captureRedirect(opts: { host: string; path?: string; timeoutMs?: number; }): Promise; private _captureRedirectOnce; /** * Download `url` through the BROWSER's own network stack via * `chrome.downloads` (real cookies + TLS/JA3 fingerprint). Unlike a * page-level `fetch()` (cors mode), this clears a Cloudflare bot-challenge * on the endpoint and follows the cross-origin redirect to the final file. * Resolves the saved local file path + size; the bridge is loopback-only / * single-host, so the MCP reads the bytes from the same disk. Requires * `'download'` in capabilities and the `url` host to be a declared `domain`. */ download(opts: { url: string; filename?: string; timeoutMs?: number; }): Promise; private _downloadOnce; /** * 0.4.0+: read declared IndexedDB keys from the user's signed-in * tab. Requires `'read_indexed_db'` in capabilities AND the * `(database, store, keys)` triple to subset-match a declared * `indexedDbScopes` entry on the same origin. * * Returns a `Record` of the JSON-typed values, with * missing keys omitted. Throws `FetchproxyProtocolError` on bridge * failures (no tab, extension offline, etc.) and a plain `Error` * on developer mistakes (undeclared capability, undeclared scope). */ readIndexedDb(opts: { domain?: string; subdomain?: string; database: string; store: string; keys: string[]; }): Promise>; /** * 1.4.0+: read declared DOM values from the user's signed-in tab. * Requires `'read_dom'` in capabilities AND every requested `name` to * match a declared `domSelectors` entry. The extension reads each * declared selector from the matched tab's DOM (isolated-world * `querySelector`, value or attribute) — no page-JS execution. * * Returns a `Record` of `name → value`, with names * whose element (or attribute) was absent omitted. Throws * `FetchproxyProtocolError` on bridge failures and a plain `Error` on * developer mistakes (undeclared capability, undeclared name). */ readDom(opts: { domain?: string; subdomain?: string; names: string[]; }): Promise>; /** * 1.x+: run a declared GraphQL operation through the page's own Apollo * client (`window.__APOLLO_CLIENT__`) in the signed-in tab's MAIN world. * Requires `'graphql'` in capabilities AND `name` to match a declared * `graphqlOps` entry. The extension resolves `name` → `operationName` → * the live DocumentNode the page already observed, then invokes * `client.query({ query, variables })` — the site's own request path, so * per-request bot telemetry (Akamai etc.) runs automatically. * * Returns the GraphQL `data` object on success (shape is * operation-specific; the caller narrows). Throws a plain `Error` on * developer mistakes (undeclared capability, undeclared name) and a * descriptive `Error` on the `ok:false` bridge path — which includes the * typed "operation not yet observed on this tab" case (open the site's * page and retry). */ graphqlQuery(opts: { name: string; variables: Record; tabUrl?: string; }): Promise; private assertScopeSubset; private resolveBaseDomain; private applyJsonDefaults; private hasContentType; private onInner; private rejectAllPending; /** * 0.5.2+: read the current pair-pending pair code from whichever handle * is active, returning null when none is pending. Public verbs call this * at the top so that a tool invoked while the bridge is waiting on user * approval fails fast with the actionable error rather than hanging on a * sealed frame the extension will never process. */ private currentPendingPairCode; /** * 0.5.2+: throw `FetchproxyProtocolError` with the actionable pair-code * message if the bridge is waiting on user approval. Used by the verb * methods (readCookies, readLocalStorage, etc.) that surface errors via * thrown exceptions rather than `ok:false` discriminated unions. */ private throwIfPendingPair; /** * Shut down the bridge. Host: terminates the WebSocket server and any * still-attached extension/peer clients. Peer: closes the upstream * connection to the host. Safe to call before `listen()` (no-op) or * twice in a row. */ close(): Promise; }