/** * Fetchproxy transport adapter — the Pattern-A glue shared by every * fetchproxy-backed MCP (redfin/zillow/compass/homes/onehome/resy/opentable/…). * * `@fetchproxy/server` already owns HTTP proxying, session bootstrap, and the * bot-wall / backoff / deadline / concurrency / retry primitives. This module * does NOT reimplement any of that — it re-exports the primitives so MCPs have a * single import site, and provides two thin factories: * * - {@link createFetchproxyTransport} wraps a `FetchproxyServer` in the * `start` / `close` / `status` lifecycle every MCP's transport interface * expects, with optional debug-gated role logging, PLUS the opt-in verb * passthroughs (`fetch` / `requestJson` / `runProbe`) that redfin / homes / * compass / musescore had each hand-rolled over the server. * - {@link registerBridgeHealthcheckTool} registers a `_healthcheck` * tool that round-trips a probe path through the bridge and surfaces the * actionable hint ladder compass + musescore had each copied (with drifted * internals + a hardcoded-port bug) into `src/tools/healthcheck.ts`. * - {@link createBootstrapOpts} assembles a multi-domain / capture-header / * storage-pointer declaration fragment of `FetchproxyServerOpts`, deriving * the required `capabilities` from the declared bootstrap so callers can't * forget to unlock the verb they declared. * * The adapter shape is identical across 12+ fetchproxy MCPs; collapsing it here * keeps the per-row / concurrency / deadline helpers as re-exports rather than * re-rolled code. * * Lazy-import note: this whole subpath is gated behind the OPTIONAL * `@fetchproxy/server` peer dep — a consumer only resolves it by importing * `@chrischall/mcp-utils/fetchproxy`, never via the core barrel. The eager * top-level import below therefore never reaches a `.mcpb` bundle that * externalizes `@fetchproxy/server` unless that consumer actually opted into * the bridge. Everything added here routes through that same already-imported * `FetchproxyServer` instance (no NEW top-level `@fetchproxy/server` import), * so the bundle-load smoke posture is unchanged. */ import { FetchproxyServer, type FetchproxyServerOpts, type HttpResponse, type BridgeProbeResult } from '@fetchproxy/server'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; export { FetchproxyServer, mapWithConcurrency, withDeadline, TokenBucket, classifyBotWall, retryOnceOnTimeout, FetchproxyProtocolError, FetchproxyHttpError, FetchproxyBridgeDownError, FetchproxyTimeoutError, classifyBridgeError, classifyRowError, classifyFetchError, backoffDelayMs, BRIDGE_CONCURRENCY, chunk, sleep, extractGlobalAssign, extractBalancedObject, extractImgTags, lastPathSegment, } from '@fetchproxy/server'; export type { FetchproxyServerOpts, FetchResult, FetchResultError, HttpResponse, RequestOpts, BodylessRequestOpts, BridgeHealth, BridgeProbeResult, BridgeError, FetchErrorKind, BotWallResult, BotWallVendor, TokenBucketOptions, BackoffOptions, DeadlineOutcome, } from '@fetchproxy/server'; /** * One request to round-trip through the bridge. The path is resolved relative * to the transport's declared domain (a `defaultSubdomain` — e.g. `'www'` — is * applied unless the caller overrides it per-call). This is the common * `FetchInit` redfin / homes / compass / musescore each declared verbatim in * their `src/transport.ts`. */ export interface FetchproxyFetchInit { /** Path-and-query relative to the declared domain, e.g. `/robots.txt`. */ path: string; method: 'GET' | 'POST' | 'PUT' | 'DELETE'; headers?: Record; /** Serialized request body. JSON callers stringify before calling. */ body?: string; /** * Per-call subdomain override. Defaults to the transport's `defaultSubdomain` * (and, absent that, the apex). Absolute `http(s)://` paths self-describe * their host, so this is ignored for them. */ subdomain?: string; /** Per-call base-domain selector (required only for multi-domain MCPs). */ domain?: string; } /** The success-arm `{status, body, url}` triple every consumer returns. */ export type FetchproxyFetchResult = HttpResponse; /** Options for {@link FetchproxyTransport.requestJson}. */ export interface FetchproxyRequestJsonInit { headers?: Record; body?: unknown; subdomain?: string; domain?: string; } /** * The lifecycle surface every per-MCP fetchproxy transport interface exposes. * `createFetchproxyTransport` returns this, typed as the caller's `T` so it can * stand in for `RedfinTransport`, `ZillowTransport`, etc. without those * interfaces depending on this package. */ export interface FetchproxyTransport { /** * Load identity (creating the 0600 keypair on first run) and prepare the * bridge. Does NOT bind the port or dial — connection is lazy on first verb. * When `debugEnvVar` is set and truthy, logs the landed role to stderr. */ start(): Promise; /** Tear the bridge connection down. Safe to call before {@link start}. */ close(): Promise; /** * Process-wide bridge freshness snapshot, for a healthcheck tool. The * factory additively pins `serverVersion` to the caller's `version` opt — the * field homes / redfin / compass each projected by hand — so consumers can * delegate `status()` straight through without re-wrapping. */ status(): ReturnType; /** Bridge role; `null` until the first verb call / explicit connect. */ readonly role: FetchproxyServer['role']; /** * The wrapped `FetchproxyServer` — the verb surface (`request`/`get`/`post`/ * `getJson`/`getHtml`/`readCookies`/`captureRequestHeader`/…). Exposed so the * caller's tool layer can issue requests without this package modelling every * verb. */ readonly server: FetchproxyServer; /** * Verb passthrough: round-trip one request through `server.request(...)`, * applying the transport's `defaultSubdomain`, and return the success-arm * `{status, body, url}` triple. Bridge failures throw the typed errors * (`FetchproxyBridgeDownError` / `FetchproxyTimeoutError` / …), exactly like * `server.request`. This is the `fetch(init)` redfin / homes / compass / * musescore each wrote by hand. */ fetch(init: FetchproxyFetchInit): Promise; /** * Verb passthrough over `server.requestJson(...)` (serialization + header * defaults + 204→null + JSON.parse). Returns BOTH the parsed `data` and the * raw success-arm `result`, so the caller keeps its per-site `throwIfNotOk` / * sign-in guards over `result`. `defaultSubdomain` is applied. */ requestJson(method: 'GET' | 'POST' | 'PUT' | 'DELETE', path: string, init?: FetchproxyRequestJsonInit): Promise<{ data: T | null; result: FetchproxyFetchResult; }>; /** * Verb passthrough over `server.runProbe(...)` — run one healthcheck probe, * measure elapsed ms, classify any thrown error, and project the post-probe * `bridgeHealth()`. Powers {@link registerBridgeHealthcheckTool}. */ runProbe(fetchFn: (path: string) => Promise, probePath: string): Promise; } /** Options for {@link createFetchproxyTransport}. */ export type CreateFetchproxyTransportOptions = FetchproxyServerOpts & { /** * Env var name that gates stderr role/lifecycle logging (e.g. `REDFIN_DEBUG`). * The value is read defensively — empty / `'null'` / `${...}` placeholders are * treated as unset, so an unexpanded MCP-host env block never enables logging. */ debugEnvVar?: string; /** Env source for {@link debugEnvVar}. Defaults to `process.env`. */ env?: NodeJS.ProcessEnv; /** * Subdomain the verb adapters (`fetch` / `requestJson`) apply per call unless * the caller overrides it. This is the ONE per-site bit of the verb surface: * redfin / homes / compass pin `'www'`; apex-served sites (musescore) omit it * to hit the bare domain. Absolute `http(s)://` paths self-describe their host * and ignore this entirely. */ defaultSubdomain?: string; /** * When `true`, {@link FetchproxyTransport.start} emits a one-line startup * banner to **stderr** (stdout is reserved for the JSON-RPC channel) in the * canonical fleet format: * * ``` * [:bridge] listening on 127.0.0.1: (role=, version=) * ``` * * `` is the bridge's resolved port (from `bridgeHealth()` after * `listen()`), so it reflects an overridden port rather than a literal. * Default `false` keeps existing consumers silent — they opt in to drop the * hand-rolled banner redfin / homes / compass each wrote verbatim. Independent * of {@link debugEnvVar} (which gates the richer per-request debug logging). */ logListening?: boolean; /** * Test seam: factory that constructs the underlying `FetchproxyServer` from * the forwarded `FetchproxyServerOpts`. Defaults to * `(o) => new FetchproxyServer(o)`. A consumer's vitest passes a factory * returning a mock so it can capture the constructor opts and stub verbs * (e.g. `download`) WITHOUT `vi.mock('@fetchproxy/server')` — which can't * reach the `new FetchproxyServer` call buried inside this package's prebuilt * dist. The default path is unchanged: it routes through the already-imported * `FetchproxyServer`, adding no new eager `@fetchproxy/server` import. */ createServer?: (opts: FetchproxyServerOpts) => FetchproxyServer; }; /** * Wrap a `FetchproxyServer` in the `start`/`close`/`status` lifecycle the * per-MCP transport interface expects. The full `FetchproxyServerOpts` is * forwarded verbatim (so `fetchTimeoutMs`, `keepAliveIntervalMs`, capture * declarations, etc. all pass through). The added knobs are: * * - `debugEnvVar` — env-gated per-request debug logging; * - `defaultSubdomain` — the per-call subdomain the verb adapters apply; * - `logListening` — opt-in canonical startup banner on `start()` (stderr); * - `createServer` — a test seam to inject a mock `FetchproxyServer`. * * `status()` additively pins `serverVersion` to the `version` opt, so consumers * no longer re-wrap `bridgeHealth()` just to project it. * * Returns the wrapper typed as the caller's `T` (defaulting to * {@link FetchproxyTransport}) so it can satisfy a structurally-compatible * per-MCP interface without that interface importing this package. * * @example * const transport = createFetchproxyTransport({ * serverName: 'redfin-mcp', version, domains: ['redfin.com'], * debugEnvVar: 'REDFIN_DEBUG', logListening: true, * }); */ export declare function createFetchproxyTransport(opts: CreateFetchproxyTransportOptions): T; /** Re-export the protocol declaration shapes so callers have one import site. */ export type { Capability, CaptureHeaderDecl, IndexedDbScopeDecl, DomSelectorDecl, StoragePointerDecl, } from '@fetchproxy/protocol'; import type { CaptureHeaderDecl, IndexedDbScopeDecl, DomSelectorDecl, StoragePointerDecl } from '@fetchproxy/protocol'; /** * The bootstrap declarations an MCP needs to extract auth from the user's * signed-in tab. Each present, non-empty group unlocks the capability that * gates the matching verb — `createBootstrapOpts` derives `capabilities` so the * caller can't declare a capture without unlocking it (or vice-versa). */ export interface BootstrapDecls { /** `read_cookies`: declared cookie names readable via `readCookies({ keys })`. */ cookieKeys?: string[]; /** `read_local_storage`: declared localStorage keys. */ localStorageKeys?: string[]; /** `read_session_storage`: declared sessionStorage keys. */ sessionStorageKeys?: string[]; /** JSON-pointer extractions over localStorage values (implies `read_local_storage`). */ localStoragePointers?: StoragePointerDecl[]; /** JSON-pointer extractions over sessionStorage values (implies `read_session_storage`). */ sessionStoragePointers?: StoragePointerDecl[]; /** `capture_request_header`: (host, path?, headerName) decls to snapshot. */ captureHeaders?: CaptureHeaderDecl[]; /** `read_indexed_db`: declared IndexedDB scopes. */ indexedDbScopes?: IndexedDbScopeDecl[]; /** `read_dom`: declared CSS-selector DOM reads (e.g. a Turnstile token input). */ domSelectors?: DomSelectorDecl[]; } /** Options for {@link createBootstrapOpts}. */ export interface CreateBootstrapOptsArgs { /** * Trust-boundary hostname(s). A bare string is accepted for the common * single-domain case; multi-domain MCPs pass an array (and must then specify * `{ domain }` on each per-call request). */ domains: string | string[]; /** * Documentation hint for *where* the bootstrap reads from (e.g. * `portal.onehome.com`). Recorded on the returned fragment as a comment-level * concern only — the actual gating is per declaration. Must be a subdomain of * (or equal to) one of `domains` if provided. */ storageDomain?: string; /** The capture/storage declarations to thread into capabilities + opts. */ bootstrap?: BootstrapDecls; } /** * Assemble the multi-domain / bootstrap-declaration fragment of * `FetchproxyServerOpts`. Spread the result into * {@link createFetchproxyTransport} alongside `serverName` / `version`. * * The returned `capabilities` is derived from the declared bootstrap: each * present declaration group adds exactly the capability that gates its verb * (deduped). When no bootstrap declarations are given, `capabilities` is left * unset so the server falls back to its default `['fetch']`. * * @example * const opts = createBootstrapOpts({ * domains: 'onehome.com', * storageDomain: 'portal.onehome.com', * bootstrap: { captureHeaders: [{ host: 'portal.onehome.com', path: '/graphql*', headerName: 'Authorization' }] }, * }); * createFetchproxyTransport({ ...opts, serverName: 'onehome-mcp', version }); */ export declare function createBootstrapOpts(args: CreateBootstrapOptsArgs): Pick; /** Discriminated classification of a tool-boundary error. */ export interface BridgeErrorInfo { type: 'bridge_down' | 'timeout' | 'http' | 'protocol' | 'unknown'; message: string; hint?: string; } /** * Thin discriminator over the `@fetchproxy/server` typed-error hierarchy. Folds * the re-exported raw {@link classifyBridgeError} (which returns a bare kind * string) into a `{ type, message, hint? }` envelope, mapping fetchproxy's * `'other'` to `'unknown'` and lifting the per-class remediation `hint` where one * exists. The surfaced message is redacted + truncated. Use this when you want * the structured envelope; use the re-exported `classifyBridgeError` for the raw * string kind (drop-in compatible with `@fetchproxy/server`). */ export declare function bridgeErrorInfo(err: unknown): BridgeErrorInfo; /** The diagnostic result a `_healthcheck` tool returns. */ export interface BridgeHealthcheckResult { ok: boolean; bridge: BridgeProbeResult['bridge'] & { last_extension_message_at: number | null; }; probe: { url: string; elapsed_ms: number; status?: number; body_length?: number; }; error?: { /** * The classified error kind. Normally one of `BridgeErrorInfo['type']`; * a consumer-supplied {@link RegisterBridgeHealthcheckToolArgs.classifyThrown} * can introduce site-specific kinds (e.g. workday's `'session_expired'`). */ kind: string; message: string; /** Server-authored next-step hint (`FetchproxyBridgeDownError.hint`), when present. */ bridge_hint?: string; /** * Extra structured diagnostics supplied by * {@link RegisterBridgeHealthcheckToolArgs.classifyThrown} (e.g. a repo's * `elapsed_ms_at_timeout` / `retry_attempted` / `role_at_failure`). The * shared envelope carries no fixed slots for these, so a consumer that wants * the richer detail its hand-rolled healthcheck used to return injects it * here. Omitted when the classifier supplies none. */ detail?: Record; }; /** Plain-English next-step suggestion derived from the result. */ hint: string; } /** The hint-ladder arms whose copy {@link RegisterBridgeHealthcheckToolArgs.hints} can override. */ export type HealthcheckHintArm = 'ok' | 'bridge_down' | 'no_role' | 'timeout' | 'protocol' | 'unknown'; /** Options for {@link registerBridgeHealthcheckTool}. */ export interface RegisterBridgeHealthcheckToolArgs { /** The `McpServer` to register the tool on. */ server: McpServer; /** Tool-name + host-banner prefix, e.g. `'compass'` → `compass_healthcheck`. */ prefix: string; /** Public probe path to round-trip, e.g. `'/robots.txt'`. */ probePath: string; /** * Display host for the probe URL + hint copy, e.g. `'compass.com'` or * `'www.redfin.com'`. The probe URL is `https://`. */ hostLabel: string; /** * The bridge transport — supplies `runProbe` (the probe loop + classification * + post-probe bridge projection) and `status()` (for the liveness counter the * projection omits). Any {@link FetchproxyTransport}-shaped object works. */ transport: Pick; /** * Performs the actual probe fetch for `probePath`. Required — most consumers * pass `(path) => client.fetchHtml(path)` so the probe exercises the same * client path real tools use (sign-in guards and all). */ probeFn: (path: string) => Promise; /** * Map the error the probe THREW to a site-specific `{ kind, hint?, detail? }` — * e.g. workday classifies its `SessionNotAuthenticatedError` as * `session_expired` with SSO re-sign-in copy. Return `undefined` to keep the * default classification. A returned `hint` wins the whole result hint; a * returned `detail` object is merged into the result's `error.detail` (the * hook zillow/etix use to re-attach the structured diagnostics — * `elapsed_ms_at_timeout` etc. — the shared envelope has no fixed slots for). * Absorbs the error-kind special cases that kept workday / zillow / etix on * hand-rolled healthchecks. */ classifyThrown?: (err: unknown) => { kind: string; hint?: string; detail?: Record; } | undefined; /** * Per-arm overrides for the default hint copy (e.g. etix replacing the * generic timeout hint with DataDome-specific guidance). A * {@link classifyThrown} hint takes precedence over these. */ hints?: Partial>; } /** * Register a `_healthcheck` MCP tool that round-trips `probePath` * through the bridge and reports bridge status + role + timing, plus an * actionable hint ladder on failure. * * The probe loop / error classification / post-probe bridge projection all live * in `transport.runProbe` (the `@fetchproxy/server` primitive); this factory * owns only the tool registration, the result shape, and the hint ladder. The * per-site bits (`prefix`, `probePath`, `hostLabel`, the probe `fetchFn`) are * options. * * @example * registerBridgeHealthcheckTool({ * server, prefix: 'compass', probePath: '/robots.txt', * hostLabel: 'compass.com', transport, * probeFn: (p) => client.fetchHtml(p), * }); */ export declare function registerBridgeHealthcheckTool(args: RegisterBridgeHealthcheckToolArgs): void; //# sourceMappingURL=index.d.ts.map