/** * @fetchproxy/bootstrap — session-capture helper for MCPs that follow * Pattern A. * * Most MCPs authenticate via the user's signed-in browser tab, then operate * from Node directly with `fetch()`. They don't need a long-lived browser * bridge; they need the cookies, localStorage values, and request-header * captures that prove the user is signed in, copied into the MCP process. * * Both entry points wrap the same lifecycle — spin up a `FetchproxyServer`, * declare the scope, read each declared bucket, close — and hand back a * `Session`. They differ in what you hold onto: * * * {@link createSessionLifter} returns a **repeatable** lift. Prefer it * whenever the captured session can expire: wire it into whatever your * client uses to mint a session and every expiry re-reads the browser. * Capturing a value once instead is the single most common way to build * an MCP that works for one credential lifetime and then dies with no * way back — a browser-backed account has no password to re-login with. * * * {@link bootstrap} performs exactly one lift, immediately. Right for * genuinely one-shot callers — typically a user-invoked `capture_session` * tool that persists the token itself. * * The MCP imports just those and `Session` — it never sees * `FetchproxyServer`. */ import { type FetchproxyServerOpts } from '@fetchproxy/server'; import type { CaptureHeaderDecl, IndexedDbScopeDecl } from '@fetchproxy/protocol'; export interface Declarations { /** * Cookie names the MCP wants to snapshot (subset of declared `cookieKeys`). * Exact names only: a trailing-`*` glob is refused, because a lift reads * each key by name and a glob would match nothing. */ cookies: string[]; /** localStorage keys to snapshot. Exact names only (see `cookies`). */ localStorage: string[]; /** sessionStorage keys to snapshot. Exact names only (see `cookies`). */ sessionStorage: string[]; /** Header captures: each entry's first matching request supplies the value. */ captureHeaders: CaptureHeaderDecl[]; /** * 0.4.0+: IndexedDB scopes to snapshot. Each entry declares * `(origin, database, store, keys)`. Defaults to `[]` for MCPs * that don't use IDB. */ indexedDb?: IndexedDbScopeDecl[]; /** * 0.4.0+: JSON-pointer extractions over localStorage values. Each * entry binds `(outputKey → (storageKey, jsonPointer))`. The * storageKey must appear in `localStorage`; bootstrap reads it * once and applies all pointers against the parsed JSON. */ localStoragePointers?: { outputKey: string; storageKey: string; jsonPointer: string; }[]; /** 0.4.0+: same shape for sessionStorage. */ sessionStoragePointers?: { outputKey: string; storageKey: string; jsonPointer: string; }[]; } export interface BootstrapOpts { serverName: string; version: string; domains: string[]; declare: Declarations; /** * 0.4.1+: which declared domain bootstrap's cookie / localStorage / * sessionStorage / indexedDb reads should target. Required when * `domains` has more than one entry; otherwise defaults to the only * declared domain. `captureHeaders` is unaffected — each entry * carries its own `host`. * * Example (HoneyBook spans `honeybook.com` for the API * `hb-api-fingerprint` capture and `hbportal.co` for the vendor * portal where `jStorage` lives): * * bootstrap({ * domains: ['honeybook.com', 'hbportal.co'], * storageDomain: 'hbportal.co', // jStorage reads target the vendor portal tab * declare: { localStoragePointers: [...], captureHeaders: [...] }, * }); */ storageDomain?: string; /** * 0.4.1+: optional subdomain selector applied to the resolved * storage domain (e.g. `www`, `app`). The extension matches a tab * whose host equals `${subdomain}.${storageDomain}`. Most MCPs * leave this unset — the apex-domain match is sufficient. */ storageSubdomain?: string; /** * 1.11.0+: optional path the cookie read should target, e.g. `/campus`. * * `chrome.cookies.get({ url, name })` matches the cookie's `Path` attribute * against the URL's path, so a cookie set with `Path=/campus` is invisible * to a read aimed at the origin root. Sites that scope their session cookie * to an app context — Tomcat does this by default — were therefore * unreadable, and the miss was indistinguishable from "user is signed out". * * Only affects the cookie read; localStorage / sessionStorage / IndexedDB * are origin-scoped and unaffected by path. */ storagePath?: string; /** * 0.4.0+: invoked once on receipt of the extension hello with the * joint pair code. Used by MCPs that need to surface the code on * stderr or via a chat notification for the user to verify against * the browser popup. Optional. */ onPairCode?: (code: string) => void; /** * 0.4.0+: invoked when bootstrap is about to wait on a step that * requires user interaction in the browser (e.g. capturing a * request header for an endpoint the user must trigger). The * `hint` is human-readable and identifies the action expected. */ onWaiting?: (hint: string) => void; /** * 0.8.0+: forwarded as-is to `FetchproxyServer`. Unset → server * default (30000ms in 0.8.0). Set to 0 to disable. Useful for * one-shot consumers that want a shorter ceiling than the default. */ fetchTimeoutMs?: number; /** * 0.8.0+: forwarded as-is to `FetchproxyServer`. Unset → server * default (2000ms in 0.8.0). Set to 0 to disable. Useful when a * Pattern-A startup capture should fail fast rather than tolerate * SW eviction (e.g. CI smoke tests). */ bridgeReviveDelayMs?: number; /** * For tests only: inject a fake `FetchproxyServer` factory. The * default uses the real `FetchproxyServer` constructor + `.listen()`. * The opts object is the same shape `FetchproxyServer` accepts. * * The underscore prefix is a soft "internal" marker. Don't pass this * from MCP code. */ _serverFactory?: BootstrapServerFactory; } /** Minimal surface `bootstrap()` actually calls on a `FetchproxyServer`. */ export interface BootstrapServer { listen(): Promise; close(): Promise; readCookies(opts: { keys: string[]; domain?: string; subdomain?: string; path?: string; }): Promise; readLocalStorage(opts: { keys: string[]; domain?: string; subdomain?: string; pointers?: Record; }): Promise>; readSessionStorage(opts: { keys: string[]; domain?: string; subdomain?: string; pointers?: Record; }): Promise>; captureRequestHeader(opts: { host: string; path?: string; headerName: string; }): Promise; readIndexedDb(opts: { database: string; store: string; keys: string[]; domain?: string; subdomain?: string; }): Promise>; } export type BootstrapServerFactory = (opts: FetchproxyServerOpts) => BootstrapServer; export interface Session { cookies: Record; localStorage: Record; sessionStorage: Record; /** Captured headers, keyed by `headerName` (one entry per declared capture). */ capturedHeaders: Record; /** * 0.4.0+: snapshotted IndexedDB values, keyed by * `database/store`. Each inner map keys by IDB key → its * JSON-deserialized value. */ indexedDb: Record>; /** * 1.8.0+: declared keys the browser did NOT return, per bucket. * * A partial lift is the most dangerous failure this helper can produce: the * call succeeds, the maps look populated, and the MCP builds a session that * fails much later somewhere unrelated. That is not hypothetical — an MCP * declaring `['accessToken','cfid','cftoken']` while reading at an apex host * that only carries `accessToken` got a "successful" bootstrap and then a * baffling "you are no longer logged in" from a completely different * endpoint. Reading the wrong host is easy (`storageSubdomain`), and nothing * used to point at it. * * Empty arrays mean everything declared came back. Callers that need all of * their declared keys should check this and fail loudly, naming the misses. * * Note these are *declared* keys, not pointer output names: localStorage * pointers are resolved from the raw storage keys, which are what is * reported here. */ missing: { cookies: string[]; localStorage: string[]; sessionStorage: string[]; }; } /** * A repeatable browser-session lift. * * Calling it opens the bridge, reads every declared bucket, closes the bridge, * and resolves with the captured {@link Session} — the same work `bootstrap()` * does, except you hold the *ability* to do it rather than one result of * having done it. */ export type SessionLifter = () => Promise; /** * Build a repeatable lift for one MCP's declared scope. * * Prefer this over {@link bootstrap} whenever the session can expire. * * ## Why this exists * * `bootstrap()` returns a *value*, so consumers naturally capture a session * once at process start and hand it to their client. That is the API's grain, * not a consumer mistake — and it produces a bug that stays invisible until * the site's credential lifetime is short enough to notice: * * * **The expiry dead end.** A browser-backed account has no password, so * when the captured credential lapses there is nothing to re-login with. * The MCP is unauthenticated for the life of the process even though the * browser still holds a live session the whole time. * * **The sticky startup failure.** A lift that failed at boot (user not * signed in yet) gets cached just as permanently, so signing in afterwards * changes nothing. * * A fleet audit behind this API found four MCPs carrying the one-shot capture * and four that had independently hand-rolled the renewable shape. Making * renewable the default is the point. * * ## Usage * * Construction touches nothing — no server, no `listen()`, no pair prompt. * Wire the lifter into whatever your client uses to mint a session, so expiry * re-reads the browser instead of dead-ending: * * ```ts * const lift = createSessionLifter({ serverName, version, domains, declare }); * const manager = new CookieSessionManager({ login: lift, isExpired }); * ``` * * ## What it deliberately does NOT do * * No TTL tracking, no caching of the result. Expiry semantics are * app-specific — some sites need the lifted token exchanged before it is * usable, others go stale on an idle timer the library cannot see — so the * caller's session manager owns *when* to re-lift. The lifter only owns *how*. * * Post-processing composes in userland rather than needing a hook: * * ```ts * const raw = createSessionLifter(opts); * const lift = async () => exchangeIfStale(await raw()); * ``` * * Concurrent calls are single-flighted: two simultaneous expiries share one * bridge round-trip rather than racing two. Once a lift settles the next call * starts a fresh one — this is de-duplication, not caching. */ export declare function createSessionLifter(opts: BootstrapOpts): SessionLifter; /** * The transport deadline `bootstrap` uses when captures are declared and the * caller named none. * * 45 s = the extension's own 30 s capture default plus 15 s, the same margin * `resy-mcp` and the `fpx` CLI each arrived at independently. The margin is the * whole point: it leaves the EXTENSION's timer first, so a closed window * answers with the rejection that explains itself rather than a bare transport * deadline. * * It is deliberately not larger. This only breaks a tie; it does not decide how * long anyone should wait, which is a caller's judgement and stays theirs * through `fetchTimeoutMs`. */ export declare const CAPTURE_BOOTSTRAP_DEADLINE_MS = 45000; /** * Bootstrap one MCP's session blob — a single lift, run immediately. * * Equivalent to `createSessionLifter(opts)()`. Kept as a first-class export * because plenty of callers genuinely only need one read: the tool-invoked * capture pattern (a `capture_session` tool the user runs on demand, which * then persists the token) is legitimately one-shot. * * If the session it returns can expire underneath you, reach for * {@link createSessionLifter} instead. * * Workflow: * 1. Construct a `FetchproxyServer` with the declared scope mapped * onto its opts (capabilities derived from non-empty buckets). * 2. `listen()` — the bridge starts and the extension reconnects. * 3. Each declared bucket is read in turn. Empty buckets are skipped. * 4. `close()` runs in a finally block — the bridge shuts down even * when one of the reads throws. * 5. Return the captured `Session`. * * The promise rejects on the first read that fails. The caller should * surface that error to the user (most often: "open in Chrome * and sign in, then retry"). */ export declare function bootstrap(opts: BootstrapOpts): Promise; /** * 0.4.0+: thrown by `bootstrap()` when the user has set * `${SERVER_NAME}_DISABLE_FETCHPROXY=1` in the environment. * MCPs catch this and fall back to a "fetchproxy is disabled" path * (typically: tell the user how to use the env var to re-enable, or * use an alternate auth method). */ export declare class BootstrapDisabledError extends Error { readonly serverName: string; readonly envVar: string; constructor(serverName: string, envVar: string); }