mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 22:35:51 +00:00
Replace the 6-line stub in @ant/claude-for-chrome-mcp with the complete implementation (8 files, 3038 lines) from the reference project. Provides 17 browser tools: navigate, screenshot, click, type, read DOM, execute JS, record GIF, monitor console/network, manage tabs, etc. No feature flag needed. No changes to src/ (already matches official). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1127 lines
36 KiB
TypeScript
1127 lines
36 KiB
TypeScript
/**
|
|
* WebSocket bridge client for the Chrome extension MCP server.
|
|
* Communicates with the Chrome extension via the office bridge server's /chrome path.
|
|
*/
|
|
|
|
import WebSocket from "ws";
|
|
|
|
import { SocketConnectionError } from "./mcpSocketClient.js";
|
|
import {
|
|
localPlatformLabel,
|
|
type BridgePermissionRequest,
|
|
type ChromeExtensionInfo,
|
|
type ClaudeForChromeContext,
|
|
type PermissionMode,
|
|
type PermissionOverrides,
|
|
type SocketClient,
|
|
} from "./types.js";
|
|
|
|
/** Timeout for list_extensions response from the bridge. */
|
|
const DISCOVERY_TIMEOUT_MS = 5000;
|
|
|
|
/** How long to wait for a peer_connected event when 0 extensions are found. */
|
|
const PEER_WAIT_TIMEOUT_MS = 10_000;
|
|
|
|
interface PendingToolCall {
|
|
resolve: (value: unknown) => void;
|
|
reject: (reason: Error) => void;
|
|
timer: NodeJS.Timeout;
|
|
results: unknown[];
|
|
isTabsContext: boolean;
|
|
onPermissionRequest?: (request: BridgePermissionRequest) => Promise<boolean>;
|
|
startTime: number;
|
|
toolName: string;
|
|
}
|
|
|
|
export class BridgeClient implements SocketClient {
|
|
private ws: WebSocket | null = null;
|
|
private connected = false;
|
|
private authenticated = false;
|
|
private connecting = false;
|
|
private reconnectTimer: NodeJS.Timeout | null = null;
|
|
private reconnectAttempts = 0;
|
|
private pendingCalls = new Map<string, PendingToolCall>();
|
|
private notificationHandler:
|
|
| ((notification: {
|
|
method: string;
|
|
params?: Record<string, unknown>;
|
|
}) => void)
|
|
| null = null;
|
|
private context: ClaudeForChromeContext;
|
|
private permissionMode: PermissionMode = "ask";
|
|
private allowedDomains: string[] | undefined;
|
|
private tabsContextCollectionTimeoutMs = 2000;
|
|
private toolCallTimeoutMs = 120_000;
|
|
private connectionStartTime: number | null = null;
|
|
private connectionEstablishedTime: number | null = null;
|
|
/** The device_id of the selected Chrome extension for targeted routing. */
|
|
private selectedDeviceId: string | undefined;
|
|
/** True after first discovery attempt completes (success or timeout). */
|
|
private discoveryComplete = false;
|
|
/** Shared promise so concurrent callTool invocations join the same discovery. */
|
|
private discoveryPromise: Promise<void> | null = null;
|
|
/** Pending discovery response from bridge. */
|
|
private pendingDiscovery: {
|
|
resolve: (extensions: ChromeExtensionInfo[]) => void;
|
|
timeout: NodeJS.Timeout;
|
|
} | null = null;
|
|
/** The device_id we had selected before a peer_disconnected — for auto-reselect. */
|
|
private previousSelectedDeviceId: string | undefined;
|
|
/** Callbacks waiting for the next peer_connected event. Receives `true` on peer arrival, `false` on abort. */
|
|
private peerConnectedWaiters: Array<(arrived: boolean) => void> = [];
|
|
/** The request_id of the current pending pairing broadcast. */
|
|
private pendingPairingRequestId: string | undefined;
|
|
/** True while a pairing broadcast is in flight and no response yet. */
|
|
private pairingInProgress = false;
|
|
/** The deviceId from a previous persisted pairing. */
|
|
private persistedDeviceId: string | undefined;
|
|
/** Resolve callback for a blocking switchBrowser() call. */
|
|
private pendingSwitchResolve:
|
|
| ((result: { deviceId: string; name: string } | null) => void)
|
|
| null = null;
|
|
|
|
constructor(context: ClaudeForChromeContext) {
|
|
this.context = context;
|
|
if (context.initialPermissionMode) {
|
|
this.permissionMode = context.initialPermissionMode;
|
|
}
|
|
}
|
|
|
|
public async ensureConnected(): Promise<boolean> {
|
|
const { logger, serverName } = this.context;
|
|
logger.info(
|
|
`[${serverName}] ensureConnected called, connected=${this.connected}, authenticated=${this.authenticated}, wsState=${this.ws?.readyState}`,
|
|
);
|
|
|
|
if (
|
|
this.connected &&
|
|
this.authenticated &&
|
|
this.ws?.readyState === WebSocket.OPEN
|
|
) {
|
|
logger.info(`[${serverName}] Already connected and authenticated`);
|
|
return true;
|
|
}
|
|
|
|
if (!this.connecting) {
|
|
logger.info(`[${serverName}] Not connecting, starting connection...`);
|
|
await this.connect();
|
|
} else {
|
|
logger.info(`[${serverName}] Already connecting, waiting...`);
|
|
}
|
|
|
|
// Wait for authentication with timeout
|
|
return new Promise((resolve) => {
|
|
const timeout = setTimeout(() => {
|
|
logger.info(
|
|
`[${serverName}] Connection timeout, connected=${this.connected}, authenticated=${this.authenticated}`,
|
|
);
|
|
resolve(false);
|
|
}, 10_000);
|
|
const check = () => {
|
|
if (this.connected && this.authenticated) {
|
|
logger.info(`[${serverName}] Connection successful`);
|
|
clearTimeout(timeout);
|
|
resolve(true);
|
|
} else if (!this.connecting) {
|
|
logger.info(`[${serverName}] No longer connecting, giving up`);
|
|
clearTimeout(timeout);
|
|
resolve(false);
|
|
} else {
|
|
setTimeout(check, 200);
|
|
}
|
|
};
|
|
check();
|
|
});
|
|
}
|
|
|
|
public async callTool(
|
|
name: string,
|
|
args: Record<string, unknown>,
|
|
permissionOverrides?: PermissionOverrides,
|
|
): Promise<unknown> {
|
|
const { logger, serverName, trackEvent } = this.context;
|
|
|
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
throw new SocketConnectionError(`[${serverName}] Bridge not connected`);
|
|
}
|
|
|
|
// Lazy discovery: run on first tool call if no extension selected yet.
|
|
// Use a shared promise so concurrent callers join the same discovery.
|
|
if (!this.selectedDeviceId && !this.discoveryComplete) {
|
|
this.discoveryPromise ??= this.discoverAndSelectExtension().finally(
|
|
() => {
|
|
this.discoveryPromise = null;
|
|
},
|
|
);
|
|
await this.discoveryPromise;
|
|
}
|
|
|
|
// TODO: Once all extensions support pairing, throw here for multi-extension
|
|
// cases where pairingInProgress is true. For now, let the bridge handle
|
|
// routing — it auto-routes to a single extension or returns an error for
|
|
// multiple extensions without a target_device_id.
|
|
|
|
const toolUseId = crypto.randomUUID();
|
|
const isTabsContext = name === "tabs_context_mcp";
|
|
const startTime = Date.now();
|
|
const timeoutMs = isTabsContext
|
|
? this.tabsContextCollectionTimeoutMs
|
|
: this.toolCallTimeoutMs;
|
|
|
|
// Track tool call start
|
|
trackEvent?.("chrome_bridge_tool_call_started", {
|
|
tool_name: name,
|
|
tool_use_id: toolUseId,
|
|
});
|
|
|
|
// Per-call overrides (from session context) take priority over
|
|
// instance values (from set_permission_mode on the singleton).
|
|
const effectivePermissionMode =
|
|
permissionOverrides?.permissionMode ?? this.permissionMode;
|
|
const effectiveAllowedDomains =
|
|
permissionOverrides?.allowedDomains ?? this.allowedDomains;
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
const pending = this.pendingCalls.get(toolUseId);
|
|
if (pending) {
|
|
this.pendingCalls.delete(toolUseId);
|
|
const durationMs = Date.now() - pending.startTime;
|
|
|
|
if (isTabsContext && pending.results.length > 0) {
|
|
// For tabs_context, resolve with collected results even on timeout
|
|
trackEvent?.("chrome_bridge_tool_call_completed", {
|
|
tool_name: name,
|
|
tool_use_id: toolUseId,
|
|
duration_ms: durationMs,
|
|
});
|
|
resolve(this.mergeTabsResults(pending.results));
|
|
} else {
|
|
logger.warn(
|
|
`[${serverName}] Tool call timeout: ${name} (${toolUseId.slice(0, 8)}) after ${durationMs}ms, pending calls: ${this.pendingCalls.size}`,
|
|
);
|
|
trackEvent?.("chrome_bridge_tool_call_timeout", {
|
|
tool_name: name,
|
|
tool_use_id: toolUseId,
|
|
duration_ms: durationMs,
|
|
timeout_ms: timeoutMs,
|
|
});
|
|
reject(
|
|
new SocketConnectionError(
|
|
`[${serverName}] Tool call timed out: ${name}`,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}, timeoutMs);
|
|
|
|
this.pendingCalls.set(toolUseId, {
|
|
resolve,
|
|
reject,
|
|
timer,
|
|
results: [],
|
|
isTabsContext,
|
|
onPermissionRequest: permissionOverrides?.onPermissionRequest,
|
|
startTime,
|
|
toolName: name,
|
|
});
|
|
|
|
const message: Record<string, unknown> = {
|
|
type: "tool_call",
|
|
tool_use_id: toolUseId,
|
|
client_type: this.context.clientTypeId,
|
|
tool: name,
|
|
args,
|
|
};
|
|
|
|
// Target the selected extension for routing
|
|
if (this.selectedDeviceId) {
|
|
message.target_device_id = this.selectedDeviceId;
|
|
}
|
|
|
|
// Only include permission fields when a value exists.
|
|
// Priority: per-call override (from session context) > instance value (from set_permission_mode).
|
|
if (effectivePermissionMode) {
|
|
message.permission_mode = effectivePermissionMode;
|
|
}
|
|
if (effectiveAllowedDomains?.length) {
|
|
message.allowed_domains = effectiveAllowedDomains;
|
|
}
|
|
if (permissionOverrides?.onPermissionRequest) {
|
|
message.handle_permission_prompts = true;
|
|
}
|
|
|
|
logger.debug(
|
|
`[${serverName}] Sending tool_call: ${name} (${toolUseId.slice(0, 8)})`,
|
|
);
|
|
this.ws!.send(JSON.stringify(message));
|
|
});
|
|
}
|
|
|
|
public isConnected(): boolean {
|
|
return (
|
|
this.connected &&
|
|
this.authenticated &&
|
|
this.ws?.readyState === WebSocket.OPEN
|
|
);
|
|
}
|
|
|
|
public disconnect(): void {
|
|
this.cleanup();
|
|
}
|
|
|
|
public setNotificationHandler(
|
|
handler: (notification: {
|
|
method: string;
|
|
params?: Record<string, unknown>;
|
|
}) => void,
|
|
): void {
|
|
this.notificationHandler = handler;
|
|
}
|
|
|
|
public async setPermissionMode(
|
|
mode: PermissionMode,
|
|
allowedDomains?: string[],
|
|
): Promise<void> {
|
|
this.permissionMode = mode;
|
|
this.allowedDomains = allowedDomains;
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Extension discovery and selection
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* Discover connected extensions and auto-select one, or broadcast a pairing request.
|
|
* Called lazily on the first tool call.
|
|
*/
|
|
private async discoverAndSelectExtension(): Promise<void> {
|
|
const { logger, serverName } = this.context;
|
|
|
|
this.persistedDeviceId ??= this.context.getPersistedDeviceId?.();
|
|
|
|
let extensions = await this.queryBridgeExtensions();
|
|
|
|
if (extensions.length === 0) {
|
|
logger.info(
|
|
`[${serverName}] No extensions connected, waiting up to ${PEER_WAIT_TIMEOUT_MS}ms for peer_connected`,
|
|
);
|
|
const peerArrived = await this.waitForPeerConnected(PEER_WAIT_TIMEOUT_MS);
|
|
if (peerArrived) {
|
|
extensions = await this.queryBridgeExtensions();
|
|
}
|
|
}
|
|
|
|
this.discoveryComplete = true;
|
|
|
|
if (extensions.length === 0) {
|
|
// Still nothing — callTool will throw a clear error
|
|
logger.info(`[${serverName}] No extensions found after waiting`);
|
|
return;
|
|
}
|
|
|
|
// Single extension: auto-select silently
|
|
if (extensions.length === 1) {
|
|
const ext = extensions[0]!;
|
|
if (!this.isLocalExtension(ext)) {
|
|
this.context.onRemoteExtensionWarning?.(ext);
|
|
}
|
|
this.selectExtension(ext.deviceId);
|
|
return;
|
|
}
|
|
|
|
// Multiple extensions: check for persisted selection
|
|
if (this.persistedDeviceId) {
|
|
const persisted = extensions.find(
|
|
(e) => e.deviceId === this.persistedDeviceId,
|
|
);
|
|
if (persisted) {
|
|
logger.info(
|
|
`[${serverName}] Auto-connecting to persisted extension: ${persisted.name || persisted.deviceId.slice(0, 8)}`,
|
|
);
|
|
this.selectExtension(persisted.deviceId);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Multiple extensions, no valid persisted selection: broadcast and fail fast
|
|
this.broadcastPairingRequest();
|
|
this.pairingInProgress = true;
|
|
}
|
|
|
|
/**
|
|
* Query the bridge for connected extensions. Returns empty array on timeout.
|
|
* Deduplicates by deviceId, keeping the most recent connection — the bridge
|
|
* may report stale duplicates (e.g. after a service worker restart).
|
|
*/
|
|
private async queryBridgeExtensions(): Promise<ChromeExtensionInfo[]> {
|
|
const raw: ChromeExtensionInfo[] = await new Promise((resolve) => {
|
|
const timeout = setTimeout(() => {
|
|
this.pendingDiscovery = null;
|
|
resolve([]);
|
|
}, DISCOVERY_TIMEOUT_MS);
|
|
|
|
this.pendingDiscovery = { resolve, timeout };
|
|
this.ws?.send(JSON.stringify({ type: "list_extensions" }));
|
|
});
|
|
|
|
const byDeviceId = new Map<string, ChromeExtensionInfo>();
|
|
for (const ext of raw) {
|
|
const existing = byDeviceId.get(ext.deviceId);
|
|
if (!existing || ext.connectedAt > existing.connectedAt) {
|
|
byDeviceId.set(ext.deviceId, ext);
|
|
}
|
|
}
|
|
return [...byDeviceId.values()];
|
|
}
|
|
|
|
/**
|
|
* Select an extension by device ID for per-message targeted routing.
|
|
*/
|
|
private selectExtension(deviceId: string): void {
|
|
const { logger, serverName } = this.context;
|
|
this.selectedDeviceId = deviceId;
|
|
this.previousSelectedDeviceId = undefined;
|
|
logger.info(
|
|
`[${serverName}] Selected Chrome extension: ${deviceId.slice(0, 8)}...`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Check if an extension might be on the same machine as this MCP client
|
|
* by comparing OS platform. Extensions can't provide a real hostname from
|
|
* the service worker sandbox, so platform is a weak heuristic. The profile
|
|
* email is the primary differentiator shown in the selection dialog.
|
|
*/
|
|
private isLocalExtension(ext: ChromeExtensionInfo): boolean {
|
|
if (!ext.osPlatform) return false;
|
|
return ext.osPlatform === localPlatformLabel();
|
|
}
|
|
|
|
/**
|
|
* Returns a promise that resolves to `true` when a peer_connected event
|
|
* fires, or `false` if the timeout elapses first.
|
|
*/
|
|
private waitForPeerConnected(timeoutMs: number): Promise<boolean> {
|
|
return new Promise((resolve) => {
|
|
const timer = setTimeout(() => {
|
|
this.peerConnectedWaiters = this.peerConnectedWaiters.filter(
|
|
(w) => w !== onPeer,
|
|
);
|
|
resolve(false);
|
|
}, timeoutMs);
|
|
|
|
const onPeer = (arrived: boolean) => {
|
|
clearTimeout(timer);
|
|
resolve(arrived);
|
|
};
|
|
|
|
this.peerConnectedWaiters.push(onPeer);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Broadcast a pairing request to all connected extensions.
|
|
* Non-blocking — the pairing_response handler will select the extension.
|
|
*/
|
|
private broadcastPairingRequest(): void {
|
|
const requestId = crypto.randomUUID();
|
|
this.pendingPairingRequestId = requestId;
|
|
this.ws?.send(
|
|
JSON.stringify({
|
|
type: "pairing_request",
|
|
request_id: requestId,
|
|
client_type: this.context.clientTypeId,
|
|
}),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Switch to a different browser. Broadcasts a pairing request and blocks
|
|
* until a response arrives or timeout (120s). Returns the paired extension
|
|
* info, or null on timeout.
|
|
*/
|
|
public async switchBrowser(): Promise<
|
|
| {
|
|
deviceId: string;
|
|
name: string;
|
|
}
|
|
| "no_other_browsers"
|
|
| null
|
|
> {
|
|
const extensions = await this.queryBridgeExtensions();
|
|
const currentDeviceId =
|
|
this.selectedDeviceId ?? this.previousSelectedDeviceId;
|
|
if (
|
|
extensions.length === 0 ||
|
|
(extensions.length === 1 &&
|
|
(!currentDeviceId || extensions[0]!.deviceId === currentDeviceId))
|
|
) {
|
|
return "no_other_browsers";
|
|
}
|
|
|
|
this.previousSelectedDeviceId = this.selectedDeviceId;
|
|
this.selectedDeviceId = undefined;
|
|
this.discoveryComplete = false;
|
|
this.pairingInProgress = false;
|
|
|
|
const requestId = crypto.randomUUID();
|
|
this.pendingPairingRequestId = requestId;
|
|
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
return null;
|
|
}
|
|
this.ws.send(
|
|
JSON.stringify({
|
|
type: "pairing_request",
|
|
request_id: requestId,
|
|
client_type: this.context.clientTypeId,
|
|
}),
|
|
);
|
|
|
|
// Resolve any previous pending switch so the caller doesn't hang forever
|
|
if (this.pendingSwitchResolve) {
|
|
this.pendingSwitchResolve(null);
|
|
}
|
|
|
|
// Block for switch_browser since user is actively engaged
|
|
return new Promise((resolve) => {
|
|
const timer = setTimeout(() => {
|
|
if (this.pendingPairingRequestId === requestId) {
|
|
this.pendingPairingRequestId = undefined;
|
|
}
|
|
this.pendingSwitchResolve = null;
|
|
resolve(null);
|
|
}, 120_000);
|
|
|
|
this.pendingSwitchResolve = (result) => {
|
|
clearTimeout(timer);
|
|
this.pendingSwitchResolve = null;
|
|
resolve(result);
|
|
};
|
|
});
|
|
}
|
|
|
|
private async connect(): Promise<void> {
|
|
const { logger, serverName, bridgeConfig, trackEvent } = this.context;
|
|
|
|
if (!bridgeConfig) {
|
|
logger.error(`[${serverName}] No bridge config provided`);
|
|
return;
|
|
}
|
|
|
|
if (this.connecting) {
|
|
return;
|
|
}
|
|
|
|
this.connecting = true;
|
|
this.authenticated = false;
|
|
this.connectionStartTime = Date.now();
|
|
this.closeSocket();
|
|
|
|
// Get user ID for the connection path
|
|
let userId: string;
|
|
let token: string | undefined;
|
|
|
|
if (bridgeConfig.devUserId) {
|
|
userId = bridgeConfig.devUserId;
|
|
logger.debug(`[${serverName}] Using dev user ID for bridge connection`);
|
|
} else {
|
|
logger.debug(`[${serverName}] Fetching user ID for bridge connection`);
|
|
const fetchedUserId = await bridgeConfig.getUserId();
|
|
if (!fetchedUserId) {
|
|
const durationMs = Date.now() - this.connectionStartTime;
|
|
logger.error(
|
|
`[${serverName}] No user ID available after ${durationMs}ms`,
|
|
);
|
|
trackEvent?.("chrome_bridge_connection_failed", {
|
|
duration_ms: durationMs,
|
|
error_type: "no_user_id",
|
|
reconnect_attempt: this.reconnectAttempts,
|
|
});
|
|
this.connecting = false;
|
|
this.context.onAuthenticationError?.();
|
|
return;
|
|
}
|
|
userId = fetchedUserId;
|
|
|
|
logger.debug(
|
|
`[${serverName}] Fetching OAuth token for bridge connection`,
|
|
);
|
|
token = await bridgeConfig.getOAuthToken();
|
|
if (!token) {
|
|
const durationMs = Date.now() - this.connectionStartTime;
|
|
logger.error(
|
|
`[${serverName}] No OAuth token available after ${durationMs}ms`,
|
|
);
|
|
trackEvent?.("chrome_bridge_connection_failed", {
|
|
duration_ms: durationMs,
|
|
error_type: "no_oauth_token",
|
|
reconnect_attempt: this.reconnectAttempts,
|
|
});
|
|
this.connecting = false;
|
|
this.context.onAuthenticationError?.();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Connect to user-specific endpoint: /chrome/<user_id>
|
|
const wsUrl = `${bridgeConfig.url}/chrome/${userId}`;
|
|
logger.info(`[${serverName}] Connecting to bridge: ${wsUrl}`);
|
|
|
|
// Track connection started
|
|
trackEvent?.("chrome_bridge_connection_started", {
|
|
bridge_url: wsUrl,
|
|
});
|
|
|
|
try {
|
|
this.ws = new WebSocket(wsUrl);
|
|
} catch (error) {
|
|
const durationMs = Date.now() - this.connectionStartTime;
|
|
logger.error(
|
|
`[${serverName}] Failed to create WebSocket after ${durationMs}ms:`,
|
|
error,
|
|
);
|
|
trackEvent?.("chrome_bridge_connection_failed", {
|
|
duration_ms: durationMs,
|
|
error_type: "websocket_error",
|
|
reconnect_attempt: this.reconnectAttempts,
|
|
});
|
|
this.connecting = false;
|
|
this.scheduleReconnect();
|
|
return;
|
|
}
|
|
|
|
this.ws.on("open", () => {
|
|
logger.info(
|
|
`[${serverName}] WebSocket connected, sending connect message`,
|
|
);
|
|
|
|
// First message must be connect (same format as office path)
|
|
const connectMessage: Record<string, unknown> = {
|
|
type: "connect",
|
|
client_type: this.context.clientTypeId,
|
|
};
|
|
|
|
if (bridgeConfig.devUserId) {
|
|
connectMessage.dev_user_id = bridgeConfig.devUserId;
|
|
} else {
|
|
connectMessage.oauth_token = token;
|
|
}
|
|
|
|
this.ws?.send(JSON.stringify(connectMessage));
|
|
});
|
|
|
|
this.ws.on("message", (data: WebSocket.Data) => {
|
|
try {
|
|
const message = JSON.parse(data.toString()) as Record<string, unknown>;
|
|
logger.debug(
|
|
`[${serverName}] Bridge received: ${JSON.stringify(message)}`,
|
|
);
|
|
this.handleMessage(message);
|
|
} catch (error) {
|
|
logger.error(`[${serverName}] Failed to parse bridge message:`, error);
|
|
}
|
|
});
|
|
|
|
this.ws.on("close", (code: number) => {
|
|
const durationSinceConnect = this.connectionEstablishedTime
|
|
? Date.now() - this.connectionEstablishedTime
|
|
: 0;
|
|
logger.info(
|
|
`[${serverName}] Bridge connection closed (code: ${code}, duration: ${durationSinceConnect}ms)`,
|
|
);
|
|
trackEvent?.("chrome_bridge_disconnected", {
|
|
close_code: code,
|
|
duration_since_connect_ms: durationSinceConnect,
|
|
reconnect_attempt: this.reconnectAttempts + 1,
|
|
});
|
|
this.connected = false;
|
|
this.authenticated = false;
|
|
this.connecting = false;
|
|
this.connectionEstablishedTime = null;
|
|
this.scheduleReconnect();
|
|
});
|
|
|
|
this.ws.on("error", (error: Error) => {
|
|
const durationMs = this.connectionStartTime
|
|
? Date.now() - this.connectionStartTime
|
|
: 0;
|
|
logger.error(
|
|
`[${serverName}] Bridge WebSocket error after ${durationMs}ms: ${error.message}`,
|
|
);
|
|
trackEvent?.("chrome_bridge_connection_failed", {
|
|
duration_ms: durationMs,
|
|
error_type: "websocket_error",
|
|
reconnect_attempt: this.reconnectAttempts,
|
|
});
|
|
this.connected = false;
|
|
this.authenticated = false;
|
|
this.connecting = false;
|
|
});
|
|
}
|
|
|
|
private handleMessage(message: Record<string, unknown>): void {
|
|
const { logger, serverName, trackEvent } = this.context;
|
|
|
|
switch (message.type) {
|
|
case "paired": {
|
|
const durationMs = this.connectionStartTime
|
|
? Date.now() - this.connectionStartTime
|
|
: 0;
|
|
logger.info(
|
|
`[${serverName}] Paired with Chrome extension (duration: ${durationMs}ms)`,
|
|
);
|
|
this.connected = true;
|
|
this.authenticated = true;
|
|
this.connecting = false;
|
|
this.reconnectAttempts = 0;
|
|
this.connectionEstablishedTime = Date.now();
|
|
trackEvent?.("chrome_bridge_connection_succeeded", {
|
|
duration_ms: durationMs,
|
|
status: "paired",
|
|
});
|
|
break;
|
|
}
|
|
|
|
case "waiting": {
|
|
const durationMs = this.connectionStartTime
|
|
? Date.now() - this.connectionStartTime
|
|
: 0;
|
|
logger.info(
|
|
`[${serverName}] Waiting for Chrome extension to connect (duration: ${durationMs}ms)`,
|
|
);
|
|
this.connected = true;
|
|
this.authenticated = true;
|
|
this.connecting = false;
|
|
this.reconnectAttempts = 0;
|
|
this.connectionEstablishedTime = Date.now();
|
|
trackEvent?.("chrome_bridge_connection_succeeded", {
|
|
duration_ms: durationMs,
|
|
status: "waiting",
|
|
});
|
|
break;
|
|
}
|
|
|
|
case "peer_connected":
|
|
logger.info(`[${serverName}] Chrome extension connected to bridge`);
|
|
trackEvent?.("chrome_bridge_peer_connected", null);
|
|
// If no extension selected, mark discovery as needed (next tool call will discover)
|
|
if (!this.selectedDeviceId) {
|
|
this.discoveryComplete = false;
|
|
}
|
|
// Auto-reselect if the previously selected extension reconnected (e.g., service worker restart)
|
|
if (
|
|
this.previousSelectedDeviceId &&
|
|
message.deviceId === this.previousSelectedDeviceId &&
|
|
!this.pendingSwitchResolve
|
|
) {
|
|
logger.info(
|
|
`[${serverName}] Previously selected extension reconnected, auto-reselecting`,
|
|
);
|
|
this.selectExtension(this.previousSelectedDeviceId);
|
|
this.previousSelectedDeviceId = undefined;
|
|
}
|
|
if (this.peerConnectedWaiters.length > 0) {
|
|
const waiters = this.peerConnectedWaiters;
|
|
this.peerConnectedWaiters = [];
|
|
for (const waiter of waiters) {
|
|
waiter(true);
|
|
}
|
|
}
|
|
break;
|
|
|
|
case "peer_disconnected":
|
|
logger.info(
|
|
`[${serverName}] Chrome extension disconnected from bridge`,
|
|
);
|
|
trackEvent?.("chrome_bridge_peer_disconnected", null);
|
|
// If the selected extension disconnected, clear selection for re-discovery
|
|
if (message.deviceId && message.deviceId === this.selectedDeviceId) {
|
|
logger.info(
|
|
`[${serverName}] Selected extension disconnected, clearing selection`,
|
|
);
|
|
this.previousSelectedDeviceId = this.selectedDeviceId;
|
|
this.selectedDeviceId = undefined;
|
|
this.discoveryComplete = false;
|
|
}
|
|
break;
|
|
|
|
case "extensions_list":
|
|
// Response to list_extensions — resolve pending discovery
|
|
if (this.pendingDiscovery) {
|
|
clearTimeout(this.pendingDiscovery.timeout);
|
|
this.pendingDiscovery.resolve(
|
|
(message.extensions as ChromeExtensionInfo[]) ?? [],
|
|
);
|
|
this.pendingDiscovery = null;
|
|
}
|
|
break;
|
|
|
|
case "pairing_response": {
|
|
const requestId = message.request_id as string;
|
|
const responseDeviceId = message.device_id as string;
|
|
const responseName = message.name as string;
|
|
if (
|
|
this.pendingPairingRequestId === requestId &&
|
|
responseDeviceId &&
|
|
responseName
|
|
) {
|
|
this.pendingPairingRequestId = undefined;
|
|
this.pairingInProgress = false;
|
|
this.selectExtension(responseDeviceId);
|
|
this.context.onExtensionPaired?.(responseDeviceId, responseName);
|
|
logger.info(
|
|
`[${serverName}] Paired with "${responseName}" (${responseDeviceId.slice(0, 8)})`,
|
|
);
|
|
if (this.pendingSwitchResolve) {
|
|
this.pendingSwitchResolve({
|
|
deviceId: responseDeviceId,
|
|
name: responseName,
|
|
});
|
|
this.pendingSwitchResolve = null;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "ping":
|
|
this.ws?.send(JSON.stringify({ type: "pong" }));
|
|
break;
|
|
|
|
case "pong":
|
|
// Response to our keepalive, nothing to do
|
|
break;
|
|
|
|
case "tool_result":
|
|
this.handleToolResult(message);
|
|
break;
|
|
|
|
case "permission_request":
|
|
void this.handlePermissionRequest(message);
|
|
break;
|
|
|
|
case "notification":
|
|
if (this.notificationHandler) {
|
|
this.notificationHandler({
|
|
method: message.method as string,
|
|
params: message.params as Record<string, unknown> | undefined,
|
|
});
|
|
}
|
|
break;
|
|
|
|
case "error":
|
|
logger.warn(`[${serverName}] Bridge error: ${message.error}`);
|
|
// If we had a selected extension, the error may indicate it's gone
|
|
// (e.g., extension disconnected between list and select). Clear state
|
|
// so the next tool call re-discovers.
|
|
if (this.selectedDeviceId) {
|
|
this.selectedDeviceId = undefined;
|
|
this.discoveryComplete = false;
|
|
}
|
|
break;
|
|
|
|
default:
|
|
logger.warn(
|
|
`[${serverName}] Unrecognized bridge message type: ${message.type}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
private async handlePermissionRequest(
|
|
message: Record<string, unknown>,
|
|
): Promise<void> {
|
|
const { logger, serverName } = this.context;
|
|
const toolUseId = message.tool_use_id as string;
|
|
const requestId = message.request_id as string;
|
|
|
|
if (!toolUseId || !requestId) {
|
|
logger.warn(
|
|
`[${serverName}] permission_request missing tool_use_id or request_id`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const pending = this.pendingCalls.get(toolUseId);
|
|
if (!pending?.onPermissionRequest) {
|
|
// Don't auto-deny — the bridge broadcasts permission_request to all
|
|
// connected MCP clients, and only the client that made the tool call
|
|
// has the pending entry. Auto-denying here would race with the correct
|
|
// client's handler when multiple Desktop instances are connected.
|
|
logger.debug(
|
|
`[${serverName}] Ignoring permission_request for unknown tool_use_id ${toolUseId.slice(0, 8)} (not our call)`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const request: BridgePermissionRequest = {
|
|
toolUseId,
|
|
requestId,
|
|
toolType: (message.tool_type as string) ?? "unknown",
|
|
url: (message.url as string) ?? "",
|
|
actionData: message.action_data as Record<string, unknown> | undefined,
|
|
};
|
|
|
|
try {
|
|
const allowed = await pending.onPermissionRequest(request);
|
|
this.sendPermissionResponse(requestId, allowed);
|
|
} catch (error) {
|
|
logger.error(`[${serverName}] Error handling permission request:`, error);
|
|
this.sendPermissionResponse(requestId, false);
|
|
}
|
|
}
|
|
|
|
private sendPermissionResponse(requestId: string, allowed: boolean): void {
|
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
const message: Record<string, unknown> = {
|
|
type: "permission_response",
|
|
request_id: requestId,
|
|
allowed,
|
|
};
|
|
if (this.selectedDeviceId) {
|
|
message.target_device_id = this.selectedDeviceId;
|
|
}
|
|
this.ws.send(JSON.stringify(message));
|
|
}
|
|
}
|
|
|
|
private handleToolResult(message: Record<string, unknown>): void {
|
|
const { logger, serverName, trackEvent } = this.context;
|
|
const toolUseId = message.tool_use_id as string;
|
|
if (!toolUseId) {
|
|
logger.warn(`[${serverName}] Received tool_result without tool_use_id`);
|
|
return;
|
|
}
|
|
|
|
const pending = this.pendingCalls.get(toolUseId);
|
|
if (!pending) {
|
|
logger.debug(
|
|
`[${serverName}] Received tool_result for unknown call: ${toolUseId.slice(0, 8)}`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const durationMs = Date.now() - pending.startTime;
|
|
|
|
// Normalize bridge response format to match socket client format.
|
|
// Bridge sends: { type, tool_use_id, content: [...], is_error?: boolean }
|
|
// Socket sends: { result: { content: [...] } } or { error: { content: [...] } }
|
|
const normalized = this.normalizeBridgeResponse(message);
|
|
const isError = Boolean(message.is_error) || "error" in normalized;
|
|
|
|
if (pending.isTabsContext && !this.selectedDeviceId) {
|
|
// No extension selected: collect results from all extensions (pre-selection / backward compat)
|
|
pending.results.push(normalized);
|
|
// Don't resolve yet — let the timer handle collection
|
|
} else {
|
|
// For other tools, resolve on first result
|
|
clearTimeout(pending.timer);
|
|
this.pendingCalls.delete(toolUseId);
|
|
|
|
if (isError) {
|
|
// Extract error message for telemetry
|
|
const errorContent = (normalized as { error?: { content?: unknown[] } })
|
|
.error?.content;
|
|
let errorMessage = "Unknown error";
|
|
if (Array.isArray(errorContent)) {
|
|
const textItem = errorContent.find(
|
|
(item) =>
|
|
typeof item === "object" && item !== null && "text" in item,
|
|
) as { text?: string } | undefined;
|
|
if (textItem?.text) {
|
|
errorMessage = textItem.text.slice(0, 200);
|
|
}
|
|
}
|
|
|
|
logger.warn(
|
|
`[${serverName}] Tool call error: ${pending.toolName} (${toolUseId.slice(0, 8)}) after ${durationMs}ms`,
|
|
);
|
|
trackEvent?.("chrome_bridge_tool_call_error", {
|
|
tool_name: pending.toolName,
|
|
tool_use_id: toolUseId,
|
|
duration_ms: durationMs,
|
|
error_message: errorMessage,
|
|
});
|
|
} else {
|
|
logger.debug(
|
|
`[${serverName}] Tool call completed: ${pending.toolName} (${toolUseId.slice(0, 8)}) in ${durationMs}ms`,
|
|
);
|
|
trackEvent?.("chrome_bridge_tool_call_completed", {
|
|
tool_name: pending.toolName,
|
|
tool_use_id: toolUseId,
|
|
duration_ms: durationMs,
|
|
});
|
|
}
|
|
|
|
pending.resolve(normalized);
|
|
}
|
|
}
|
|
|
|
private normalizeBridgeResponse(
|
|
message: Record<string, unknown>,
|
|
): Record<string, unknown> {
|
|
// Already has result/error wrapper (socket format) — pass through
|
|
if (message.result || message.error) {
|
|
return message;
|
|
}
|
|
|
|
// Bridge format has content at top level — wrap it
|
|
if (message.content) {
|
|
if (message.is_error) {
|
|
return { error: { content: message.content } };
|
|
}
|
|
return { result: { content: message.content } };
|
|
}
|
|
|
|
return message;
|
|
}
|
|
|
|
private mergeTabsResults(results: unknown[]): unknown {
|
|
const mergedTabs: unknown[] = [];
|
|
|
|
for (const result of results) {
|
|
const msg = result as Record<string, unknown>;
|
|
const resultData = msg.result as
|
|
| { content?: Array<{ type: string; text?: string }> }
|
|
| undefined;
|
|
const content = resultData?.content;
|
|
|
|
if (!content || !Array.isArray(content)) continue;
|
|
|
|
for (const item of content) {
|
|
if (item.type === "text" && item.text) {
|
|
try {
|
|
const parsed = JSON.parse(item.text);
|
|
if (Array.isArray(parsed)) {
|
|
mergedTabs.push(...parsed);
|
|
} else if (
|
|
parsed?.availableTabs &&
|
|
Array.isArray(parsed.availableTabs)
|
|
) {
|
|
mergedTabs.push(...parsed.availableTabs);
|
|
}
|
|
} catch {
|
|
// Not JSON, skip
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (mergedTabs.length > 0) {
|
|
const tabListText = mergedTabs
|
|
.map((t) => {
|
|
const tab = t as { tabId: number; title: string; url: string };
|
|
return ` \u2022 tabId ${tab.tabId}: "${tab.title}" (${tab.url})`;
|
|
})
|
|
.join("\n");
|
|
|
|
return {
|
|
result: {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: JSON.stringify({ availableTabs: mergedTabs }),
|
|
},
|
|
{
|
|
type: "text",
|
|
text: `\n\nTab Context:\n- Available tabs:\n${tabListText}`,
|
|
},
|
|
],
|
|
},
|
|
};
|
|
}
|
|
|
|
// Return first result as fallback
|
|
return results[0];
|
|
}
|
|
|
|
private scheduleReconnect(): void {
|
|
const { logger, serverName, trackEvent } = this.context;
|
|
|
|
if (this.reconnectTimer) return;
|
|
|
|
this.reconnectAttempts++;
|
|
|
|
if (this.reconnectAttempts > 100) {
|
|
logger.warn(
|
|
`[${serverName}] Giving up bridge reconnection after 100 attempts`,
|
|
);
|
|
trackEvent?.("chrome_bridge_reconnect_exhausted", {
|
|
total_attempts: 100,
|
|
});
|
|
this.reconnectAttempts = 0;
|
|
return;
|
|
}
|
|
|
|
const delay = Math.min(
|
|
2000 * Math.pow(1.5, this.reconnectAttempts - 1),
|
|
30_000,
|
|
);
|
|
|
|
if (this.reconnectAttempts <= 10 || this.reconnectAttempts % 10 === 0) {
|
|
logger.info(
|
|
`[${serverName}] Bridge reconnecting in ${Math.round(delay)}ms (attempt ${this.reconnectAttempts})`,
|
|
);
|
|
}
|
|
|
|
this.reconnectTimer = setTimeout(() => {
|
|
this.reconnectTimer = null;
|
|
void this.connect();
|
|
}, delay);
|
|
}
|
|
|
|
private closeSocket(): void {
|
|
if (this.ws) {
|
|
this.ws.removeAllListeners();
|
|
this.ws.close();
|
|
this.ws = null;
|
|
}
|
|
this.connected = false;
|
|
this.authenticated = false;
|
|
// Clear extension selection state so reconnections start fresh
|
|
this.selectedDeviceId = undefined;
|
|
this.discoveryComplete = false;
|
|
this.pendingPairingRequestId = undefined;
|
|
this.pairingInProgress = false;
|
|
if (this.pendingSwitchResolve) {
|
|
this.pendingSwitchResolve(null);
|
|
this.pendingSwitchResolve = null;
|
|
}
|
|
if (this.pendingDiscovery) {
|
|
clearTimeout(this.pendingDiscovery.timeout);
|
|
this.pendingDiscovery.resolve([]);
|
|
this.pendingDiscovery = null;
|
|
}
|
|
// Unblock any in-progress waitForPeerConnected so it doesn't hang until its timeout
|
|
if (this.peerConnectedWaiters.length > 0) {
|
|
const waiters = this.peerConnectedWaiters;
|
|
this.peerConnectedWaiters = [];
|
|
for (const waiter of waiters) {
|
|
waiter(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
private cleanup(): void {
|
|
if (this.reconnectTimer) {
|
|
clearTimeout(this.reconnectTimer);
|
|
this.reconnectTimer = null;
|
|
}
|
|
|
|
// Reject all pending calls
|
|
for (const [id, pending] of this.pendingCalls) {
|
|
clearTimeout(pending.timer);
|
|
pending.reject(new SocketConnectionError("Bridge client disconnected"));
|
|
this.pendingCalls.delete(id);
|
|
}
|
|
|
|
this.closeSocket();
|
|
this.reconnectAttempts = 0;
|
|
}
|
|
}
|
|
|
|
export function createBridgeClient(
|
|
context: ClaudeForChromeContext,
|
|
): BridgeClient {
|
|
return new BridgeClient(context);
|
|
}
|