mirror of
https://github.com/hangwin/mcp-chrome.git
synced 2026-09-24 23:24:04 +08:00
feat(extension): shared CDP session manager; refactor dialog/computer/network tools
- Add utils/cdp-session-manager with refcount + owner tracking - Migrate dialog.ts to use session manager - Migrate computer.ts to use session manager - Migrate network-capture-debugger.ts to use session manager - Lint + vue-tsc clean; wxt build passes
This commit is contained in:
@@ -7,6 +7,7 @@ import { clickTool, fillTool } from './interaction';
|
||||
import { keyboardTool } from './keyboard';
|
||||
import { screenshotTool } from './screenshot';
|
||||
import { screenshotContextManager, scaleCoordinates } from '@/utils/screenshot-context';
|
||||
import { cdpSessionManager } from '@/utils/cdp-session-manager';
|
||||
|
||||
type MouseButton = 'left' | 'right' | 'middle';
|
||||
|
||||
@@ -48,49 +49,16 @@ interface ComputerParams {
|
||||
|
||||
// Minimal CDP helper encapsulated here to avoid scattering CDP code
|
||||
class CDPHelper {
|
||||
private static active = new Set<number>();
|
||||
|
||||
static async attach(tabId: number): Promise<void> {
|
||||
// If already attached by us, skip
|
||||
const targets = await chrome.debugger.getTargets();
|
||||
const existing = targets.find((t) => t.tabId === tabId && t.attached);
|
||||
if (existing) {
|
||||
if (existing.extensionId === chrome.runtime.id) {
|
||||
this.active.add(tabId);
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
`Debugger is already attached to tab ${tabId} by another tool (e.g., DevTools/extension)`,
|
||||
);
|
||||
}
|
||||
await chrome.debugger.attach({ tabId }, '1.3');
|
||||
this.active.add(tabId);
|
||||
await cdpSessionManager.attach(tabId, 'computer');
|
||||
}
|
||||
|
||||
static async detach(tabId: number): Promise<void> {
|
||||
if (!this.active.has(tabId)) return;
|
||||
try {
|
||||
await chrome.debugger.detach({ tabId });
|
||||
} finally {
|
||||
this.active.delete(tabId);
|
||||
}
|
||||
await cdpSessionManager.detach(tabId, 'computer');
|
||||
}
|
||||
|
||||
static async send(tabId: number, method: string, params?: object): Promise<any> {
|
||||
try {
|
||||
return await chrome.debugger.sendCommand({ tabId }, method, params);
|
||||
} catch (e: any) {
|
||||
// Try reattach once if lost
|
||||
if (
|
||||
String(e?.message || e)
|
||||
.toLowerCase()
|
||||
.includes('not attached')
|
||||
) {
|
||||
await this.attach(tabId);
|
||||
return await chrome.debugger.sendCommand({ tabId }, method, params);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
return await cdpSessionManager.sendCommand(tabId, method, params);
|
||||
}
|
||||
|
||||
static async dispatchMouseEvent(tabId: number, opts: any) {
|
||||
@@ -259,18 +227,14 @@ class ComputerTool extends BaseBrowserToolExecutor {
|
||||
// Prefer precise CDP emulation
|
||||
await CDPHelper.attach(tab.id);
|
||||
try {
|
||||
await chrome.debugger.sendCommand(
|
||||
{ tabId: tab.id },
|
||||
'Emulation.setDeviceMetricsOverride',
|
||||
{
|
||||
width: Math.round(w),
|
||||
height: Math.round(h),
|
||||
deviceScaleFactor: 0,
|
||||
mobile: false,
|
||||
screenWidth: Math.round(w),
|
||||
screenHeight: Math.round(h),
|
||||
},
|
||||
);
|
||||
await CDPHelper.send(tab.id, 'Emulation.setDeviceMetricsOverride', {
|
||||
width: Math.round(w),
|
||||
height: Math.round(h),
|
||||
deviceScaleFactor: 0,
|
||||
mobile: false,
|
||||
screenWidth: Math.round(w),
|
||||
screenHeight: Math.round(h),
|
||||
});
|
||||
} finally {
|
||||
await CDPHelper.detach(tab.id);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createErrorResponse, ToolResult } from '@/common/tool-handler';
|
||||
import { BaseBrowserToolExecutor } from '../base-browser';
|
||||
import { TOOL_NAMES } from 'chrome-mcp-shared';
|
||||
import { cdpSessionManager } from '@/utils/cdp-session-manager';
|
||||
|
||||
interface HandleDialogParams {
|
||||
action: 'accept' | 'dismiss';
|
||||
@@ -22,40 +23,16 @@ class HandleDialogTool extends BaseBrowserToolExecutor {
|
||||
try {
|
||||
const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!activeTab?.id) return createErrorResponse('No active tab found');
|
||||
const tabId = activeTab.id!;
|
||||
|
||||
// Attach debugger and try handling the dialog
|
||||
try {
|
||||
await chrome.debugger.attach({ tabId: activeTab.id }, '1.3');
|
||||
} catch (e: any) {
|
||||
if (String(e?.message || '').includes('attached')) {
|
||||
// If already attached by us, proceed; otherwise fail with clear message
|
||||
const targets = await chrome.debugger.getTargets();
|
||||
const existing = targets.find((t) => t.tabId === activeTab.id && t.attached);
|
||||
if (!existing || existing.extensionId !== chrome.runtime.id) {
|
||||
return createErrorResponse(
|
||||
`Debugger already attached to tab ${activeTab.id} by another client (e.g., DevTools). Close it and retry.`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Enable Page domain to be safe
|
||||
await chrome.debugger.sendCommand({ tabId: activeTab.id }, 'Page.enable');
|
||||
await chrome.debugger.sendCommand({ tabId: activeTab.id }, 'Page.handleJavaScriptDialog', {
|
||||
// Use shared CDP session manager for safe attach/detach with refcount
|
||||
await cdpSessionManager.withSession(tabId, 'dialog', async () => {
|
||||
await cdpSessionManager.sendCommand(tabId, 'Page.enable');
|
||||
await cdpSessionManager.sendCommand(tabId, 'Page.handleJavaScriptDialog', {
|
||||
accept: action === 'accept',
|
||||
promptText: action === 'accept' ? promptText : undefined,
|
||||
});
|
||||
} finally {
|
||||
// Best-effort detach if we were the owners
|
||||
try {
|
||||
await chrome.debugger.detach({ tabId: activeTab.id });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
|
||||
+27
-64
@@ -1,6 +1,7 @@
|
||||
import { createErrorResponse, ToolResult } from '@/common/tool-handler';
|
||||
import { BaseBrowserToolExecutor } from '../base-browser';
|
||||
import { TOOL_NAMES } from 'chrome-mcp-shared';
|
||||
import { cdpSessionManager } from '@/utils/cdp-session-manager';
|
||||
|
||||
interface NetworkDebuggerStartToolParams {
|
||||
url?: string; // URL to navigate to or focus. If not provided, uses active tab.
|
||||
@@ -218,35 +219,15 @@ class NetworkDebuggerStartTool extends BaseBrowserToolExecutor {
|
||||
// Get tab information
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
|
||||
// Check if debugger is already attached
|
||||
const targets = await chrome.debugger.getTargets();
|
||||
const existingTarget = targets.find(
|
||||
(t) => t.tabId === tabId && t.attached && t.type === 'page',
|
||||
);
|
||||
if (existingTarget && !existingTarget.extensionId) {
|
||||
throw new Error(
|
||||
`Debugger is already attached to tab ${tabId} by another tool (e.g., DevTools).`,
|
||||
);
|
||||
}
|
||||
|
||||
// Attach debugger
|
||||
try {
|
||||
await chrome.debugger.attach({ tabId }, DEBUGGER_PROTOCOL_VERSION);
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('Cannot attach to the target with an attached client')) {
|
||||
throw new Error(
|
||||
`Debugger is already attached to tab ${tabId}. This might be DevTools or another extension.`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
// Attach via shared manager (handles conflicts and refcount)
|
||||
await cdpSessionManager.attach(tabId, 'network-capture');
|
||||
|
||||
// Enable network tracking
|
||||
try {
|
||||
await chrome.debugger.sendCommand({ tabId }, 'Network.enable');
|
||||
await cdpSessionManager.sendCommand(tabId, 'Network.enable');
|
||||
} catch (error: any) {
|
||||
await chrome.debugger
|
||||
.detach({ tabId })
|
||||
await cdpSessionManager
|
||||
.detach(tabId, 'network-capture')
|
||||
.catch((e) => console.warn('Error detaching after failed enable:', e));
|
||||
throw error;
|
||||
}
|
||||
@@ -290,8 +271,8 @@ class NetworkDebuggerStartTool extends BaseBrowserToolExecutor {
|
||||
|
||||
// Clean up resources
|
||||
if (this.captureData.has(tabId)) {
|
||||
await chrome.debugger
|
||||
.detach({ tabId })
|
||||
await cdpSessionManager
|
||||
.detach(tabId, 'network-capture')
|
||||
.catch((e) => console.warn('Cleanup detach error:', e));
|
||||
this.cleanupCapture(tabId);
|
||||
}
|
||||
@@ -673,14 +654,8 @@ class NetworkDebuggerStartTool extends BaseBrowserToolExecutor {
|
||||
|
||||
const responseBodyPromise = (async () => {
|
||||
try {
|
||||
// Check if debugger is still attached to this tabId
|
||||
const attachedTabs = await chrome.debugger.getTargets();
|
||||
if (!attachedTabs.some((target) => target.tabId === tabId && target.attached)) {
|
||||
// console.warn(`NetworkDebuggerStartTool: Debugger not attached to tab ${tabId} when trying to get response body for ${requestId}.`);
|
||||
throw new Error(`Debugger not attached to tab ${tabId}`);
|
||||
}
|
||||
|
||||
const result = (await chrome.debugger.sendCommand({ tabId }, 'Network.getResponseBody', {
|
||||
// Will attach temporarily if needed
|
||||
const result = (await cdpSessionManager.sendCommand(tabId, 'Network.getResponseBody', {
|
||||
requestId,
|
||||
})) as { body: string; base64Encoded: boolean };
|
||||
return result;
|
||||
@@ -734,33 +709,21 @@ class NetworkDebuggerStartTool extends BaseBrowserToolExecutor {
|
||||
);
|
||||
|
||||
try {
|
||||
// Detach debugger first to prevent further events.
|
||||
// Check if debugger is attached before trying to send commands or detach
|
||||
const attachedTargets = await chrome.debugger.getTargets();
|
||||
const isAttached = attachedTargets.some(
|
||||
(target) => target.tabId === tabId && target.attached,
|
||||
);
|
||||
|
||||
if (isAttached) {
|
||||
try {
|
||||
await chrome.debugger.sendCommand({ tabId }, 'Network.disable');
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`NetworkDebuggerStartTool: Error disabling network for tab ${tabId} (possibly already detached):`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
try {
|
||||
await chrome.debugger.detach({ tabId });
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`NetworkDebuggerStartTool: Error detaching debugger for tab ${tabId} (possibly already detached):`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
`NetworkDebuggerStartTool: Debugger was not attached to tab ${tabId} at stopCapture.`,
|
||||
// Attempt to disable network and detach via manager; it will no-op if others own the session
|
||||
try {
|
||||
await cdpSessionManager.sendCommand(tabId, 'Network.disable');
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`NetworkDebuggerStartTool: Error disabling network for tab ${tabId} (possibly already detached):`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
try {
|
||||
await cdpSessionManager.detach(tabId, 'network-capture');
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`NetworkDebuggerStartTool: Error detaching debugger for tab ${tabId} (possibly already detached):`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
@@ -1003,8 +966,8 @@ class NetworkDebuggerStartTool extends BaseBrowserToolExecutor {
|
||||
// If a tabId was involved and debugger might be attached, try to clean up.
|
||||
const tabIdToClean = tabToOperateOn?.id;
|
||||
if (tabIdToClean && this.captureData.has(tabIdToClean)) {
|
||||
await chrome.debugger
|
||||
.detach({ tabId: tabIdToClean })
|
||||
await cdpSessionManager
|
||||
.detach(tabIdToClean, 'network-capture')
|
||||
.catch((e) => console.warn('Cleanup detach error:', e));
|
||||
this.cleanupCapture(tabIdToClean);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { TOOL_NAMES } from 'chrome-mcp-shared';
|
||||
|
||||
type OwnerTag = string;
|
||||
|
||||
interface TabSessionState {
|
||||
refCount: number;
|
||||
owners: Set<OwnerTag>;
|
||||
attachedByUs: boolean;
|
||||
}
|
||||
|
||||
const DEBUGGER_PROTOCOL_VERSION = '1.3';
|
||||
|
||||
class CDPSessionManager {
|
||||
private sessions = new Map<number, TabSessionState>();
|
||||
|
||||
private getState(tabId: number): TabSessionState | undefined {
|
||||
return this.sessions.get(tabId);
|
||||
}
|
||||
|
||||
private setState(tabId: number, state: TabSessionState) {
|
||||
this.sessions.set(tabId, state);
|
||||
}
|
||||
|
||||
async attach(tabId: number, owner: OwnerTag = 'unknown'): Promise<void> {
|
||||
const state = this.getState(tabId);
|
||||
if (state && state.attachedByUs) {
|
||||
state.refCount += 1;
|
||||
state.owners.add(owner);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check existing attachments
|
||||
const targets = await chrome.debugger.getTargets();
|
||||
const existing = targets.find((t) => t.tabId === tabId && t.attached);
|
||||
if (existing) {
|
||||
if (existing.extensionId === chrome.runtime.id) {
|
||||
// Already attached by us (e.g., previous tool). Adopt and refcount.
|
||||
this.setState(tabId, {
|
||||
refCount: state ? state.refCount + 1 : 1,
|
||||
owners: new Set([...(state?.owners || []), owner]),
|
||||
attachedByUs: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Another client (DevTools/other extension) is attached
|
||||
throw new Error(
|
||||
`Debugger is already attached to tab ${tabId} by another client (e.g., DevTools/extension)`,
|
||||
);
|
||||
}
|
||||
|
||||
// Attach freshly
|
||||
await chrome.debugger.attach({ tabId }, DEBUGGER_PROTOCOL_VERSION);
|
||||
this.setState(tabId, { refCount: 1, owners: new Set([owner]), attachedByUs: true });
|
||||
}
|
||||
|
||||
async detach(tabId: number, owner: OwnerTag = 'unknown'): Promise<void> {
|
||||
const state = this.getState(tabId);
|
||||
if (!state) return; // Nothing to do
|
||||
|
||||
// Update ownership/refcount
|
||||
if (state.owners.has(owner)) state.owners.delete(owner);
|
||||
state.refCount = Math.max(0, state.refCount - 1);
|
||||
|
||||
if (state.refCount > 0) {
|
||||
// Still in use by other owners
|
||||
return;
|
||||
}
|
||||
|
||||
// We are the last owner
|
||||
try {
|
||||
if (state.attachedByUs) {
|
||||
await chrome.debugger.detach({ tabId });
|
||||
}
|
||||
} catch (e) {
|
||||
// Best-effort detach; ignore
|
||||
} finally {
|
||||
this.sessions.delete(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper: ensures attach before fn, and balanced detach after.
|
||||
*/
|
||||
async withSession<T>(tabId: number, owner: OwnerTag, fn: () => Promise<T>): Promise<T> {
|
||||
await this.attach(tabId, owner);
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
await this.detach(tabId, owner);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a CDP command. Requires that this manager has attached to the tab.
|
||||
* If not attached by us, will attempt a one-shot attach around the call.
|
||||
*/
|
||||
async sendCommand<T = any>(tabId: number, method: string, params?: object): Promise<T> {
|
||||
const state = this.getState(tabId);
|
||||
if (state && state.attachedByUs) {
|
||||
return (await chrome.debugger.sendCommand({ tabId }, method, params)) as T;
|
||||
}
|
||||
// Fallback: temporary session
|
||||
return await this.withSession<T>(tabId, `send:${method}`, async () => {
|
||||
return (await chrome.debugger.sendCommand({ tabId }, method, params)) as T;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const cdpSessionManager = new CDPSessionManager();
|
||||
Reference in New Issue
Block a user