import type { Artifact, Connector, ExportResult, TabScopedResult, TabView, } from "../connector/connector.js"; import type { ConsoleEntry, ConsoleQuery, NetworkEntry, NetworkQuery, TabId, } from "../connector/store.js"; import type { AuditCategory, AuditReport } from "../lighthouse/types.js"; export interface PageInfo { url: string; tabId: number | string | null; extensionConnected: boolean; connectedTabs: number; } export interface TabListing { tabs: TabView[]; currentTabId: TabId | null; connectedTabs: number; } export type ScopedConsoleQuery = ConsoleQuery & { allTabs?: boolean }; export type ScopedNetworkQuery = NetworkQuery & { allTabs?: boolean }; export interface ConnectorStatus { version: string; extensionConnected: boolean; connections: number; tabs: number; currentTabId: TabId | null; screenshotDir: string; counts: { console: number; network: number }; } export interface ScreenshotResult { path: string; tabId?: TabId | null; url?: string; /** Data URL, carrying whichever format the browser settled on. */ data: string; name: string; mimeType: string; bytes: number; /** False when the image is too large to hand to the model. */ withinBudget: boolean; } /** * The operations the MCP tools need. Implemented both in-process (the normal * single-process setup) and over HTTP (when attaching to a connector that is * already running for another client). */ export interface ConnectorClient { console(query: ScopedConsoleQuery): Promise>; network(query: ScopedNetworkQuery): Promise>; selectedElement(options?: { tabId?: TabId }): Promise; page(): Promise; status(): Promise; tabs(): Promise; wipe(options?: { tabId?: TabId }): Promise; screenshot(options: { name?: string; tabId?: TabId }): Promise; refresh(options?: { tabId?: TabId }): Promise; storage(kinds: string[], options?: { tabId?: TabId }): Promise>; audit( category: AuditCategory, options?: { url?: string; tabId?: TabId } ): Promise; exportConsole(options?: { tabId?: TabId; allTabs?: boolean }): Promise>; exportNetwork(options?: { tabId?: TabId; allTabs?: boolean }): Promise>; readArtifact(kind: "screenshot" | "audit", name: string): Promise; } /** Talks straight to an embedded connector — no network hop, no discovery. */ export class InProcessConnectorClient implements ConnectorClient { #connector: Connector; constructor(connector: Connector) { this.#connector = connector; } async console(query: ScopedConsoleQuery): Promise> { return this.#connector.queryConsole(query); } async network(query: ScopedNetworkQuery): Promise> { return this.#connector.queryNetwork(query); } async selectedElement(options: { tabId?: TabId } = {}): Promise { return this.#connector.getSelectedElement(options); } async page(): Promise { const tabs = this.#connector.listTabs(); const current = tabs.find((tab) => tab.isCurrent); const page = this.#connector.store.getCurrentPage(); return { url: current?.url || page.url, tabId: this.#connector.getCurrentTabId() ?? page.tabId, extensionConnected: this.#connector.hasExtension(), connectedTabs: tabs.length, }; } async status(): Promise { const tabs = this.#connector.listTabs(); return { version: "2.0.0", extensionConnected: this.#connector.hasExtension(), connections: tabs.length, tabs: tabs.length, currentTabId: this.#connector.getCurrentTabId(), screenshotDir: this.#connector.screenshotDir, counts: { console: this.#connector.store.queryConsole({}).total, network: this.#connector.store.queryNetwork({}).total, }, }; } async tabs(): Promise { const tabs = this.#connector.listTabs(); return { tabs, currentTabId: this.#connector.getCurrentTabId(), connectedTabs: tabs.length, }; } async wipe(options: { tabId?: TabId } = {}): Promise { this.#connector.store.wipe(options.tabId); } async screenshot(options: { name?: string; tabId?: TabId }): Promise { return this.#connector.captureScreenshot(options); } async refresh(options: { tabId?: TabId } = {}): Promise { return this.#connector.refreshTab(options); } async storage( kinds: string[], options: { tabId?: TabId } = {} ): Promise> { return this.#connector.readStorage(kinds, options); } async audit( category: AuditCategory, options: { url?: string; tabId?: TabId } = {} ): Promise { return this.#connector.runAudit(category, options); } async exportConsole( options: { tabId?: TabId; allTabs?: boolean } = {} ): Promise> { return this.#connector.exportConsole(options); } async exportNetwork( options: { tabId?: TabId; allTabs?: boolean } = {} ): Promise> { return this.#connector.exportNetwork(options); } async readArtifact(kind: "screenshot" | "audit", name: string): Promise { return this.#connector.readArtifact(kind, name); } } export interface HttpConnectorClientOptions { baseUrl: string; token: string; fetchImpl?: typeof fetch; } /** Talks to a connector running in another process. */ export class HttpConnectorClient implements ConnectorClient { #baseUrl: string; #token: string; #fetch: typeof fetch; constructor(options: HttpConnectorClientOptions) { this.#baseUrl = options.baseUrl.replace(/\/$/, ""); this.#token = options.token; this.#fetch = options.fetchImpl ?? fetch; } async #request( path: string, init: { method?: string; body?: unknown; query?: Record } = {} ): Promise { const url = new URL(`${this.#baseUrl}${path}`); for (const [key, value] of Object.entries(init.query ?? {})) { if (value === undefined || value === null) continue; if (Array.isArray(value)) { if (value.length) url.searchParams.set(key, value.join(",")); } else { url.searchParams.set(key, String(value)); } } const response = await this.#fetch(url, { method: init.method ?? "GET", headers: { Authorization: `Bearer ${this.#token}`, ...(init.body !== undefined ? { "Content-Type": "application/json" } : {}), }, ...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}), }); const text = await response.text(); const payload = text ? (JSON.parse(text) as Record) : {}; if (!response.ok) { const message = typeof payload["error"] === "string" ? payload["error"] : `Request failed (${response.status})`; throw new Error(message); } return payload as T; } console(query: ScopedConsoleQuery): Promise> { return this.#request("/api/console", { query: query as Record }); } network(query: ScopedNetworkQuery): Promise> { return this.#request("/api/network", { query: query as Record }); } async selectedElement(options: { tabId?: TabId } = {}): Promise { const result = await this.#request<{ element: unknown }>("/api/selected-element", { query: options as Record, }); return result.element; } tabs(): Promise { return this.#request("/api/tabs"); } page(): Promise { return this.#request("/api/page"); } status(): Promise { return this.#request("/api/status"); } async wipe(options: { tabId?: TabId } = {}): Promise { await this.#request("/api/wipe", { method: "POST", body: options }); } screenshot(options: { name?: string; tabId?: TabId }): Promise { return this.#request("/api/screenshot", { method: "POST", body: options }); } async refresh(options: { tabId?: TabId } = {}): Promise { await this.#request("/api/refresh", { method: "POST", body: options }); } async storage( kinds: string[], options: { tabId?: TabId } = {} ): Promise> { const result = await this.#request<{ storage: Record }>("/api/storage", { method: "POST", body: { kinds, ...options }, }); return result.storage; } audit( category: AuditCategory, options: { url?: string; tabId?: TabId } = {} ): Promise { return this.#request(`/api/audit/${category}`, { method: "POST", body: options }); } exportConsole( options: { tabId?: TabId; allTabs?: boolean } = {} ): Promise> { return this.#request("/api/export/console", { query: options as Record }); } exportNetwork( options: { tabId?: TabId; allTabs?: boolean } = {} ): Promise> { return this.#request("/api/export/network", { query: options as Record }); } readArtifact(kind: "screenshot" | "audit", name: string): Promise { return this.#request(`/api/artifact/${kind}/${encodeURIComponent(name)}`); } }