mirror of
https://github.com/hangwin/mcp-chrome.git
synced 2026-09-21 12:43:18 +08:00
refactor(extension): migrate file-upload & console to shared CDP manager
This commit is contained in:
@@ -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';
|
||||
|
||||
const DEBUGGER_PROTOCOL_VERSION = '1.3';
|
||||
const DEFAULT_MAX_MESSAGES = 100;
|
||||
@@ -16,6 +17,7 @@ interface ConsoleMessage {
|
||||
level: string;
|
||||
text: string;
|
||||
args?: any[];
|
||||
argsSerialized?: any[];
|
||||
source?: string;
|
||||
url?: string;
|
||||
lineNumber?: number;
|
||||
@@ -177,28 +179,8 @@ class ConsoleTool 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
|
||||
await cdpSessionManager.attach(tabId, 'console');
|
||||
|
||||
// Set up event listener to collect messages
|
||||
const collectedMessages: any[] = [];
|
||||
@@ -235,15 +217,90 @@ class ConsoleTool extends BaseBrowserToolExecutor {
|
||||
|
||||
try {
|
||||
// Enable Runtime domain first to capture console API calls and exceptions
|
||||
await chrome.debugger.sendCommand({ tabId }, 'Runtime.enable');
|
||||
await cdpSessionManager.sendCommand(tabId, 'Runtime.enable');
|
||||
|
||||
// Also enable Log domain to capture other log entries
|
||||
await chrome.debugger.sendCommand({ tabId }, 'Log.enable');
|
||||
await cdpSessionManager.sendCommand(tabId, 'Log.enable');
|
||||
|
||||
// Wait for all messages to be flushed
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
// Process collected messages
|
||||
// Helper to deeply serialize console arguments when possible
|
||||
const serializeArg = async (arg: any): Promise<any> => {
|
||||
try {
|
||||
if (!arg) return arg;
|
||||
if (Object.prototype.hasOwnProperty.call(arg, 'unserializableValue')) {
|
||||
return arg.unserializableValue;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(arg, 'value')) {
|
||||
return arg.value;
|
||||
}
|
||||
if (arg.objectId) {
|
||||
const resp = (await cdpSessionManager.sendCommand(tabId, 'Runtime.callFunctionOn', {
|
||||
objectId: arg.objectId,
|
||||
functionDeclaration:
|
||||
'function(maxDepth, maxProps){\n' +
|
||||
' const seen=new WeakSet();\n' +
|
||||
' function S(v,d){\n' +
|
||||
' try{\n' +
|
||||
' if(d<0) return "[MaxDepth]";\n' +
|
||||
' if(v===null) return null;\n' +
|
||||
' const t=typeof v;\n' +
|
||||
' if(t!=="object"){\n' +
|
||||
' if(t==="bigint") return v.toString()+"n";\n' +
|
||||
' return v;\n' +
|
||||
' }\n' +
|
||||
' if(seen.has(v)) return "[Circular]";\n' +
|
||||
' seen.add(v);\n' +
|
||||
' if(Array.isArray(v)){\n' +
|
||||
' const out=[];\n' +
|
||||
' for(let i=0;i<v.length;i++){\n' +
|
||||
' if(i>=maxProps){ out.push("[...truncated]"); break; }\n' +
|
||||
' out.push(S(v[i], d-1));\n' +
|
||||
' }\n' +
|
||||
' return out;\n' +
|
||||
' }\n' +
|
||||
' if(v instanceof Date) return {__type:"Date", value:v.toISOString()};\n' +
|
||||
' if(v instanceof RegExp) return {__type:"RegExp", value:String(v)};\n' +
|
||||
' if(v instanceof Map){\n' +
|
||||
' const out={__type:"Map", entries:[]}; let c=0;\n' +
|
||||
' for(const [k,val] of v.entries()){\n' +
|
||||
' if(c++>=maxProps){ out.entries.push(["[...truncated]","[...truncated]"]); break; }\n' +
|
||||
' out.entries.push([S(k,d-1), S(val,d-1)]);\n' +
|
||||
' }\n' +
|
||||
' return out;\n' +
|
||||
' }\n' +
|
||||
' if(v instanceof Set){\n' +
|
||||
' const out={__type:"Set", values:[]}; let c=0;\n' +
|
||||
' for(const val of v.values()){\n' +
|
||||
' if(c++>=maxProps){ out.values.push("[...truncated]"); break; }\n' +
|
||||
' out.values.push(S(val,d-1));\n' +
|
||||
' }\n' +
|
||||
' return out;\n' +
|
||||
' }\n' +
|
||||
' const out={}; let c=0;\n' +
|
||||
' for(const key in v){\n' +
|
||||
' if(c++>=maxProps){ out.__truncated__=true; break; }\n' +
|
||||
' try{ out[key]=S(v[key], d-1); }catch(e){ out[key]="[Thrown]"; }\n' +
|
||||
' }\n' +
|
||||
' return out;\n' +
|
||||
' }catch(e){ return "[Unserializable]" }\n' +
|
||||
' }\n' +
|
||||
' return S(this, maxDepth);\n' +
|
||||
'}',
|
||||
arguments: [{ value: 3 }, { value: 100 }],
|
||||
silent: true,
|
||||
returnByValue: true,
|
||||
})) as any;
|
||||
return resp?.result?.value ?? '[Unavailable]';
|
||||
}
|
||||
return '[Unknown]';
|
||||
} catch (e) {
|
||||
return '[SerializeError]';
|
||||
}
|
||||
};
|
||||
|
||||
for (const entry of collectedMessages) {
|
||||
if (messages.length >= maxMessages) {
|
||||
limitReached = true;
|
||||
@@ -265,6 +322,12 @@ class ConsoleTool extends BaseBrowserToolExecutor {
|
||||
|
||||
if (entry.args && Array.isArray(entry.args)) {
|
||||
message.args = entry.args;
|
||||
// Attempt deep serialization for better fidelity
|
||||
const serialized: any[] = [];
|
||||
for (const a of entry.args) {
|
||||
serialized.push(await serializeArg(a));
|
||||
}
|
||||
message.argsSerialized = serialized;
|
||||
}
|
||||
|
||||
messages.push(message);
|
||||
@@ -294,19 +357,19 @@ class ConsoleTool extends BaseBrowserToolExecutor {
|
||||
chrome.debugger.onEvent.removeListener(eventListener);
|
||||
|
||||
try {
|
||||
await chrome.debugger.sendCommand({ tabId }, 'Runtime.disable');
|
||||
await cdpSessionManager.sendCommand(tabId, 'Runtime.disable');
|
||||
} catch (e) {
|
||||
console.warn(`ConsoleTool: Error disabling Runtime for tab ${tabId}:`, e);
|
||||
}
|
||||
|
||||
try {
|
||||
await chrome.debugger.sendCommand({ tabId }, 'Log.disable');
|
||||
await cdpSessionManager.sendCommand(tabId, 'Log.disable');
|
||||
} catch (e) {
|
||||
console.warn(`ConsoleTool: Error disabling Log for tab ${tabId}:`, e);
|
||||
}
|
||||
|
||||
try {
|
||||
await chrome.debugger.detach({ tabId });
|
||||
await cdpSessionManager.detach(tabId, 'console');
|
||||
} catch (e) {
|
||||
console.warn(`ConsoleTool: Error detaching debugger for tab ${tabId}:`, e);
|
||||
}
|
||||
|
||||
@@ -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 FileUploadToolParams {
|
||||
selector: string; // CSS selector for the file input element
|
||||
@@ -17,16 +18,8 @@ interface FileUploadToolParams {
|
||||
*/
|
||||
class FileUploadTool extends BaseBrowserToolExecutor {
|
||||
name = TOOL_NAMES.BROWSER.FILE_UPLOAD;
|
||||
private activeDebuggers: Map<number, boolean> = new Map();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
// Clean up debuggers on tab removal
|
||||
chrome.tabs.onRemoved.addListener((tabId) => {
|
||||
if (this.activeDebuggers.has(tabId)) {
|
||||
this.cleanupDebugger(tabId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,12 +36,10 @@ class FileUploadTool extends BaseBrowserToolExecutor {
|
||||
}
|
||||
|
||||
if (!filePath && !fileUrl && !base64Data) {
|
||||
return createErrorResponse(
|
||||
'One of filePath, fileUrl, or base64Data must be provided',
|
||||
);
|
||||
return createErrorResponse('One of filePath, fileUrl, or base64Data must be provided');
|
||||
}
|
||||
|
||||
let tabId: number | undefined;
|
||||
let tabId: number;
|
||||
|
||||
try {
|
||||
// Get current tab
|
||||
@@ -56,7 +47,7 @@ class FileUploadTool extends BaseBrowserToolExecutor {
|
||||
if (!tabs[0]?.id) {
|
||||
return createErrorResponse('No active tab found');
|
||||
}
|
||||
tabId = tabs[0].id;
|
||||
tabId = tabs[0].id!;
|
||||
|
||||
// Prepare file paths
|
||||
let files: string[] = [];
|
||||
@@ -78,75 +69,59 @@ class FileUploadTool extends BaseBrowserToolExecutor {
|
||||
files = [tempFilePath];
|
||||
}
|
||||
|
||||
// Attach debugger to the tab
|
||||
await this.attachDebugger(tabId);
|
||||
// Use shared CDP session manager to attach/do work/detach safely
|
||||
await cdpSessionManager.withSession(tabId, 'file-upload', async () => {
|
||||
// Enable necessary CDP domains
|
||||
await cdpSessionManager.sendCommand(tabId, 'DOM.enable', {});
|
||||
await cdpSessionManager.sendCommand(tabId, 'Runtime.enable', {});
|
||||
|
||||
// Enable necessary CDP domains
|
||||
await chrome.debugger.sendCommand({ tabId }, 'DOM.enable', {});
|
||||
await chrome.debugger.sendCommand({ tabId }, 'Runtime.enable', {});
|
||||
// Get the document
|
||||
const { root } = (await cdpSessionManager.sendCommand(tabId, 'DOM.getDocument', {
|
||||
depth: -1,
|
||||
pierce: true,
|
||||
})) as { root: { nodeId: number } };
|
||||
|
||||
// Get the document
|
||||
const { root } = await chrome.debugger.sendCommand(
|
||||
{ tabId },
|
||||
'DOM.getDocument',
|
||||
{ depth: -1, pierce: true },
|
||||
) as { root: { nodeId: number } };
|
||||
|
||||
// Find the file input element using the selector
|
||||
const { nodeId } = await chrome.debugger.sendCommand(
|
||||
{ tabId },
|
||||
'DOM.querySelector',
|
||||
{
|
||||
// Find the file input element using the selector
|
||||
const { nodeId } = (await cdpSessionManager.sendCommand(tabId, 'DOM.querySelector', {
|
||||
nodeId: root.nodeId,
|
||||
selector: selector,
|
||||
},
|
||||
) as { nodeId: number };
|
||||
})) as { nodeId: number };
|
||||
|
||||
if (!nodeId || nodeId === 0) {
|
||||
throw new Error(`Element with selector "${selector}" not found`);
|
||||
}
|
||||
|
||||
// Verify it's actually a file input
|
||||
const { node } = await chrome.debugger.sendCommand(
|
||||
{ tabId },
|
||||
'DOM.describeNode',
|
||||
{ nodeId },
|
||||
) as { node: { nodeName: string; attributes?: string[] } };
|
||||
|
||||
if (node.nodeName !== 'INPUT') {
|
||||
throw new Error(`Element with selector "${selector}" is not an input element`);
|
||||
}
|
||||
|
||||
// Check if it's a file input by looking for type="file" in attributes
|
||||
const attributes = node.attributes || [];
|
||||
let isFileInput = false;
|
||||
for (let i = 0; i < attributes.length; i += 2) {
|
||||
if (attributes[i] === 'type' && attributes[i + 1] === 'file') {
|
||||
isFileInput = true;
|
||||
break;
|
||||
if (!nodeId || nodeId === 0) {
|
||||
throw new Error(`Element with selector "${selector}" not found`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isFileInput) {
|
||||
throw new Error(`Element with selector "${selector}" is not a file input (type="file")`);
|
||||
}
|
||||
// Verify it's actually a file input
|
||||
const { node } = (await cdpSessionManager.sendCommand(tabId, 'DOM.describeNode', {
|
||||
nodeId,
|
||||
})) as { node: { nodeName: string; attributes?: string[] } };
|
||||
|
||||
// Set the files on the input element
|
||||
// This is the key CDP command that Playwright and Puppeteer use
|
||||
await chrome.debugger.sendCommand(
|
||||
{ tabId },
|
||||
'DOM.setFileInputFiles',
|
||||
{
|
||||
nodeId: nodeId,
|
||||
files: files,
|
||||
},
|
||||
);
|
||||
if (node.nodeName !== 'INPUT') {
|
||||
throw new Error(`Element with selector "${selector}" is not an input element`);
|
||||
}
|
||||
|
||||
// Trigger change event to ensure the page reacts to the file upload
|
||||
await chrome.debugger.sendCommand(
|
||||
{ tabId },
|
||||
'Runtime.evaluate',
|
||||
{
|
||||
// Check if it's a file input by looking for type="file" in attributes
|
||||
const attributes = node.attributes || [];
|
||||
let isFileInput = false;
|
||||
for (let i = 0; i < attributes.length; i += 2) {
|
||||
if (attributes[i] === 'type' && attributes[i + 1] === 'file') {
|
||||
isFileInput = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isFileInput) {
|
||||
throw new Error(`Element with selector "${selector}" is not a file input (type="file")`);
|
||||
}
|
||||
|
||||
// Set the files on the input element
|
||||
await cdpSessionManager.sendCommand(tabId, 'DOM.setFileInputFiles', {
|
||||
nodeId,
|
||||
files,
|
||||
});
|
||||
|
||||
// Trigger change event to ensure the page reacts to the file upload
|
||||
await cdpSessionManager.sendCommand(tabId, 'Runtime.evaluate', {
|
||||
expression: `
|
||||
(function() {
|
||||
const element = document.querySelector('${selector.replace(/'/g, "\\'")}');
|
||||
@@ -158,11 +133,8 @@ class FileUploadTool extends BaseBrowserToolExecutor {
|
||||
return false;
|
||||
})()
|
||||
`,
|
||||
},
|
||||
);
|
||||
|
||||
// Clean up debugger
|
||||
await this.detachDebugger(tabId);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
@@ -181,11 +153,8 @@ class FileUploadTool extends BaseBrowserToolExecutor {
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in file upload operation:', error);
|
||||
|
||||
// Clean up debugger if attached
|
||||
if (tabId !== undefined && this.activeDebuggers.has(tabId)) {
|
||||
await this.detachDebugger(tabId);
|
||||
}
|
||||
|
||||
// Session manager handles detach; nothing extra needed here
|
||||
|
||||
return createErrorResponse(
|
||||
`Error uploading file: ${error instanceof Error ? error.message : String(error)}`,
|
||||
@@ -193,58 +162,7 @@ class FileUploadTool extends BaseBrowserToolExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach debugger to a tab
|
||||
*/
|
||||
private async attachDebugger(tabId: number): Promise<void> {
|
||||
// Check if debugger is already attached
|
||||
const targets = await chrome.debugger.getTargets();
|
||||
const existingTarget = targets.find(
|
||||
(t) => t.tabId === tabId && t.attached,
|
||||
);
|
||||
|
||||
if (existingTarget) {
|
||||
if (existingTarget.extensionId === chrome.runtime.id) {
|
||||
// Our extension already attached
|
||||
console.log('Debugger already attached by this extension');
|
||||
return;
|
||||
} else {
|
||||
throw new Error(
|
||||
'Debugger is already attached to this tab by another extension or DevTools',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Attach debugger
|
||||
await chrome.debugger.attach({ tabId }, '1.3');
|
||||
this.activeDebuggers.set(tabId, true);
|
||||
console.log(`Debugger attached to tab ${tabId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach debugger from a tab
|
||||
*/
|
||||
private async detachDebugger(tabId: number): Promise<void> {
|
||||
if (!this.activeDebuggers.has(tabId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await chrome.debugger.detach({ tabId });
|
||||
console.log(`Debugger detached from tab ${tabId}`);
|
||||
} catch (error) {
|
||||
console.warn(`Error detaching debugger from tab ${tabId}:`, error);
|
||||
} finally {
|
||||
this.activeDebuggers.delete(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up debugger connection
|
||||
*/
|
||||
private cleanupDebugger(tabId: number): void {
|
||||
this.activeDebuggers.delete(tabId);
|
||||
}
|
||||
// All debugger attach/detach is centrally managed by cdpSessionManager
|
||||
|
||||
/**
|
||||
* Prepare file from URL or base64 data using native messaging host
|
||||
@@ -265,15 +183,20 @@ class FileUploadTool extends BaseBrowserToolExecutor {
|
||||
|
||||
// Create listener for the response
|
||||
const handleMessage = (message: any) => {
|
||||
if (message.type === 'file_operation_response' &&
|
||||
message.responseToRequestId === requestId) {
|
||||
if (
|
||||
message.type === 'file_operation_response' &&
|
||||
message.responseToRequestId === requestId
|
||||
) {
|
||||
clearTimeout(timeout);
|
||||
chrome.runtime.onMessage.removeListener(handleMessage);
|
||||
|
||||
|
||||
if (message.payload?.success && message.payload?.filePath) {
|
||||
resolve(message.payload.filePath);
|
||||
} else {
|
||||
console.error('Native host failed to prepare file:', message.error || message.payload?.error);
|
||||
console.error(
|
||||
'Native host failed to prepare file:',
|
||||
message.error || message.payload?.error,
|
||||
);
|
||||
resolve(null);
|
||||
}
|
||||
}
|
||||
@@ -283,26 +206,28 @@ class FileUploadTool extends BaseBrowserToolExecutor {
|
||||
chrome.runtime.onMessage.addListener(handleMessage);
|
||||
|
||||
// Send message to background script to forward to native host
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'forward_to_native',
|
||||
message: {
|
||||
type: 'file_operation',
|
||||
requestId: requestId,
|
||||
payload: {
|
||||
action: 'prepareFile',
|
||||
fileUrl,
|
||||
base64Data,
|
||||
fileName,
|
||||
chrome.runtime
|
||||
.sendMessage({
|
||||
type: 'forward_to_native',
|
||||
message: {
|
||||
type: 'file_operation',
|
||||
requestId: requestId,
|
||||
payload: {
|
||||
action: 'prepareFile',
|
||||
fileUrl,
|
||||
base64Data,
|
||||
fileName,
|
||||
},
|
||||
},
|
||||
},
|
||||
}).catch((error) => {
|
||||
console.error('Error sending message to background:', error);
|
||||
clearTimeout(timeout);
|
||||
chrome.runtime.onMessage.removeListener(handleMessage);
|
||||
resolve(null);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error sending message to background:', error);
|
||||
clearTimeout(timeout);
|
||||
chrome.runtime.onMessage.removeListener(handleMessage);
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const fileUploadTool = new FileUploadTool();
|
||||
export const fileUploadTool = new FileUploadTool();
|
||||
|
||||
Reference in New Issue
Block a user