/** * Persistent MCP bridge server for chrome-devtools-axi. * * Selects either a local/proxy stdio session or a direct Streamable HTTP * session and maintains it persistently. Exposes a simple HTTP API: * POST /call { name, args } → { result } * GET /tools → [{ name, description }] * GET /health → { status: "ok", session } or 503 { status: "error", error } * GET /health?deep=1 → also verifies the attached CDP target; 503 may include reason, * 200 adds pageIdentityChanged when the probe consumed * chrome-devtools-mcp's one-shot reconnect marker *and* * that dropped a persisted page selection * * Writes a PID file to the active session's state dir on startup * (~/.chrome-devtools-axi/bridge.pid for the default session; named sessions * nest under sessions// - see src/sessions.ts). */ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import { type IncomingMessage, type Server, type ServerResponse } from "node:http"; import { BRIDGE_PORT_IN_USE_EXIT_CODE, PAGE_IDENTITY_CHANGED_ERROR, resolveBridgeScript } from "./bridge-script.js"; export { BRIDGE_PORT_IN_USE_EXIT_CODE, PAGE_IDENTITY_CHANGED_ERROR, resolveBridgeScript, }; export interface BridgeContentBlock { type: string; text?: string; } export interface BridgeCallPayload { name: string; args: Record; /** * Absolute directories the caller wants added to the MCP workspace roots for * this call, so file-writing tools may write there. See * {@link RootsAwareClient.applyRoots} and the roots negotiation in * `runBridge`. */ roots?: string[]; } interface BridgeToolDescription { name: string; description?: string; } export interface BridgeClient { listTools(): Promise<{ tools: BridgeToolDescription[]; }>; callTool(request: { name: string; arguments: Record; }, roots?: string[]): Promise; close(): Promise; /** * Negotiate the MCP workspace roots to the given absolute directories before * the next tool call, so a caller-supplied write path outside the OS temp * directory passes chrome-devtools-mcp's `validatePath`. Optional so test * fakes need not implement it; the real client always does. */ applyRoots?(dirs: string[]): Promise; } /** A {@link BridgeClient} that always negotiates MCP roots. */ export interface RootsAwareClient extends BridgeClient { applyRoots(dirs: string[]): Promise; } export declare function isBridgeClientConnected(client: BridgeClient): Promise; /** * Probe whether the bridge's underlying CDP target is reachable. Drives one * round-trip MCP tool call (`list_pages`) that requires a live browser/CDP * connection — `listTools()` alone only confirms the local MCP server is up, * not that the attached browser is still alive. Used by `/health?deep=1` so * `ensureBridge` can detect a stale bridge after the user kills + restarts * the underlying Chrome/Electron target. * * It also reports chrome-devtools-mcp's one-shot reconnect marker (see * {@link didMcpPageIdentityChange}) rather than only its own reachability: * `ensureBridge` deep-probes before every command, so after an in-process * browser reconnect this `list_pages` is the first call to see the marker and * consumes it, leaving none for the `/call` that follows. `handleBridgeRequest` * relays that to the CLI, which otherwise has no way to tell a selection the * reconnect just dropped from one that was never made. */ export declare function isBridgeTargetReachable(client: BridgeClient): Promise<{ ok: true; pageIdentityChanged: boolean; } | { ok: false; reason: string; }>; /** * Remove the session PID file, but only when this process owns it. On a * same-session bind race the losing bridge exits via EADDRINUSE after the * winning bridge has already written the shared PID file; an unconditional * unlink would delete the still-running winner's handle and orphan it (later * `stop`/reuse can no longer find it). A missing, unreadable, or malformed * file — or one recording a different pid — is left untouched. `ownerPid` is * injectable for tests. */ export declare function removePidFile(pidFile?: string, ownerPid?: number): void; export declare function getErrorMessage(error: unknown): string; /** * Extract the hostname from a `Host` header value, dropping any `:port` suffix. * Handles bracketed IPv6 (`[::1]:9224` -> `::1`) and bare IPv6 literals * (`::1`). Returns null for an empty/whitespace-only value. */ export declare function extractHostHeaderHostname(hostHeader: string): string | null; /** * True when the `Host` header is present and names the loopback interface. * A missing Host, or one naming any other host (e.g. a rebound * `evil.attacker.com`), is rejected. */ export declare function isAllowedBridgeHost(host: string | undefined): boolean; /** * True when the request carries no `Origin` (the CLI client sends none) or an * `Origin` whose hostname is loopback. A present-but-non-loopback or * unparseable Origin is rejected. */ export declare function isRequestOriginAllowed(req: IncomingMessage): boolean; /** * Anti-rebinding gate: a request is allowed only when its `Host` header names * loopback and any `Origin` header also names loopback. Checked FIRST on every * route (health included) so a rebound request is refused before any CDP tool * can run. */ export declare function isRequestAllowed(req: IncomingMessage): boolean; export declare function extractToolText(content: BridgeContentBlock[]): string; /** * Whether an MCP tool result signals failure (`isError: true`). chrome-devtools-mcp * reports recoverable tool failures - a denied file write, a bad selector, a * navigation error - as a *successful* JSON-RPC response carrying `isError`, not * as a protocol error. Treating that as success is how a rejected screenshot got * reported as written (issue #96); the bridge must surface it as a failure so the * CLI exits non-zero with the tool's own message. */ export declare function isToolResultError(result: unknown): boolean; /** * chrome-devtools-mcp keeps its stdio process alive when Chrome reconnects, * but deliberately reissues every page id. Its one-shot reconnect marker is * the authoritative identity boundary; the bridge must observe it before the * response is flattened or a persisted AXI selection can outlive the ids it * belongs to. */ export declare function didMcpPageIdentityChange(result: unknown, flattenedText?: string): boolean; export declare function parseBridgeCallPayload(body: string): BridgeCallPayload; export declare function handleBridgeRequest(client: BridgeClient, req: IncomingMessage, res: ServerResponse, sessionName?: string, logForbidden?: (message: string) => void, onPageIdentityChanged?: () => boolean | void): Promise; export declare function createBridgeServer(client: BridgeClient, sessionName?: string): Server; /** * Handle a fatal HTTP server error by logging it and exiting non-zero. An * EADDRINUSE means another bridge already owns this port (typically because * `CHROME_DEVTOOLS_AXI_PORT` was exported globally, forcing every session onto * one port); it exits with {@link BRIDGE_PORT_IN_USE_EXIT_CODE} so `ensureBridge` * can distinguish it from any other early death. Failing loudly prevents * `ensureBridge` from silently attaching to the other session's bridge. `exit` * is injectable for tests. */ export declare function handleBridgeServerError(error: NodeJS.ErrnoException, port: number, exit?: (code: number) => void): void; /** * Chrome flags that keep a browser *we* launch away from the machine owner's * login keychain. * * `--use-mock-keychain` makes Chromium's OSCrypt use an in-memory mock instead * of the real `Chrome Safe Storage` keychain item; `--password-store=basic` * keeps the password store off the platform secret service. Without them a * launched Chrome calls `SecItemAdd` against the login keychain, and if that * keychain is not resolvable for the browser process (for example because it * was spawned with a redirected `HOME`) macOS answers `errSecNoDefaultKeychain` * and raises the `system.keychain.create.loginkc` authorization panel - * "Keychain Not Found ... Reset To Defaults" - on the machine owner's screen. * * Puppeteer happens to pass both flags in its own default set today, so this is * currently belt-and-braces. It is stated explicitly because the isolation is a * property we owe our users, not one we want to silently inherit from an * upstream default that could change: an automation browser must never be able * to reach - or offer to reset - the owner's password store. */ export declare const KEYCHAIN_ISOLATION_CHROME_ARGS: readonly ["--use-mock-keychain", "--password-store=basic"]; export declare function buildTransportArgs(): string[]; /** * Probe interface for {@link detectGlobalMcpPath}. Defaults to real `node:fs` * + `npm prefix -g`; injectable for tests. */ export interface McpPathProbe { existsSync: (path: string) => boolean; getNpmPrefix: () => string | null; } /** * The command and arguments for a stdio-launched chrome-devtools-mcp process. * This stays separate from direct Streamable HTTP selection so callers cannot * accidentally spawn a local MCP process when only a shared URL is configured. */ export interface TransportSpec { command: string; args: string[]; } export interface SessionTerminatingTransport extends Transport { terminateSession(): Promise; } export type ResolvedTransport = { kind: "http"; url: URL; } | { kind: "stdio"; spec: TransportSpec; }; /** * A selected MCP transport plus the lifecycle operation that is unique to a * Streamable HTTP session. Stdio transports intentionally omit * `terminateSession`; closing them only tears down their local child process. */ export interface BridgeTransport { transport: Transport; terminateSession?: () => Promise; } export interface BridgeTransportFactories { createStdio: (spec: TransportSpec) => Transport; createHttp: (url: URL) => SessionTerminatingTransport; } /** * Auto-detect a globally-installed chrome-devtools-mcp by probing * both npm global package layouts: `/node_modules/...` (Windows) and * `/lib/node_modules/...` (POSIX). * * Returns the resolved path on success, or null if npm is unavailable or the * package isn't installed. Used by {@link resolveTransportSpec} only for local * mode when no explicit executable is configured. */ export declare function detectGlobalMcpPath(probe?: McpPathProbe): string | null; /** * Resolve the command + args used to spawn the chrome-devtools-mcp transport. * * This resolver intentionally handles only stdio paths. Shared URL + executable * mode is the stdio proxy path and must verify proxy support before spawning: * an incompatible MCP executable could otherwise start a separate local * browser. Local browser arguments are deliberately excluded because the * service owns Chrome's policy. See README Configuration for the supported * dependency and setup. * * For local mode, detecting a global install avoids npx bootstrap overhead, * which can exceed the bridge's readiness deadline on a slow or cold system. */ export declare function resolveTransportSpec(probe?: McpPathProbe): TransportSpec; /** * Select direct Streamable HTTP when only a shared URL is configured. * Supplying MCP_PATH opts into the verified stdio proxy path instead. */ export declare function resolveTransport(probe?: McpPathProbe): ResolvedTransport; /** * Construct the selected transport. Direct HTTP gets a session terminator; * stdio deliberately does not, so shutdown cannot issue a second termination. */ export declare function createTransport(selection?: ResolvedTransport, factories?: BridgeTransportFactories): BridgeTransport; /** * Close a bridge transport, terminating a direct remote MCP session first. * `Client.close()` delegates to its transport, so this helper closes the * transport exactly once without separately closing the Client. */ export declare function closeBridgeTransport(bridgeTransport: BridgeTransport): Promise; /** * Wrap an MCP {@link Client} so it answers `roots/list` and can renegotiate the * workspace roots on demand. Registers the `roots/list` handler that returns the * current roots, and exposes {@link RootsAwareClient.applyRoots}: it swaps in the * requested directories, fires `roots/list_changed`, and waits for the server to * re-read them (bounded) before returning, so the *next* tool call validates * against the new roots rather than the previous set. A no-op when the roots are * unchanged, so repeated calls in the same directory add no round-trips. */ export declare function createRootsAwareBridgeClient(client: Client): RootsAwareClient; export declare function runBridge(port?: number): Promise;