first commit

This commit is contained in:
nasir@endelospay.com
2025-08-12 02:54:17 +05:00
commit d97cad1736
225 changed files with 137626 additions and 0 deletions

View File

@@ -0,0 +1,95 @@
import { ToolExecutor } from '@/common/tool-handler';
import type { ToolResult } from '@/common/tool-handler';
import { TIMEOUTS, ERROR_MESSAGES } from '@/common/constants';
const PING_TIMEOUT_MS = 300;
/**
* Base class for browser tool executors
*/
export abstract class BaseBrowserToolExecutor implements ToolExecutor {
abstract name: string;
abstract execute(args: any): Promise<ToolResult>;
/**
* Inject content script into tab
*/
protected async injectContentScript(
tabId: number,
files: string[],
injectImmediately = false,
world: 'MAIN' | 'ISOLATED' = 'ISOLATED',
): Promise<void> {
console.log(`Injecting ${files.join(', ')} into tab ${tabId}`);
// check if script is already injected
try {
const response = await Promise.race([
chrome.tabs.sendMessage(tabId, { action: `${this.name}_ping` }),
new Promise((_, reject) =>
setTimeout(
() => reject(new Error(`${this.name} Ping action to tab ${tabId} timed out`)),
PING_TIMEOUT_MS,
),
),
]);
if (response && response.status === 'pong') {
console.log(
`pong received for action '${this.name}' in tab ${tabId}. Assuming script is active.`,
);
return;
} else {
console.warn(`Unexpected ping response in tab ${tabId}:`, response);
}
} catch (error) {
console.error(
`ping content script failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
try {
await chrome.scripting.executeScript({
target: { tabId },
files,
injectImmediately,
world,
});
console.log(`'${files.join(', ')}' injection successful for tab ${tabId}`);
} catch (injectionError) {
const errorMessage =
injectionError instanceof Error ? injectionError.message : String(injectionError);
console.error(
`Content script '${files.join(', ')}' injection failed for tab ${tabId}: ${errorMessage}`,
);
throw new Error(
`${ERROR_MESSAGES.TOOL_EXECUTION_FAILED}: Failed to inject content script in tab ${tabId}: ${errorMessage}`,
);
}
}
/**
* Send message to tab
*/
protected async sendMessageToTab(tabId: number, message: any): Promise<any> {
try {
const response = await chrome.tabs.sendMessage(tabId, message);
if (response && response.error) {
throw new Error(String(response.error));
}
return response;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(
`Error sending message to tab ${tabId} for action ${message?.action || 'unknown'}: ${errorMessage}`,
);
if (error instanceof Error) {
throw error;
}
throw new Error(errorMessage);
}
}
}

View File

@@ -0,0 +1,602 @@
import { createErrorResponse, ToolResult } from '@/common/tool-handler';
import { BaseBrowserToolExecutor } from '../base-browser';
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { getMessage } from '@/utils/i18n';
/**
* Bookmark search tool parameters interface
*/
interface BookmarkSearchToolParams {
query?: string; // Search keywords for matching bookmark titles and URLs
maxResults?: number; // Maximum number of results to return
folderPath?: string; // Optional, specify which folder to search in (can be ID or path string like "Work/Projects")
}
/**
* Bookmark add tool parameters interface
*/
interface BookmarkAddToolParams {
url?: string; // URL to add as bookmark, if not provided use current active tab URL
title?: string; // Bookmark title, if not provided use page title
parentId?: string; // Parent folder ID or path string (like "Work/Projects"), if not provided add to "Bookmarks Bar" folder
createFolder?: boolean; // Whether to automatically create parent folder if it doesn't exist
}
/**
* Bookmark delete tool parameters interface
*/
interface BookmarkDeleteToolParams {
bookmarkId?: string; // ID of bookmark to delete
url?: string; // URL of bookmark to delete (if ID not provided, search by URL)
title?: string; // Title of bookmark to delete (used for auxiliary matching, used together with URL)
}
// --- Helper Functions ---
/**
* Get the complete folder path of a bookmark
* @param bookmarkNodeId ID of the bookmark or folder
* @returns Returns folder path string (e.g., "Bookmarks Bar > Folder A > Subfolder B")
*/
async function getBookmarkFolderPath(bookmarkNodeId: string): Promise<string> {
const pathParts: string[] = [];
try {
// First get the node itself to check if it's a bookmark or folder
const initialNodes = await chrome.bookmarks.get(bookmarkNodeId);
if (initialNodes.length > 0 && initialNodes[0]) {
const initialNode = initialNodes[0];
// Build path starting from parent node (same for both bookmarks and folders)
let pathNodeId = initialNode.parentId;
while (pathNodeId) {
const parentNodes = await chrome.bookmarks.get(pathNodeId);
if (parentNodes.length === 0) break;
const parentNode = parentNodes[0];
if (parentNode.title) {
pathParts.unshift(parentNode.title);
}
if (!parentNode.parentId) break;
pathNodeId = parentNode.parentId;
}
}
} catch (error) {
console.error(`Error getting bookmark path for node ID ${bookmarkNodeId}:`, error);
return pathParts.join(' > ') || 'Error getting path';
}
return pathParts.join(' > ');
}
/**
* Find bookmark folder by ID or path string
* If it's an ID, validate it
* If it's a path string, try to parse it
* @param pathOrId Path string (e.g., "Work/Projects") or folder ID
* @returns Returns folder node, or null if not found
*/
async function findFolderByPathOrId(
pathOrId: string,
): Promise<chrome.bookmarks.BookmarkTreeNode | null> {
try {
const nodes = await chrome.bookmarks.get(pathOrId);
if (nodes && nodes.length > 0 && !nodes[0].url) {
return nodes[0];
}
} catch (e) {
// do nothing, try to parse as path string
}
const pathParts = pathOrId
.split('/')
.map((p) => p.trim())
.filter((p) => p.length > 0);
if (pathParts.length === 0) return null;
const rootChildren = await chrome.bookmarks.getChildren('0');
let currentNodes = rootChildren;
let foundFolder: chrome.bookmarks.BookmarkTreeNode | null = null;
for (let i = 0; i < pathParts.length; i++) {
const part = pathParts[i];
foundFolder = null;
let matchedNodeThisLevel: chrome.bookmarks.BookmarkTreeNode | null = null;
for (const node of currentNodes) {
if (!node.url && node.title.toLowerCase() === part.toLowerCase()) {
matchedNodeThisLevel = node;
break;
}
}
if (matchedNodeThisLevel) {
if (i === pathParts.length - 1) {
foundFolder = matchedNodeThisLevel;
} else {
currentNodes = await chrome.bookmarks.getChildren(matchedNodeThisLevel.id);
}
} else {
return null;
}
}
return foundFolder;
}
/**
* Create folder path (if it doesn't exist)
* @param folderPath Folder path string (e.g., "Work/Projects/Subproject")
* @param parentId Optional parent folder ID, defaults to "Bookmarks Bar"
* @returns Returns the created or found final folder node
*/
async function createFolderPath(
folderPath: string,
parentId?: string,
): Promise<chrome.bookmarks.BookmarkTreeNode> {
const pathParts = folderPath
.split('/')
.map((p) => p.trim())
.filter((p) => p.length > 0);
if (pathParts.length === 0) {
throw new Error('Folder path cannot be empty');
}
// If no parent ID specified, use "Bookmarks Bar" folder
let currentParentId: string = parentId || '';
if (!currentParentId) {
const rootChildren = await chrome.bookmarks.getChildren('0');
// Find "Bookmarks Bar" folder (usually ID is '1', but search by title for compatibility)
const bookmarkBarFolder = rootChildren.find(
(node) =>
!node.url &&
(node.title === getMessage('bookmarksBarLabel') ||
node.title === 'Bookmarks bar' ||
node.title === 'Bookmarks Bar'),
);
currentParentId = bookmarkBarFolder?.id || '1'; // fallback to default ID
}
let currentFolder: chrome.bookmarks.BookmarkTreeNode | null = null;
// Create or find folders level by level
for (const folderName of pathParts) {
const children: chrome.bookmarks.BookmarkTreeNode[] =
await chrome.bookmarks.getChildren(currentParentId);
// Check if folder with same name already exists
const existingFolder: chrome.bookmarks.BookmarkTreeNode | undefined = children.find(
(child: chrome.bookmarks.BookmarkTreeNode) =>
!child.url && child.title.toLowerCase() === folderName.toLowerCase(),
);
if (existingFolder) {
currentFolder = existingFolder;
currentParentId = existingFolder.id;
} else {
// Create new folder
currentFolder = await chrome.bookmarks.create({
parentId: currentParentId,
title: folderName,
});
currentParentId = currentFolder.id;
}
}
if (!currentFolder) {
throw new Error('Failed to create folder path');
}
return currentFolder;
}
/**
* Flatten bookmark tree (or node array) to bookmark list (excluding folders)
* @param nodes Bookmark tree nodes to flatten
* @returns Returns actual bookmark node array (nodes with URLs)
*/
function flattenBookmarkNodesToBookmarks(
nodes: chrome.bookmarks.BookmarkTreeNode[],
): chrome.bookmarks.BookmarkTreeNode[] {
const result: chrome.bookmarks.BookmarkTreeNode[] = [];
const stack = [...nodes]; // Use stack for iterative traversal to avoid deep recursion issues
while (stack.length > 0) {
const node = stack.pop();
if (!node) continue;
if (node.url) {
// It's a bookmark
result.push(node);
}
if (node.children) {
// Add child nodes to stack for processing
for (let i = node.children.length - 1; i >= 0; i--) {
stack.push(node.children[i]);
}
}
}
return result;
}
/**
* Find bookmarks by URL and title
* @param url Bookmark URL
* @param title Optional bookmark title for auxiliary matching
* @returns Returns array of matching bookmarks
*/
async function findBookmarksByUrl(
url: string,
title?: string,
): Promise<chrome.bookmarks.BookmarkTreeNode[]> {
// Use Chrome API to search by URL
const searchResults = await chrome.bookmarks.search({ url });
if (!title) {
return searchResults;
}
// If title is provided, further filter results
const titleLower = title.toLowerCase();
return searchResults.filter(
(bookmark) => bookmark.title && bookmark.title.toLowerCase().includes(titleLower),
);
}
/**
* Bookmark search tool
* Used to search bookmarks in Chrome browser
*/
class BookmarkSearchTool extends BaseBrowserToolExecutor {
name = TOOL_NAMES.BROWSER.BOOKMARK_SEARCH;
/**
* Execute bookmark search
*/
async execute(args: BookmarkSearchToolParams): Promise<ToolResult> {
const { query = '', maxResults = 50, folderPath } = args;
console.log(
`BookmarkSearchTool: Searching bookmarks, keywords: "${query}", folder path: "${folderPath}"`,
);
try {
let bookmarksToSearch: chrome.bookmarks.BookmarkTreeNode[] = [];
let targetFolderNode: chrome.bookmarks.BookmarkTreeNode | null = null;
// If folder path is specified, find that folder first
if (folderPath) {
targetFolderNode = await findFolderByPathOrId(folderPath);
if (!targetFolderNode) {
return createErrorResponse(`Specified folder not found: "${folderPath}"`);
}
// Get all bookmarks in that folder and its subfolders
const subTree = await chrome.bookmarks.getSubTree(targetFolderNode.id);
bookmarksToSearch =
subTree.length > 0 ? flattenBookmarkNodesToBookmarks(subTree[0].children || []) : [];
}
let filteredBookmarks: chrome.bookmarks.BookmarkTreeNode[];
if (query) {
if (targetFolderNode) {
// Has query keywords and specified folder: manually filter bookmarks from folder
const lowerCaseQuery = query.toLowerCase();
filteredBookmarks = bookmarksToSearch.filter(
(bookmark) =>
(bookmark.title && bookmark.title.toLowerCase().includes(lowerCaseQuery)) ||
(bookmark.url && bookmark.url.toLowerCase().includes(lowerCaseQuery)),
);
} else {
// Has query keywords but no specified folder: use API search
filteredBookmarks = await chrome.bookmarks.search({ query });
// API search may return folders (if title matches), filter them out
filteredBookmarks = filteredBookmarks.filter((item) => !!item.url);
}
} else {
// No query keywords
if (!targetFolderNode) {
// No folder path specified, get all bookmarks
const tree = await chrome.bookmarks.getTree();
bookmarksToSearch = flattenBookmarkNodesToBookmarks(tree);
}
filteredBookmarks = bookmarksToSearch;
}
// Limit number of results
const limitedResults = filteredBookmarks.slice(0, maxResults);
// Add folder path information for each bookmark
const resultsWithPath = await Promise.all(
limitedResults.map(async (bookmark) => {
const path = await getBookmarkFolderPath(bookmark.id);
return {
id: bookmark.id,
title: bookmark.title,
url: bookmark.url,
dateAdded: bookmark.dateAdded,
folderPath: path,
};
}),
);
return {
content: [
{
type: 'text',
text: JSON.stringify(
{
success: true,
totalResults: resultsWithPath.length,
query: query || null,
folderSearched: targetFolderNode
? targetFolderNode.title || targetFolderNode.id
: 'All bookmarks',
bookmarks: resultsWithPath,
},
null,
2,
),
},
],
isError: false,
};
} catch (error) {
console.error('Error searching bookmarks:', error);
return createErrorResponse(
`Error searching bookmarks: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
/**
* Bookmark add tool
* Used to add new bookmarks to Chrome browser
*/
class BookmarkAddTool extends BaseBrowserToolExecutor {
name = TOOL_NAMES.BROWSER.BOOKMARK_ADD;
/**
* Execute add bookmark operation
*/
async execute(args: BookmarkAddToolParams): Promise<ToolResult> {
const { url, title, parentId, createFolder = false } = args;
console.log(`BookmarkAddTool: Adding bookmark, options:`, args);
try {
// If no URL provided, use current active tab
let bookmarkUrl = url;
let bookmarkTitle = title;
if (!bookmarkUrl) {
// Get current active tab
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tabs[0] || !tabs[0].url) {
// tab.url might be undefined (e.g., chrome:// pages)
return createErrorResponse('No active tab with valid URL found, and no URL provided');
}
bookmarkUrl = tabs[0].url;
if (!bookmarkTitle) {
bookmarkTitle = tabs[0].title || bookmarkUrl; // If tab title is empty, use URL as title
}
}
if (!bookmarkUrl) {
// Should have been caught above, but as a safety measure
return createErrorResponse('URL is required to create bookmark');
}
// Parse parentId (could be ID or path string)
let actualParentId: string | undefined = undefined;
if (parentId) {
let folderNode = await findFolderByPathOrId(parentId);
if (!folderNode && createFolder) {
// If folder doesn't exist and creation is allowed, create folder path
try {
folderNode = await createFolderPath(parentId);
} catch (createError) {
return createErrorResponse(
`Failed to create folder path: ${createError instanceof Error ? createError.message : String(createError)}`,
);
}
}
if (folderNode) {
actualParentId = folderNode.id;
} else {
// Check if parentId might be a direct ID missed by findFolderByPathOrId (e.g., root folder '1')
try {
const nodes = await chrome.bookmarks.get(parentId);
if (nodes && nodes.length > 0 && !nodes[0].url) {
actualParentId = nodes[0].id;
} else {
return createErrorResponse(
`Specified parent folder (ID/path: "${parentId}") not found or is not a folder${createFolder ? ', and creation failed' : '. You can set createFolder=true to auto-create folders'}`,
);
}
} catch (e) {
return createErrorResponse(
`Specified parent folder (ID/path: "${parentId}") not found or invalid${createFolder ? ', and creation failed' : '. You can set createFolder=true to auto-create folders'}`,
);
}
}
} else {
// If no parentId specified, default to "Bookmarks Bar"
const rootChildren = await chrome.bookmarks.getChildren('0');
const bookmarkBarFolder = rootChildren.find(
(node) =>
!node.url &&
(node.title === getMessage('bookmarksBarLabel') ||
node.title === 'Bookmarks bar' ||
node.title === 'Bookmarks Bar'),
);
actualParentId = bookmarkBarFolder?.id || '1'; // fallback to default ID
}
// If actualParentId is still undefined, chrome.bookmarks.create will use default "Other Bookmarks", but we've set Bookmarks Bar
// Create bookmark
const newBookmark = await chrome.bookmarks.create({
parentId: actualParentId, // If undefined, API uses default value
title: bookmarkTitle || bookmarkUrl, // Ensure title is never empty
url: bookmarkUrl,
});
// Get bookmark path
const path = await getBookmarkFolderPath(newBookmark.id);
return {
content: [
{
type: 'text',
text: JSON.stringify(
{
success: true,
message: 'Bookmark added successfully',
bookmark: {
id: newBookmark.id,
title: newBookmark.title,
url: newBookmark.url,
dateAdded: newBookmark.dateAdded,
folderPath: path,
},
folderCreated: createFolder && parentId ? 'Folder created if necessary' : false,
},
null,
2,
),
},
],
isError: false,
};
} catch (error) {
console.error('Error adding bookmark:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
// Provide more specific error messages for common error cases, such as trying to bookmark chrome:// URLs
if (errorMessage.includes("Can't bookmark URLs of type")) {
return createErrorResponse(
`Error adding bookmark: Cannot bookmark this type of URL (e.g., chrome:// system pages). ${errorMessage}`,
);
}
return createErrorResponse(`Error adding bookmark: ${errorMessage}`);
}
}
}
/**
* Bookmark delete tool
* Used to delete bookmarks in Chrome browser
*/
class BookmarkDeleteTool extends BaseBrowserToolExecutor {
name = TOOL_NAMES.BROWSER.BOOKMARK_DELETE;
/**
* Execute delete bookmark operation
*/
async execute(args: BookmarkDeleteToolParams): Promise<ToolResult> {
const { bookmarkId, url, title } = args;
console.log(`BookmarkDeleteTool: Deleting bookmark, options:`, args);
if (!bookmarkId && !url) {
return createErrorResponse('Must provide bookmark ID or URL to delete bookmark');
}
try {
let bookmarksToDelete: chrome.bookmarks.BookmarkTreeNode[] = [];
if (bookmarkId) {
// Delete by ID
try {
const nodes = await chrome.bookmarks.get(bookmarkId);
if (nodes && nodes.length > 0 && nodes[0].url) {
bookmarksToDelete = nodes;
} else {
return createErrorResponse(
`Bookmark with ID "${bookmarkId}" not found, or the ID does not correspond to a bookmark`,
);
}
} catch (error) {
return createErrorResponse(`Invalid bookmark ID: "${bookmarkId}"`);
}
} else if (url) {
// Delete by URL
bookmarksToDelete = await findBookmarksByUrl(url, title);
if (bookmarksToDelete.length === 0) {
return createErrorResponse(
`No bookmark found with URL "${url}"${title ? ` (title contains: "${title}")` : ''}`,
);
}
}
// Delete found bookmarks
const deletedBookmarks = [];
const errors = [];
for (const bookmark of bookmarksToDelete) {
try {
// Get path information before deletion
const path = await getBookmarkFolderPath(bookmark.id);
await chrome.bookmarks.remove(bookmark.id);
deletedBookmarks.push({
id: bookmark.id,
title: bookmark.title,
url: bookmark.url,
folderPath: path,
});
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
errors.push(
`Failed to delete bookmark "${bookmark.title}" (ID: ${bookmark.id}): ${errorMsg}`,
);
}
}
if (deletedBookmarks.length === 0) {
return createErrorResponse(`Failed to delete bookmarks: ${errors.join('; ')}`);
}
const result: any = {
success: true,
message: `Successfully deleted ${deletedBookmarks.length} bookmark(s)`,
deletedBookmarks,
};
if (errors.length > 0) {
result.partialSuccess = true;
result.errors = errors;
}
return {
content: [
{
type: 'text',
text: JSON.stringify(result, null, 2),
},
],
isError: false,
};
} catch (error) {
console.error('Error deleting bookmark:', error);
return createErrorResponse(
`Error deleting bookmark: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
export const bookmarkSearchTool = new BookmarkSearchTool();
export const bookmarkAddTool = new BookmarkAddTool();
export const bookmarkDeleteTool = new BookmarkDeleteTool();

View File

@@ -0,0 +1,478 @@
import { createErrorResponse, ToolResult } from '@/common/tool-handler';
import { BaseBrowserToolExecutor } from '../base-browser';
import { TOOL_NAMES } from 'chrome-mcp-shared';
// Default window dimensions
const DEFAULT_WINDOW_WIDTH = 1280;
const DEFAULT_WINDOW_HEIGHT = 720;
interface NavigateToolParams {
url?: string;
newWindow?: boolean;
width?: number;
height?: number;
refresh?: boolean;
}
/**
* Tool for navigating to URLs in browser tabs or windows
*/
class NavigateTool extends BaseBrowserToolExecutor {
name = TOOL_NAMES.BROWSER.NAVIGATE;
async execute(args: NavigateToolParams): Promise<ToolResult> {
const { newWindow = false, width, height, url, refresh = false } = args;
console.log(
`Attempting to ${refresh ? 'refresh current tab' : `open URL: ${url}`} with options:`,
args,
);
try {
// Handle refresh option first
if (refresh) {
console.log('Refreshing current active tab');
// Get current active tab
const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!activeTab || !activeTab.id) {
return createErrorResponse('No active tab found to refresh');
}
// Reload the tab
await chrome.tabs.reload(activeTab.id);
console.log(`Refreshed tab ID: ${activeTab.id}`);
// Get updated tab information
const updatedTab = await chrome.tabs.get(activeTab.id);
return {
content: [
{
type: 'text',
text: JSON.stringify({
success: true,
message: 'Successfully refreshed current tab',
tabId: updatedTab.id,
windowId: updatedTab.windowId,
url: updatedTab.url,
}),
},
],
isError: false,
};
}
// Validate that url is provided when not refreshing
if (!url) {
return createErrorResponse('URL parameter is required when refresh is not true');
}
// 1. Check if URL is already open
// Get all tabs and manually compare URLs
console.log(`Checking if URL is already open: ${url}`);
// Get all tabs
const allTabs = await chrome.tabs.query({});
// Manually filter matching tabs
const tabs = allTabs.filter((tab) => {
// Normalize URLs for comparison (remove trailing slashes)
const tabUrl = tab.url?.endsWith('/') ? tab.url.slice(0, -1) : tab.url;
const targetUrl = url.endsWith('/') ? url.slice(0, -1) : url;
return tabUrl === targetUrl;
});
console.log(`Found ${tabs.length} matching tabs`);
if (tabs && tabs.length > 0) {
const existingTab = tabs[0];
console.log(
`URL already open in Tab ID: ${existingTab.id}, Window ID: ${existingTab.windowId}`,
);
if (existingTab.id !== undefined) {
// Activate the tab
await chrome.tabs.update(existingTab.id, { active: true });
if (existingTab.windowId !== undefined) {
// Bring the window containing this tab to the foreground and focus it
await chrome.windows.update(existingTab.windowId, { focused: true });
}
console.log(`Activated existing Tab ID: ${existingTab.id}`);
// Get updated tab information and return it
const updatedTab = await chrome.tabs.get(existingTab.id);
return {
content: [
{
type: 'text',
text: JSON.stringify({
success: true,
message: 'Activated existing tab',
tabId: updatedTab.id,
windowId: updatedTab.windowId,
url: updatedTab.url,
}),
},
],
isError: false,
};
}
}
// 2. If URL is not already open, decide how to open it based on options
const openInNewWindow = newWindow || typeof width === 'number' || typeof height === 'number';
if (openInNewWindow) {
console.log('Opening URL in a new window.');
// Create new window
const newWindow = await chrome.windows.create({
url: url,
width: typeof width === 'number' ? width : DEFAULT_WINDOW_WIDTH,
height: typeof height === 'number' ? height : DEFAULT_WINDOW_HEIGHT,
focused: true,
});
if (newWindow && newWindow.id !== undefined) {
console.log(`URL opened in new Window ID: ${newWindow.id}`);
return {
content: [
{
type: 'text',
text: JSON.stringify({
success: true,
message: 'Opened URL in new window',
windowId: newWindow.id,
tabs: newWindow.tabs
? newWindow.tabs.map((tab) => ({
tabId: tab.id,
url: tab.url,
}))
: [],
}),
},
],
isError: false,
};
}
} else {
console.log('Opening URL in the last active window.');
// Try to open a new tab in the most recently active window
const lastFocusedWindow = await chrome.windows.getLastFocused({ populate: false });
if (lastFocusedWindow && lastFocusedWindow.id !== undefined) {
console.log(`Found last focused Window ID: ${lastFocusedWindow.id}`);
const newTab = await chrome.tabs.create({
url: url,
windowId: lastFocusedWindow.id,
active: true,
});
// Ensure the window also gets focus
await chrome.windows.update(lastFocusedWindow.id, { focused: true });
console.log(
`URL opened in new Tab ID: ${newTab.id} in existing Window ID: ${lastFocusedWindow.id}`,
);
return {
content: [
{
type: 'text',
text: JSON.stringify({
success: true,
message: 'Opened URL in new tab in existing window',
tabId: newTab.id,
windowId: lastFocusedWindow.id,
url: newTab.url,
}),
},
],
isError: false,
};
} else {
// In rare cases, if there's no recently active window (e.g., browser just started with no windows)
// Fall back to opening in a new window
console.warn('No last focused window found, falling back to creating a new window.');
const fallbackWindow = await chrome.windows.create({
url: url,
width: DEFAULT_WINDOW_WIDTH,
height: DEFAULT_WINDOW_HEIGHT,
focused: true,
});
if (fallbackWindow && fallbackWindow.id !== undefined) {
console.log(`URL opened in fallback new Window ID: ${fallbackWindow.id}`);
return {
content: [
{
type: 'text',
text: JSON.stringify({
success: true,
message: 'Opened URL in new window',
windowId: fallbackWindow.id,
tabs: fallbackWindow.tabs
? fallbackWindow.tabs.map((tab) => ({
tabId: tab.id,
url: tab.url,
}))
: [],
}),
},
],
isError: false,
};
}
}
}
// If all attempts fail, return a generic error
return createErrorResponse('Failed to open URL: Unknown error occurred');
} catch (error) {
if (chrome.runtime.lastError) {
console.error(`Chrome API Error: ${chrome.runtime.lastError.message}`, error);
return createErrorResponse(`Chrome API Error: ${chrome.runtime.lastError.message}`);
} else {
console.error('Error in navigate:', error);
return createErrorResponse(
`Error navigating to URL: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
}
export const navigateTool = new NavigateTool();
interface CloseTabsToolParams {
tabIds?: number[];
url?: string;
}
/**
* Tool for closing browser tabs
*/
class CloseTabsTool extends BaseBrowserToolExecutor {
name = TOOL_NAMES.BROWSER.CLOSE_TABS;
async execute(args: CloseTabsToolParams): Promise<ToolResult> {
const { tabIds, url } = args;
let urlPattern = url;
console.log(`Attempting to close tabs with options:`, args);
try {
// If URL is provided, close all tabs matching that URL
if (urlPattern) {
console.log(`Searching for tabs with URL: ${url}`);
if (!urlPattern.endsWith('/')) {
urlPattern += '/*';
}
const tabs = await chrome.tabs.query({ url });
if (!tabs || tabs.length === 0) {
console.log(`No tabs found with URL: ${url}`);
return {
content: [
{
type: 'text',
text: JSON.stringify({
success: false,
message: `No tabs found with URL: ${url}`,
closedCount: 0,
}),
},
],
isError: false,
};
}
console.log(`Found ${tabs.length} tabs with URL: ${url}`);
const tabIdsToClose = tabs
.map((tab) => tab.id)
.filter((id): id is number => id !== undefined);
if (tabIdsToClose.length === 0) {
return createErrorResponse('Found tabs but could not get their IDs');
}
await chrome.tabs.remove(tabIdsToClose);
return {
content: [
{
type: 'text',
text: JSON.stringify({
success: true,
message: `Closed ${tabIdsToClose.length} tabs with URL: ${url}`,
closedCount: tabIdsToClose.length,
closedTabIds: tabIdsToClose,
}),
},
],
isError: false,
};
}
// If tabIds are provided, close those tabs
if (tabIds && tabIds.length > 0) {
console.log(`Closing tabs with IDs: ${tabIds.join(', ')}`);
// Verify that all tabIds exist
const existingTabs = await Promise.all(
tabIds.map(async (tabId) => {
try {
return await chrome.tabs.get(tabId);
} catch (error) {
console.warn(`Tab with ID ${tabId} not found`);
return null;
}
}),
);
const validTabIds = existingTabs
.filter((tab): tab is chrome.tabs.Tab => tab !== null)
.map((tab) => tab.id)
.filter((id): id is number => id !== undefined);
if (validTabIds.length === 0) {
return {
content: [
{
type: 'text',
text: JSON.stringify({
success: false,
message: 'None of the provided tab IDs exist',
closedCount: 0,
}),
},
],
isError: false,
};
}
await chrome.tabs.remove(validTabIds);
return {
content: [
{
type: 'text',
text: JSON.stringify({
success: true,
message: `Closed ${validTabIds.length} tabs`,
closedCount: validTabIds.length,
closedTabIds: validTabIds,
invalidTabIds: tabIds.filter((id) => !validTabIds.includes(id)),
}),
},
],
isError: false,
};
}
// If no tabIds or URL provided, close the current active tab
console.log('No tabIds or URL provided, closing active tab');
const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!activeTab || !activeTab.id) {
return createErrorResponse('No active tab found');
}
await chrome.tabs.remove(activeTab.id);
return {
content: [
{
type: 'text',
text: JSON.stringify({
success: true,
message: 'Closed active tab',
closedCount: 1,
closedTabIds: [activeTab.id],
}),
},
],
isError: false,
};
} catch (error) {
console.error('Error in CloseTabsTool.execute:', error);
return createErrorResponse(
`Error closing tabs: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
export const closeTabsTool = new CloseTabsTool();
interface GoBackOrForwardToolParams {
isForward?: boolean;
}
/**
* Tool for navigating back or forward in browser history
*/
class GoBackOrForwardTool extends BaseBrowserToolExecutor {
name = TOOL_NAMES.BROWSER.GO_BACK_OR_FORWARD;
async execute(args: GoBackOrForwardToolParams): Promise<ToolResult> {
const { isForward = false } = args;
console.log(`Attempting to navigate ${isForward ? 'forward' : 'back'} in browser history`);
try {
// Get current active tab
const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!activeTab || !activeTab.id) {
return createErrorResponse('No active tab found');
}
// Navigate back or forward based on the isForward parameter
if (isForward) {
await chrome.tabs.goForward(activeTab.id);
console.log(`Navigated forward in tab ID: ${activeTab.id}`);
} else {
await chrome.tabs.goBack(activeTab.id);
console.log(`Navigated back in tab ID: ${activeTab.id}`);
}
// Get updated tab information
const updatedTab = await chrome.tabs.get(activeTab.id);
return {
content: [
{
type: 'text',
text: JSON.stringify({
success: true,
message: `Successfully navigated ${isForward ? 'forward' : 'back'} in browser history`,
tabId: updatedTab.id,
windowId: updatedTab.windowId,
url: updatedTab.url,
}),
},
],
isError: false,
};
} catch (error) {
if (chrome.runtime.lastError) {
console.error(`Chrome API Error: ${chrome.runtime.lastError.message}`, error);
return createErrorResponse(`Chrome API Error: ${chrome.runtime.lastError.message}`);
} else {
console.error('Error in GoBackOrForwardTool.execute:', error);
return createErrorResponse(
`Error navigating ${isForward ? 'forward' : 'back'}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
}
}
export const goBackOrForwardTool = new GoBackOrForwardTool();

View File

@@ -0,0 +1,343 @@
import { createErrorResponse, ToolResult } from '@/common/tool-handler';
import { BaseBrowserToolExecutor } from '../base-browser';
import { TOOL_NAMES } from 'chrome-mcp-shared';
const DEBUGGER_PROTOCOL_VERSION = '1.3';
const DEFAULT_MAX_MESSAGES = 100;
interface ConsoleToolParams {
url?: string;
includeExceptions?: boolean;
maxMessages?: number;
}
interface ConsoleMessage {
timestamp: number;
level: string;
text: string;
args?: any[];
source?: string;
url?: string;
lineNumber?: number;
stackTrace?: any;
}
interface ConsoleException {
timestamp: number;
text: string;
url?: string;
lineNumber?: number;
columnNumber?: number;
stackTrace?: any;
}
interface ConsoleResult {
success: boolean;
message: string;
tabId: number;
tabUrl: string;
tabTitle: string;
captureStartTime: number;
captureEndTime: number;
totalDurationMs: number;
messages: ConsoleMessage[];
exceptions: ConsoleException[];
messageCount: number;
exceptionCount: number;
messageLimitReached: boolean;
}
/**
* Tool for capturing console output from browser tabs
*/
class ConsoleTool extends BaseBrowserToolExecutor {
name = TOOL_NAMES.BROWSER.CONSOLE;
async execute(args: ConsoleToolParams): Promise<ToolResult> {
const { url, includeExceptions = true, maxMessages = DEFAULT_MAX_MESSAGES } = args;
let targetTab: chrome.tabs.Tab;
try {
if (url) {
// Navigate to the specified URL
targetTab = await this.navigateToUrl(url);
} else {
// Use current active tab
const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!activeTab?.id) {
return createErrorResponse('No active tab found and no URL provided.');
}
targetTab = activeTab;
}
if (!targetTab?.id) {
return createErrorResponse('Failed to identify target tab.');
}
const tabId = targetTab.id;
// Capture console messages (one-time capture)
const result = await this.captureConsoleMessages(tabId, {
includeExceptions,
maxMessages,
});
return {
content: [
{
type: 'text',
text: JSON.stringify(result),
},
],
isError: false,
};
} catch (error: any) {
console.error('ConsoleTool: Critical error during execute:', error);
return createErrorResponse(`Error in ConsoleTool: ${error.message || String(error)}`);
}
}
private async navigateToUrl(url: string): Promise<chrome.tabs.Tab> {
// Check if URL is already open
const existingTabs = await chrome.tabs.query({ url });
if (existingTabs.length > 0 && existingTabs[0]?.id) {
const tab = existingTabs[0];
// Activate the existing tab
await chrome.tabs.update(tab.id!, { active: true });
await chrome.windows.update(tab.windowId, { focused: true });
return tab;
} else {
// Create new tab with the URL
const newTab = await chrome.tabs.create({ url, active: true });
// Wait for tab to be ready
await this.waitForTabReady(newTab.id!);
return newTab;
}
}
private async waitForTabReady(tabId: number): Promise<void> {
return new Promise((resolve) => {
const checkTab = async () => {
try {
const tab = await chrome.tabs.get(tabId);
if (tab.status === 'complete') {
resolve();
} else {
setTimeout(checkTab, 100);
}
} catch (error) {
// Tab might be closed, resolve anyway
resolve();
}
};
checkTab();
});
}
private formatConsoleArgs(args: any[]): string {
if (!args || args.length === 0) return '';
return args
.map((arg) => {
if (arg.type === 'string') {
return arg.value || '';
} else if (arg.type === 'number') {
return String(arg.value || '');
} else if (arg.type === 'boolean') {
return String(arg.value || '');
} else if (arg.type === 'object') {
return arg.description || '[Object]';
} else if (arg.type === 'undefined') {
return 'undefined';
} else if (arg.type === 'function') {
return arg.description || '[Function]';
} else {
return arg.description || arg.value || String(arg);
}
})
.join(' ');
}
private async captureConsoleMessages(
tabId: number,
options: {
includeExceptions: boolean;
maxMessages: number;
},
): Promise<ConsoleResult> {
const { includeExceptions, maxMessages } = options;
const startTime = Date.now();
const messages: ConsoleMessage[] = [];
const exceptions: ConsoleException[] = [];
let limitReached = false;
try {
// 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;
}
// Set up event listener to collect messages
const collectedMessages: any[] = [];
const collectedExceptions: any[] = [];
const eventListener = (source: chrome.debugger.Debuggee, method: string, params?: any) => {
if (source.tabId !== tabId) return;
if (method === 'Log.entryAdded' && params?.entry) {
collectedMessages.push(params.entry);
} else if (method === 'Runtime.consoleAPICalled' && params) {
// Convert Runtime.consoleAPICalled to Log.entryAdded format
const logEntry = {
timestamp: params.timestamp,
level: params.type || 'log',
text: this.formatConsoleArgs(params.args || []),
source: 'console-api',
url: params.stackTrace?.callFrames?.[0]?.url,
lineNumber: params.stackTrace?.callFrames?.[0]?.lineNumber,
stackTrace: params.stackTrace,
args: params.args,
};
collectedMessages.push(logEntry);
} else if (
method === 'Runtime.exceptionThrown' &&
includeExceptions &&
params?.exceptionDetails
) {
collectedExceptions.push(params.exceptionDetails);
}
};
chrome.debugger.onEvent.addListener(eventListener);
try {
// Enable Runtime domain first to capture console API calls and exceptions
await chrome.debugger.sendCommand({ tabId }, 'Runtime.enable');
// Also enable Log domain to capture other log entries
await chrome.debugger.sendCommand({ tabId }, 'Log.enable');
// Wait for all messages to be flushed
await new Promise((resolve) => setTimeout(resolve, 2000));
// Process collected messages
for (const entry of collectedMessages) {
if (messages.length >= maxMessages) {
limitReached = true;
break;
}
const message: ConsoleMessage = {
timestamp: entry.timestamp,
level: entry.level || 'log',
text: entry.text || '',
source: entry.source,
url: entry.url,
lineNumber: entry.lineNumber,
};
if (entry.stackTrace) {
message.stackTrace = entry.stackTrace;
}
if (entry.args && Array.isArray(entry.args)) {
message.args = entry.args;
}
messages.push(message);
}
// Process collected exceptions
for (const exceptionDetails of collectedExceptions) {
const exception: ConsoleException = {
timestamp: Date.now(),
text:
exceptionDetails.text ||
exceptionDetails.exception?.description ||
'Unknown exception',
url: exceptionDetails.url,
lineNumber: exceptionDetails.lineNumber,
columnNumber: exceptionDetails.columnNumber,
};
if (exceptionDetails.stackTrace) {
exception.stackTrace = exceptionDetails.stackTrace;
}
exceptions.push(exception);
}
} finally {
// Clean up
chrome.debugger.onEvent.removeListener(eventListener);
try {
await chrome.debugger.sendCommand({ tabId }, 'Runtime.disable');
} catch (e) {
console.warn(`ConsoleTool: Error disabling Runtime for tab ${tabId}:`, e);
}
try {
await chrome.debugger.sendCommand({ tabId }, 'Log.disable');
} catch (e) {
console.warn(`ConsoleTool: Error disabling Log for tab ${tabId}:`, e);
}
try {
await chrome.debugger.detach({ tabId });
} catch (e) {
console.warn(`ConsoleTool: Error detaching debugger for tab ${tabId}:`, e);
}
}
const endTime = Date.now();
// Sort messages by timestamp
messages.sort((a, b) => a.timestamp - b.timestamp);
exceptions.sort((a, b) => a.timestamp - b.timestamp);
return {
success: true,
message: `Console capture completed for tab ${tabId}. ${messages.length} messages, ${exceptions.length} exceptions captured.`,
tabId,
tabUrl: tab.url || '',
tabTitle: tab.title || '',
captureStartTime: startTime,
captureEndTime: endTime,
totalDurationMs: endTime - startTime,
messages,
exceptions,
messageCount: messages.length,
exceptionCount: exceptions.length,
messageLimitReached: limitReached,
};
} catch (error: any) {
console.error(`ConsoleTool: Error capturing console messages for tab ${tabId}:`, error);
throw error;
}
}
}
export const consoleTool = new ConsoleTool();

View File

@@ -0,0 +1,232 @@
import { createErrorResponse, ToolResult } from '@/common/tool-handler';
import { BaseBrowserToolExecutor } from '../base-browser';
import { TOOL_NAMES } from 'chrome-mcp-shared';
import {
parseISO,
subDays,
subWeeks,
subMonths,
subYears,
startOfToday,
startOfYesterday,
isValid,
format,
} from 'date-fns';
interface HistoryToolParams {
text?: string;
startTime?: string;
endTime?: string;
maxResults?: number;
excludeCurrentTabs?: boolean;
}
interface HistoryItem {
id: string;
url?: string;
title?: string;
lastVisitTime?: number; // Timestamp in milliseconds
visitCount?: number;
typedCount?: number;
}
interface HistoryResult {
items: HistoryItem[];
totalCount: number;
timeRange: {
startTime: number;
endTime: number;
startTimeFormatted: string;
endTimeFormatted: string;
};
query?: string;
}
class HistoryTool extends BaseBrowserToolExecutor {
name = TOOL_NAMES.BROWSER.HISTORY;
private static readonly ONE_DAY_MS = 24 * 60 * 60 * 1000;
/**
* Parse a date string into milliseconds since epoch.
* Returns null if the date string is invalid.
* Supports:
* - ISO date strings (e.g., "2023-10-31", "2023-10-31T14:30:00.000Z")
* - Relative times: "1 day ago", "2 weeks ago", "3 months ago", "1 year ago"
* - Special keywords: "now", "today", "yesterday"
*/
private parseDateString(dateStr: string | undefined | null): number | null {
if (!dateStr) {
// If an empty or null string is passed, it might mean "no specific date",
// depending on how you want to treat it. Returning null is safer.
return null;
}
const now = new Date();
const lowerDateStr = dateStr.toLowerCase().trim();
if (lowerDateStr === 'now') return now.getTime();
if (lowerDateStr === 'today') return startOfToday().getTime();
if (lowerDateStr === 'yesterday') return startOfYesterday().getTime();
const relativeMatch = lowerDateStr.match(
/^(\d+)\s+(day|days|week|weeks|month|months|year|years)\s+ago$/,
);
if (relativeMatch) {
const amount = parseInt(relativeMatch[1], 10);
const unit = relativeMatch[2];
let resultDate: Date;
if (unit.startsWith('day')) resultDate = subDays(now, amount);
else if (unit.startsWith('week')) resultDate = subWeeks(now, amount);
else if (unit.startsWith('month')) resultDate = subMonths(now, amount);
else if (unit.startsWith('year')) resultDate = subYears(now, amount);
else return null; // Should not happen with the regex
return resultDate.getTime();
}
// Try parsing as ISO or other common date string formats
// Native Date constructor can be unreliable for non-standard formats.
// date-fns' parseISO is good for ISO 8601.
// For other formats, date-fns' parse function is more flexible.
let parsedDate = parseISO(dateStr); // Handles "2023-10-31" or "2023-10-31T10:00:00"
if (isValid(parsedDate)) {
return parsedDate.getTime();
}
// Fallback to new Date() for other potential formats, but with caution
parsedDate = new Date(dateStr);
if (isValid(parsedDate) && dateStr.includes(parsedDate.getFullYear().toString())) {
return parsedDate.getTime();
}
console.warn(`Could not parse date string: ${dateStr}`);
return null;
}
/**
* Format a timestamp as a human-readable date string
*/
private formatDate(timestamp: number): string {
// Using date-fns for consistent and potentially localized formatting
return format(timestamp, 'yyyy-MM-dd HH:mm:ss');
}
async execute(args: HistoryToolParams): Promise<ToolResult> {
try {
console.log('Executing HistoryTool with args:', args);
const {
text = '',
maxResults = 100, // Default to 100 results
excludeCurrentTabs = false,
} = args;
const now = Date.now();
let startTimeMs: number;
let endTimeMs: number;
// Parse startTime
if (args.startTime) {
const parsedStart = this.parseDateString(args.startTime);
if (parsedStart === null) {
return createErrorResponse(
`Invalid format for start time: "${args.startTime}". Supported formats: ISO (YYYY-MM-DD), "today", "yesterday", "X days/weeks/months/years ago".`,
);
}
startTimeMs = parsedStart;
} else {
// Default to 24 hours ago if startTime is not provided
startTimeMs = now - HistoryTool.ONE_DAY_MS;
}
// Parse endTime
if (args.endTime) {
const parsedEnd = this.parseDateString(args.endTime);
if (parsedEnd === null) {
return createErrorResponse(
`Invalid format for end time: "${args.endTime}". Supported formats: ISO (YYYY-MM-DD), "today", "yesterday", "X days/weeks/months/years ago".`,
);
}
endTimeMs = parsedEnd;
} else {
// Default to current time if endTime is not provided
endTimeMs = now;
}
// Validate time range
if (startTimeMs > endTimeMs) {
return createErrorResponse('Start time cannot be after end time.');
}
console.log(
`Searching history from ${this.formatDate(startTimeMs)} to ${this.formatDate(endTimeMs)} for query "${text}"`,
);
const historyItems = await chrome.history.search({
text,
startTime: startTimeMs,
endTime: endTimeMs,
maxResults,
});
console.log(`Found ${historyItems.length} history items before filtering current tabs.`);
let filteredItems = historyItems;
if (excludeCurrentTabs && historyItems.length > 0) {
const currentTabs = await chrome.tabs.query({});
const openUrls = new Set<string>();
currentTabs.forEach((tab) => {
if (tab.url) {
openUrls.add(tab.url);
}
});
if (openUrls.size > 0) {
filteredItems = historyItems.filter((item) => !(item.url && openUrls.has(item.url)));
console.log(
`Filtered out ${historyItems.length - filteredItems.length} items that are currently open. ${filteredItems.length} items remaining.`,
);
}
}
const result: HistoryResult = {
items: filteredItems.map((item) => ({
id: item.id,
url: item.url,
title: item.title,
lastVisitTime: item.lastVisitTime,
visitCount: item.visitCount,
typedCount: item.typedCount,
})),
totalCount: filteredItems.length,
timeRange: {
startTime: startTimeMs,
endTime: endTimeMs,
startTimeFormatted: this.formatDate(startTimeMs),
endTimeFormatted: this.formatDate(endTimeMs),
},
};
if (text) {
result.query = text;
}
return {
content: [
{
type: 'text',
text: JSON.stringify(result, null, 2),
},
],
isError: false,
};
} catch (error) {
console.error('Error in HistoryTool.execute:', error);
return createErrorResponse(
`Error retrieving browsing history: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
export const historyTool = new HistoryTool();

View File

@@ -0,0 +1,14 @@
export { navigateTool, closeTabsTool, goBackOrForwardTool } from './common';
export { windowTool } from './window';
export { vectorSearchTabsContentTool as searchTabsContentTool } from './vector-search';
export { screenshotTool } from './screenshot';
export { webFetcherTool, getInteractiveElementsTool } from './web-fetcher';
export { clickTool, fillTool } from './interaction';
export { networkRequestTool } from './network-request';
export { networkDebuggerStartTool, networkDebuggerStopTool } from './network-capture-debugger';
export { networkCaptureStartTool, networkCaptureStopTool } from './network-capture-web-request';
export { keyboardTool } from './keyboard';
export { historyTool } from './history';
export { bookmarkSearchTool, bookmarkAddTool, bookmarkDeleteTool } from './bookmark';
export { injectScriptTool, sendCommandToInjectScriptTool } from './inject-script';
export { consoleTool } from './console';

View File

@@ -0,0 +1,229 @@
import { createErrorResponse, ToolResult } from '@/common/tool-handler';
import { BaseBrowserToolExecutor } from '../base-browser';
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { ExecutionWorld } from '@/common/constants';
interface InjectScriptParam {
url?: string;
}
interface ScriptConfig {
type: ExecutionWorld;
jsScript: string;
}
interface SendCommandToInjectScriptToolParam {
tabId?: number;
eventName: string;
payload?: string;
}
const injectedTabs = new Map();
class InjectScriptTool extends BaseBrowserToolExecutor {
name = TOOL_NAMES.BROWSER.INJECT_SCRIPT;
async execute(args: InjectScriptParam & ScriptConfig): Promise<ToolResult> {
try {
const { url, type, jsScript } = args;
let tab;
if (!type || !jsScript) {
return createErrorResponse('Param [type] and [jsScript] is required');
}
if (url) {
// If URL is provided, check if it's already open
console.log(`Checking if URL is already open: ${url}`);
const allTabs = await chrome.tabs.query({});
// Find tab with matching URL
const matchingTabs = allTabs.filter((t) => {
// Normalize URLs for comparison (remove trailing slashes)
const tabUrl = t.url?.endsWith('/') ? t.url.slice(0, -1) : t.url;
const targetUrl = url.endsWith('/') ? url.slice(0, -1) : url;
return tabUrl === targetUrl;
});
if (matchingTabs.length > 0) {
// Use existing tab
tab = matchingTabs[0];
console.log(`Found existing tab with URL: ${url}, tab ID: ${tab.id}`);
} else {
// Create new tab with the URL
console.log(`No existing tab found with URL: ${url}, creating new tab`);
tab = await chrome.tabs.create({ url, active: true });
// Wait for page to load
console.log('Waiting for page to load...');
await new Promise((resolve) => setTimeout(resolve, 3000));
}
} else {
// Use active tab
const tabs = await chrome.tabs.query({ active: true });
if (!tabs[0]) {
return createErrorResponse('No active tab found');
}
tab = tabs[0];
}
if (!tab.id) {
return createErrorResponse('Tab has no ID');
}
// Make sure tab is active
await chrome.tabs.update(tab.id, { active: true });
const res = await handleInject(tab.id!, { ...args });
return {
content: [
{
type: 'text',
text: JSON.stringify(res),
},
],
isError: false,
};
} catch (error) {
console.error('Error in InjectScriptTool.execute:', error);
return createErrorResponse(
`Inject script error: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
class SendCommandToInjectScriptTool extends BaseBrowserToolExecutor {
name = TOOL_NAMES.BROWSER.SEND_COMMAND_TO_INJECT_SCRIPT;
async execute(args: SendCommandToInjectScriptToolParam): Promise<ToolResult> {
try {
const { tabId, eventName, payload } = args;
if (!eventName) {
return createErrorResponse('Param [eventName] is required');
}
if (tabId) {
const tabExists = await isTabExists(tabId);
if (!tabExists) {
return createErrorResponse('The tab:[tabId] is not exists');
}
}
let finalTabId: number | undefined = tabId;
if (finalTabId === undefined) {
// Use active tab
const tabs = await chrome.tabs.query({ active: true });
if (!tabs[0]) {
return createErrorResponse('No active tab found');
}
finalTabId = tabs[0].id;
}
if (!finalTabId) {
return createErrorResponse('No active tab found');
}
if (!injectedTabs.has(finalTabId)) {
throw new Error('No script injected in this tab.');
}
const result = await chrome.tabs.sendMessage(finalTabId, {
action: eventName,
payload,
targetWorld: injectedTabs.get(finalTabId).type, // The bridge uses this to decide whether to forward to MAIN world.
});
return {
content: [
{
type: 'text',
text: JSON.stringify(result),
},
],
isError: false,
};
} catch (error) {
console.error('Error in InjectScriptTool.execute:', error);
return createErrorResponse(
`Inject script error: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
async function isTabExists(tabId: number) {
try {
await chrome.tabs.get(tabId);
return true;
} catch (error) {
// An error is thrown if the tab doesn't exist.
return false;
}
}
/**
* @description Handles the injection of user scripts into a specific tab.
* @param {number} tabId - The ID of the target tab.
* @param {object} scriptConfig - The configuration object for the script.
*/
async function handleInject(tabId: number, scriptConfig: ScriptConfig) {
if (injectedTabs.has(tabId)) {
// If already injected, run cleanup first to ensure a clean state.
console.log(`Tab ${tabId} already has injections. Cleaning up first.`);
await handleCleanup(tabId);
}
const { type, jsScript } = scriptConfig;
const hasMain = type === ExecutionWorld.MAIN;
if (hasMain) {
// The bridge is essential for MAIN world communication and cleanup.
await chrome.scripting.executeScript({
target: { tabId },
files: ['inject-scripts/inject-bridge.js'],
world: ExecutionWorld.ISOLATED,
});
await chrome.scripting.executeScript({
target: { tabId },
func: (code) => new Function(code)(),
args: [jsScript],
world: ExecutionWorld.MAIN,
});
} else {
await chrome.scripting.executeScript({
target: { tabId },
func: (code) => new Function(code)(),
args: [jsScript],
world: ExecutionWorld.ISOLATED,
});
}
injectedTabs.set(tabId, scriptConfig);
console.log(`Scripts successfully injected into tab ${tabId}.`);
return { injected: true };
}
/**
* @description Triggers the cleanup process in a specific tab.
* @param {number} tabId - The ID of the target tab.
*/
async function handleCleanup(tabId: number) {
if (!injectedTabs.has(tabId)) return;
// Send cleanup signal. The bridge will forward it to the MAIN world.
chrome.tabs
.sendMessage(tabId, { type: 'chrome-mcp:cleanup' })
.catch((err) =>
console.warn(`Could not send cleanup message to tab ${tabId}. It might have been closed.`),
);
injectedTabs.delete(tabId);
console.log(`Cleanup signal sent to tab ${tabId}. State cleared.`);
}
export const injectScriptTool = new InjectScriptTool();
export const sendCommandToInjectScriptTool = new SendCommandToInjectScriptTool();
// --- Automatic Cleanup Listeners ---
chrome.tabs.onRemoved.addListener((tabId) => {
if (injectedTabs.has(tabId)) {
console.log(`Tab ${tabId} closed. Cleaning up state.`);
injectedTabs.delete(tabId);
}
});

View File

@@ -0,0 +1,167 @@
import { createErrorResponse, ToolResult } from '@/common/tool-handler';
import { BaseBrowserToolExecutor } from '../base-browser';
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { TOOL_MESSAGE_TYPES } from '@/common/message-types';
import { TIMEOUTS, ERROR_MESSAGES } from '@/common/constants';
interface Coordinates {
x: number;
y: number;
}
interface ClickToolParams {
selector?: string; // CSS selector for the element to click
coordinates?: Coordinates; // Coordinates to click at (x, y relative to viewport)
waitForNavigation?: boolean; // Whether to wait for navigation to complete after click
timeout?: number; // Timeout in milliseconds for waiting for the element or navigation
}
/**
* Tool for clicking elements on web pages
*/
class ClickTool extends BaseBrowserToolExecutor {
name = TOOL_NAMES.BROWSER.CLICK;
/**
* Execute click operation
*/
async execute(args: ClickToolParams): Promise<ToolResult> {
const {
selector,
coordinates,
waitForNavigation = false,
timeout = TIMEOUTS.DEFAULT_WAIT * 5,
} = args;
console.log(`Starting click operation with options:`, args);
if (!selector && !coordinates) {
return createErrorResponse(
ERROR_MESSAGES.INVALID_PARAMETERS + ': Either selector or coordinates must be provided',
);
}
try {
// Get current tab
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tabs[0]) {
return createErrorResponse(ERROR_MESSAGES.TAB_NOT_FOUND);
}
const tab = tabs[0];
if (!tab.id) {
return createErrorResponse(ERROR_MESSAGES.TAB_NOT_FOUND + ': Active tab has no ID');
}
await this.injectContentScript(tab.id, ['inject-scripts/click-helper.js']);
// Send click message to content script
const result = await this.sendMessageToTab(tab.id, {
action: TOOL_MESSAGE_TYPES.CLICK_ELEMENT,
selector,
coordinates,
waitForNavigation,
timeout,
});
return {
content: [
{
type: 'text',
text: JSON.stringify({
success: true,
message: result.message || 'Click operation successful',
elementInfo: result.elementInfo,
navigationOccurred: result.navigationOccurred,
clickMethod: coordinates ? 'coordinates' : 'selector',
}),
},
],
isError: false,
};
} catch (error) {
console.error('Error in click operation:', error);
return createErrorResponse(
`Error performing click: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
export const clickTool = new ClickTool();
interface FillToolParams {
selector: string;
value: string;
}
/**
* Tool for filling form elements on web pages
*/
class FillTool extends BaseBrowserToolExecutor {
name = TOOL_NAMES.BROWSER.FILL;
/**
* Execute fill operation
*/
async execute(args: FillToolParams): Promise<ToolResult> {
const { selector, value } = args;
console.log(`Starting fill operation with options:`, args);
if (!selector) {
return createErrorResponse(ERROR_MESSAGES.INVALID_PARAMETERS + ': Selector must be provided');
}
if (value === undefined || value === null) {
return createErrorResponse(ERROR_MESSAGES.INVALID_PARAMETERS + ': Value must be provided');
}
try {
// Get current tab
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tabs[0]) {
return createErrorResponse(ERROR_MESSAGES.TAB_NOT_FOUND);
}
const tab = tabs[0];
if (!tab.id) {
return createErrorResponse(ERROR_MESSAGES.TAB_NOT_FOUND + ': Active tab has no ID');
}
await this.injectContentScript(tab.id, ['inject-scripts/fill-helper.js']);
// Send fill message to content script
const result = await this.sendMessageToTab(tab.id, {
action: TOOL_MESSAGE_TYPES.FILL_ELEMENT,
selector,
value,
});
if (result.error) {
return createErrorResponse(result.error);
}
return {
content: [
{
type: 'text',
text: JSON.stringify({
success: true,
message: result.message || 'Fill operation successful',
elementInfo: result.elementInfo,
}),
},
],
isError: false,
};
} catch (error) {
console.error('Error in fill operation:', error);
return createErrorResponse(
`Error filling element: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
export const fillTool = new FillTool();

View File

@@ -0,0 +1,82 @@
import { createErrorResponse, ToolResult } from '@/common/tool-handler';
import { BaseBrowserToolExecutor } from '../base-browser';
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { TOOL_MESSAGE_TYPES } from '@/common/message-types';
import { TIMEOUTS, ERROR_MESSAGES } from '@/common/constants';
interface KeyboardToolParams {
keys: string; // Required: string representing keys or key combinations to simulate (e.g., "Enter", "Ctrl+C")
selector?: string; // Optional: CSS selector for target element to send keyboard events to
delay?: number; // Optional: delay between keystrokes in milliseconds
}
/**
* Tool for simulating keyboard input on web pages
*/
class KeyboardTool extends BaseBrowserToolExecutor {
name = TOOL_NAMES.BROWSER.KEYBOARD;
/**
* Execute keyboard operation
*/
async execute(args: KeyboardToolParams): Promise<ToolResult> {
const { keys, selector, delay = TIMEOUTS.KEYBOARD_DELAY } = args;
console.log(`Starting keyboard operation with options:`, args);
if (!keys) {
return createErrorResponse(
ERROR_MESSAGES.INVALID_PARAMETERS + ': Keys parameter must be provided',
);
}
try {
// Get current tab
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tabs[0]) {
return createErrorResponse(ERROR_MESSAGES.TAB_NOT_FOUND);
}
const tab = tabs[0];
if (!tab.id) {
return createErrorResponse(ERROR_MESSAGES.TAB_NOT_FOUND + ': Active tab has no ID');
}
await this.injectContentScript(tab.id, ['inject-scripts/keyboard-helper.js']);
// Send keyboard simulation message to content script
const result = await this.sendMessageToTab(tab.id, {
action: TOOL_MESSAGE_TYPES.SIMULATE_KEYBOARD,
keys,
selector,
delay,
});
if (result.error) {
return createErrorResponse(result.error);
}
return {
content: [
{
type: 'text',
text: JSON.stringify({
success: true,
message: result.message || 'Keyboard operation successful',
targetElement: result.targetElement,
results: result.results,
}),
},
],
isError: false,
};
} catch (error) {
console.error('Error in keyboard operation:', error);
return createErrorResponse(
`Error simulating keyboard events: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
export const keyboardTool = new KeyboardTool();

View File

@@ -0,0 +1,988 @@
import { createErrorResponse, ToolResult } from '@/common/tool-handler';
import { BaseBrowserToolExecutor } from '../base-browser';
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { LIMITS, NETWORK_FILTERS } from '@/common/constants';
// Static resource file extensions
const STATIC_RESOURCE_EXTENSIONS = [
'.jpg',
'.jpeg',
'.png',
'.gif',
'.svg',
'.webp',
'.ico',
'.bmp', // Images
'.css',
'.scss',
'.less', // Styles
'.js',
'.jsx',
'.ts',
'.tsx', // Scripts
'.woff',
'.woff2',
'.ttf',
'.eot',
'.otf', // Fonts
'.mp3',
'.mp4',
'.avi',
'.mov',
'.wmv',
'.flv',
'.ogg',
'.wav', // Media
'.pdf',
'.doc',
'.docx',
'.xls',
'.xlsx',
'.ppt',
'.pptx', // Documents
];
// Ad and analytics domain list
const AD_ANALYTICS_DOMAINS = NETWORK_FILTERS.EXCLUDED_DOMAINS;
interface NetworkCaptureStartToolParams {
url?: string; // URL to navigate to or focus. If not provided, uses active tab.
maxCaptureTime?: number; // Maximum capture time (milliseconds)
inactivityTimeout?: number; // Inactivity timeout (milliseconds)
includeStatic?: boolean; // Whether to include static resources
}
interface NetworkRequestInfo {
requestId: string;
url: string;
method: string;
type: string;
requestTime: number;
requestHeaders?: Record<string, string>;
requestBody?: string;
responseHeaders?: Record<string, string>;
responseTime?: number;
status?: number;
statusText?: string;
responseSize?: number;
responseType?: string;
responseBody?: string;
errorText?: string;
specificRequestHeaders?: Record<string, string>;
specificResponseHeaders?: Record<string, string>;
mimeType?: string; // Response MIME type
}
interface CaptureInfo {
tabId: number;
tabUrl: string;
tabTitle: string;
startTime: number;
endTime?: number;
requests: Record<string, NetworkRequestInfo>;
maxCaptureTime: number;
inactivityTimeout: number;
includeStatic: boolean;
limitReached?: boolean; // Whether request count limit is reached
}
/**
* Network Capture Start Tool V2 - Uses Chrome webRequest API to start capturing network requests
*/
class NetworkCaptureStartTool extends BaseBrowserToolExecutor {
name = TOOL_NAMES.BROWSER.NETWORK_CAPTURE_START;
public static instance: NetworkCaptureStartTool | null = null;
public captureData: Map<number, CaptureInfo> = new Map(); // tabId -> capture data
private captureTimers: Map<number, NodeJS.Timeout> = new Map(); // tabId -> max capture timer
private inactivityTimers: Map<number, NodeJS.Timeout> = new Map(); // tabId -> inactivity timer
private lastActivityTime: Map<number, number> = new Map(); // tabId -> timestamp of last activity
private requestCounters: Map<number, number> = new Map(); // tabId -> count of captured requests
public static MAX_REQUESTS_PER_CAPTURE = LIMITS.MAX_NETWORK_REQUESTS; // Maximum capture request count
private listeners: { [key: string]: (details: any) => void } = {};
// Static resource MIME types list (for filtering)
private static STATIC_MIME_TYPES_TO_FILTER = [
'image/', // All image types
'font/', // All font types
'audio/', // All audio types
'video/', // All video types
'text/css',
'text/javascript',
'application/javascript',
'application/x-javascript',
'application/pdf',
'application/zip',
'application/octet-stream', // Usually for downloads or generic binary data
];
// API response MIME types list (these types are usually not filtered)
private static API_MIME_TYPES = [
'application/json',
'application/xml',
'text/xml',
'application/x-www-form-urlencoded',
'application/graphql',
'application/grpc',
'application/protobuf',
'application/x-protobuf',
'application/x-json',
'application/ld+json',
'application/problem+json',
'application/problem+xml',
'application/soap+xml',
'application/vnd.api+json',
];
constructor() {
super();
if (NetworkCaptureStartTool.instance) {
return NetworkCaptureStartTool.instance;
}
NetworkCaptureStartTool.instance = this;
// Listen for tab close events
chrome.tabs.onRemoved.addListener(this.handleTabRemoved.bind(this));
// Listen for tab creation events
chrome.tabs.onCreated.addListener(this.handleTabCreated.bind(this));
}
/**
* Handle tab close events
*/
private handleTabRemoved(tabId: number) {
if (this.captureData.has(tabId)) {
console.log(`NetworkCaptureV2: Tab ${tabId} was closed, cleaning up resources.`);
this.cleanupCapture(tabId);
}
}
/**
* Handle tab creation events
* If a new tab is opened from a tab being captured, automatically start capturing the new tab's requests
*/
private async handleTabCreated(tab: chrome.tabs.Tab) {
try {
// Check if there are any tabs currently capturing
if (this.captureData.size === 0) return;
// Get the openerTabId of the new tab (ID of the tab that opened this tab)
const openerTabId = tab.openerTabId;
if (!openerTabId) return;
// Check if the opener tab is currently capturing
if (!this.captureData.has(openerTabId)) return;
// Get the new tab's ID
const newTabId = tab.id;
if (!newTabId) return;
console.log(
`NetworkCaptureV2: New tab ${newTabId} created from capturing tab ${openerTabId}, will extend capture to it.`,
);
// Get the opener tab's capture settings
const openerCaptureInfo = this.captureData.get(openerTabId);
if (!openerCaptureInfo) return;
// Wait a short time to ensure the tab is ready
await new Promise((resolve) => setTimeout(resolve, 500));
// Start capturing requests for the new tab
await this.startCaptureForTab(newTabId, {
maxCaptureTime: openerCaptureInfo.maxCaptureTime,
inactivityTimeout: openerCaptureInfo.inactivityTimeout,
includeStatic: openerCaptureInfo.includeStatic,
});
console.log(`NetworkCaptureV2: Successfully extended capture to new tab ${newTabId}`);
} catch (error) {
console.error(`NetworkCaptureV2: Error extending capture to new tab:`, error);
}
}
/**
* Determine whether a request should be filtered (based on URL)
*/
private shouldFilterRequest(url: string, includeStatic: boolean): boolean {
try {
const urlObj = new URL(url);
// Check if it's an ad or analytics domain
if (AD_ANALYTICS_DOMAINS.some((domain) => urlObj.hostname.includes(domain))) {
console.log(`NetworkCaptureV2: Filtering ad/analytics domain: ${urlObj.hostname}`);
return true;
}
// If not including static resources, check extensions
if (!includeStatic) {
const path = urlObj.pathname.toLowerCase();
if (STATIC_RESOURCE_EXTENSIONS.some((ext) => path.endsWith(ext))) {
console.log(`NetworkCaptureV2: Filtering static resource by extension: ${path}`);
return true;
}
}
return false;
} catch (e) {
console.error('NetworkCaptureV2: Error filtering URL:', e);
return false;
}
}
/**
* Filter based on MIME type
*/
private shouldFilterByMimeType(mimeType: string, includeStatic: boolean): boolean {
if (!mimeType) return false;
// Always keep API response types
if (NetworkCaptureStartTool.API_MIME_TYPES.some((type) => mimeType.startsWith(type))) {
return false;
}
// If not including static resources, filter out static resource MIME types
if (!includeStatic) {
// Filter static resource MIME types
if (
NetworkCaptureStartTool.STATIC_MIME_TYPES_TO_FILTER.some((type) =>
mimeType.startsWith(type),
)
) {
console.log(`NetworkCaptureV2: Filtering static resource by MIME type: ${mimeType}`);
return true;
}
// Filter all MIME types starting with text/ (except those already in API_MIME_TYPES)
if (mimeType.startsWith('text/')) {
console.log(`NetworkCaptureV2: Filtering text response: ${mimeType}`);
return true;
}
}
return false;
}
/**
* Update last activity time and reset inactivity timer
*/
private updateLastActivityTime(tabId: number): void {
const captureInfo = this.captureData.get(tabId);
if (!captureInfo) return;
this.lastActivityTime.set(tabId, Date.now());
// Reset inactivity timer
if (this.inactivityTimers.has(tabId)) {
clearTimeout(this.inactivityTimers.get(tabId)!);
}
if (captureInfo.inactivityTimeout > 0) {
this.inactivityTimers.set(
tabId,
setTimeout(() => this.checkInactivity(tabId), captureInfo.inactivityTimeout),
);
}
}
/**
* Check for inactivity
*/
private checkInactivity(tabId: number): void {
const captureInfo = this.captureData.get(tabId);
if (!captureInfo) return;
const lastActivity = this.lastActivityTime.get(tabId) || captureInfo.startTime;
const now = Date.now();
const inactiveTime = now - lastActivity;
if (inactiveTime >= captureInfo.inactivityTimeout) {
console.log(
`NetworkCaptureV2: No activity for ${inactiveTime}ms, stopping capture for tab ${tabId}`,
);
this.stopCaptureByInactivity(tabId);
} else {
// If inactivity time hasn't been reached yet, continue checking
const remainingTime = captureInfo.inactivityTimeout - inactiveTime;
this.inactivityTimers.set(
tabId,
setTimeout(() => this.checkInactivity(tabId), remainingTime),
);
}
}
/**
* Stop capture due to inactivity
*/
private async stopCaptureByInactivity(tabId: number): Promise<void> {
const captureInfo = this.captureData.get(tabId);
if (!captureInfo) return;
console.log(`NetworkCaptureV2: Stopping capture due to inactivity for tab ${tabId}`);
await this.stopCapture(tabId);
}
/**
* Clean up capture resources
*/
private cleanupCapture(tabId: number): void {
// Clear timers
if (this.captureTimers.has(tabId)) {
clearTimeout(this.captureTimers.get(tabId)!);
this.captureTimers.delete(tabId);
}
if (this.inactivityTimers.has(tabId)) {
clearTimeout(this.inactivityTimers.get(tabId)!);
this.inactivityTimers.delete(tabId);
}
// Remove data
this.lastActivityTime.delete(tabId);
this.captureData.delete(tabId);
this.requestCounters.delete(tabId);
console.log(`NetworkCaptureV2: Cleaned up all resources for tab ${tabId}`);
}
/**
* Set up request listeners
*/
private setupListeners(): void {
// Before request is sent
this.listeners.onBeforeRequest = (details: chrome.webRequest.WebRequestBodyDetails) => {
const captureInfo = this.captureData.get(details.tabId);
if (!captureInfo) return;
if (this.shouldFilterRequest(details.url, captureInfo.includeStatic)) {
return;
}
const currentCount = this.requestCounters.get(details.tabId) || 0;
if (currentCount >= NetworkCaptureStartTool.MAX_REQUESTS_PER_CAPTURE) {
console.log(
`NetworkCaptureV2: Request limit (${NetworkCaptureStartTool.MAX_REQUESTS_PER_CAPTURE}) reached for tab ${details.tabId}, ignoring new request: ${details.url}`,
);
captureInfo.limitReached = true;
return;
}
this.requestCounters.set(details.tabId, currentCount + 1);
this.updateLastActivityTime(details.tabId);
if (!captureInfo.requests[details.requestId]) {
captureInfo.requests[details.requestId] = {
requestId: details.requestId,
url: details.url,
method: details.method,
type: details.type,
requestTime: details.timeStamp,
};
if (details.requestBody) {
const requestBody = this.processRequestBody(details.requestBody);
if (requestBody) {
captureInfo.requests[details.requestId].requestBody = requestBody;
}
}
console.log(
`NetworkCaptureV2: Captured request ${currentCount + 1}/${NetworkCaptureStartTool.MAX_REQUESTS_PER_CAPTURE} for tab ${details.tabId}: ${details.method} ${details.url}`,
);
}
};
// Send request headers
this.listeners.onSendHeaders = (details: chrome.webRequest.WebRequestHeadersDetails) => {
const captureInfo = this.captureData.get(details.tabId);
if (!captureInfo || !captureInfo.requests[details.requestId]) return;
if (details.requestHeaders) {
const headers: Record<string, string> = {};
details.requestHeaders.forEach((header) => {
headers[header.name] = header.value || '';
});
captureInfo.requests[details.requestId].requestHeaders = headers;
}
};
// Receive response headers
this.listeners.onHeadersReceived = (details: chrome.webRequest.WebResponseHeadersDetails) => {
const captureInfo = this.captureData.get(details.tabId);
if (!captureInfo || !captureInfo.requests[details.requestId]) return;
const requestInfo = captureInfo.requests[details.requestId];
requestInfo.status = details.statusCode;
requestInfo.statusText = details.statusLine;
requestInfo.responseTime = details.timeStamp;
requestInfo.mimeType = details.responseHeaders?.find(
(h) => h.name.toLowerCase() === 'content-type',
)?.value;
// Secondary filtering based on MIME type
if (
requestInfo.mimeType &&
this.shouldFilterByMimeType(requestInfo.mimeType, captureInfo.includeStatic)
) {
delete captureInfo.requests[details.requestId];
const currentCount = this.requestCounters.get(details.tabId) || 0;
if (currentCount > 0) {
this.requestCounters.set(details.tabId, currentCount - 1);
}
console.log(
`NetworkCaptureV2: Filtered request by MIME type (${requestInfo.mimeType}): ${requestInfo.url}`,
);
return;
}
if (details.responseHeaders) {
const headers: Record<string, string> = {};
details.responseHeaders.forEach((header) => {
headers[header.name] = header.value || '';
});
requestInfo.responseHeaders = headers;
}
this.updateLastActivityTime(details.tabId);
};
// Request completed
this.listeners.onCompleted = (details: chrome.webRequest.WebResponseCacheDetails) => {
const captureInfo = this.captureData.get(details.tabId);
if (!captureInfo || !captureInfo.requests[details.requestId]) return;
const requestInfo = captureInfo.requests[details.requestId];
if ('responseSize' in details) {
requestInfo.responseSize = details.fromCache ? 0 : (details as any).responseSize;
}
this.updateLastActivityTime(details.tabId);
};
// Request failed
this.listeners.onErrorOccurred = (details: chrome.webRequest.WebResponseErrorDetails) => {
const captureInfo = this.captureData.get(details.tabId);
if (!captureInfo || !captureInfo.requests[details.requestId]) return;
const requestInfo = captureInfo.requests[details.requestId];
requestInfo.errorText = details.error;
this.updateLastActivityTime(details.tabId);
};
// Register all listeners
chrome.webRequest.onBeforeRequest.addListener(
this.listeners.onBeforeRequest,
{ urls: ['<all_urls>'] },
['requestBody'],
);
chrome.webRequest.onSendHeaders.addListener(
this.listeners.onSendHeaders,
{ urls: ['<all_urls>'] },
['requestHeaders'],
);
chrome.webRequest.onHeadersReceived.addListener(
this.listeners.onHeadersReceived,
{ urls: ['<all_urls>'] },
['responseHeaders'],
);
chrome.webRequest.onCompleted.addListener(this.listeners.onCompleted, { urls: ['<all_urls>'] });
chrome.webRequest.onErrorOccurred.addListener(this.listeners.onErrorOccurred, {
urls: ['<all_urls>'],
});
}
/**
* Remove all request listeners
* Only remove listeners when all tab captures have stopped
*/
private removeListeners(): void {
// Don't remove listeners if there are still tabs being captured
if (this.captureData.size > 0) {
console.log(
`NetworkCaptureV2: Still capturing on ${this.captureData.size} tabs, not removing listeners.`,
);
return;
}
console.log(`NetworkCaptureV2: No more active captures, removing all listeners.`);
if (this.listeners.onBeforeRequest) {
chrome.webRequest.onBeforeRequest.removeListener(this.listeners.onBeforeRequest);
}
if (this.listeners.onSendHeaders) {
chrome.webRequest.onSendHeaders.removeListener(this.listeners.onSendHeaders);
}
if (this.listeners.onHeadersReceived) {
chrome.webRequest.onHeadersReceived.removeListener(this.listeners.onHeadersReceived);
}
if (this.listeners.onCompleted) {
chrome.webRequest.onCompleted.removeListener(this.listeners.onCompleted);
}
if (this.listeners.onErrorOccurred) {
chrome.webRequest.onErrorOccurred.removeListener(this.listeners.onErrorOccurred);
}
// Clear listener object
this.listeners = {};
}
/**
* Process request body data
*/
private processRequestBody(requestBody: chrome.webRequest.WebRequestBody): string | undefined {
if (requestBody.raw && requestBody.raw.length > 0) {
return '[Binary data]';
} else if (requestBody.formData) {
return JSON.stringify(requestBody.formData);
}
return undefined;
}
/**
* Start network request capture for specified tab
* @param tabId Tab ID
* @param options Capture options
*/
private async startCaptureForTab(
tabId: number,
options: {
maxCaptureTime: number;
inactivityTimeout: number;
includeStatic: boolean;
},
): Promise<void> {
const { maxCaptureTime, inactivityTimeout, includeStatic } = options;
// If already capturing, stop first
if (this.captureData.has(tabId)) {
console.log(
`NetworkCaptureV2: Already capturing on tab ${tabId}. Stopping previous session.`,
);
await this.stopCapture(tabId);
}
try {
// Get tab information
const tab = await chrome.tabs.get(tabId);
// Initialize capture data
this.captureData.set(tabId, {
tabId: tabId,
tabUrl: tab.url || '',
tabTitle: tab.title || '',
startTime: Date.now(),
requests: {},
maxCaptureTime,
inactivityTimeout,
includeStatic,
limitReached: false,
});
// Initialize request counter
this.requestCounters.set(tabId, 0);
// Set up listeners
this.setupListeners();
// Update last activity time
this.updateLastActivityTime(tabId);
console.log(
`NetworkCaptureV2: Started capture for tab ${tabId} (${tab.url}). Max requests: ${NetworkCaptureStartTool.MAX_REQUESTS_PER_CAPTURE}, Max time: ${maxCaptureTime}ms, Inactivity: ${inactivityTimeout}ms.`,
);
// Set maximum capture time
if (maxCaptureTime > 0) {
this.captureTimers.set(
tabId,
setTimeout(async () => {
console.log(
`NetworkCaptureV2: Max capture time (${maxCaptureTime}ms) reached for tab ${tabId}.`,
);
await this.stopCapture(tabId);
}, maxCaptureTime),
);
}
} catch (error: any) {
console.error(`NetworkCaptureV2: Error starting capture for tab ${tabId}:`, error);
// Clean up resources
if (this.captureData.has(tabId)) {
this.cleanupCapture(tabId);
}
throw error;
}
}
/**
* Stop capture
* @param tabId Tab ID
*/
public async stopCapture(
tabId: number,
): Promise<{ success: boolean; message?: string; data?: any }> {
const captureInfo = this.captureData.get(tabId);
if (!captureInfo) {
console.log(`NetworkCaptureV2: No capture in progress for tab ${tabId}`);
return { success: false, message: `No capture in progress for tab ${tabId}` };
}
try {
// Record end time
captureInfo.endTime = Date.now();
// Extract common request and response headers
const requestsArray = Object.values(captureInfo.requests);
const commonRequestHeaders = this.analyzeCommonHeaders(requestsArray, 'requestHeaders');
const commonResponseHeaders = this.analyzeCommonHeaders(requestsArray, 'responseHeaders');
// Process request data, remove common headers
const processedRequests = requestsArray.map((req) => {
const finalReq: NetworkRequestInfo = { ...req };
if (finalReq.requestHeaders) {
finalReq.specificRequestHeaders = this.filterOutCommonHeaders(
finalReq.requestHeaders,
commonRequestHeaders,
);
delete finalReq.requestHeaders;
} else {
finalReq.specificRequestHeaders = {};
}
if (finalReq.responseHeaders) {
finalReq.specificResponseHeaders = this.filterOutCommonHeaders(
finalReq.responseHeaders,
commonResponseHeaders,
);
delete finalReq.responseHeaders;
} else {
finalReq.specificResponseHeaders = {};
}
return finalReq;
});
// Sort by time
processedRequests.sort((a, b) => (a.requestTime || 0) - (b.requestTime || 0));
// Remove listeners
this.removeListeners();
// Prepare result data
const resultData = {
captureStartTime: captureInfo.startTime,
captureEndTime: captureInfo.endTime,
totalDurationMs: captureInfo.endTime - captureInfo.startTime,
settingsUsed: {
maxCaptureTime: captureInfo.maxCaptureTime,
inactivityTimeout: captureInfo.inactivityTimeout,
includeStatic: captureInfo.includeStatic,
maxRequests: NetworkCaptureStartTool.MAX_REQUESTS_PER_CAPTURE,
},
commonRequestHeaders,
commonResponseHeaders,
requests: processedRequests,
requestCount: processedRequests.length,
totalRequestsReceived: this.requestCounters.get(tabId) || 0,
requestLimitReached: captureInfo.limitReached || false,
tabUrl: captureInfo.tabUrl,
tabTitle: captureInfo.tabTitle,
};
// Clean up resources
this.cleanupCapture(tabId);
return {
success: true,
data: resultData,
};
} catch (error: any) {
console.error(`NetworkCaptureV2: Error stopping capture for tab ${tabId}:`, error);
// Ensure resources are cleaned up
this.cleanupCapture(tabId);
return {
success: false,
message: `Error stopping capture: ${error.message || String(error)}`,
};
}
}
/**
* Analyze common request or response headers
*/
private analyzeCommonHeaders(
requests: NetworkRequestInfo[],
headerType: 'requestHeaders' | 'responseHeaders',
): Record<string, string> {
if (!requests || requests.length === 0) return {};
// Find headers that are included in all requests
const commonHeaders: Record<string, string> = {};
const firstRequestWithHeaders = requests.find(
(req) => req[headerType] && Object.keys(req[headerType] || {}).length > 0,
);
if (!firstRequestWithHeaders || !firstRequestWithHeaders[headerType]) {
return {};
}
// Get all headers from the first request
const headers = firstRequestWithHeaders[headerType] as Record<string, string>;
const headerNames = Object.keys(headers);
// Check if each header exists in all requests with the same value
for (const name of headerNames) {
const value = headers[name];
const isCommon = requests.every((req) => {
const reqHeaders = req[headerType] as Record<string, string>;
return reqHeaders && reqHeaders[name] === value;
});
if (isCommon) {
commonHeaders[name] = value;
}
}
return commonHeaders;
}
/**
* Filter out common headers
*/
private filterOutCommonHeaders(
headers: Record<string, string>,
commonHeaders: Record<string, string>,
): Record<string, string> {
if (!headers || typeof headers !== 'object') return {};
const specificHeaders: Record<string, string> = {};
// Use Object.keys to avoid ESLint no-prototype-builtins warning
Object.keys(headers).forEach((name) => {
if (!(name in commonHeaders) || headers[name] !== commonHeaders[name]) {
specificHeaders[name] = headers[name];
}
});
return specificHeaders;
}
async execute(args: NetworkCaptureStartToolParams): Promise<ToolResult> {
const {
url: targetUrl,
maxCaptureTime = 3 * 60 * 1000, // Default 3 minutes
inactivityTimeout = 60 * 1000, // Default 1 minute of inactivity before auto-stop
includeStatic = false, // Default: don't include static resources
} = args;
console.log(`NetworkCaptureStartTool: Executing with args:`, args);
try {
// Get current tab or create new tab
let tabToOperateOn: chrome.tabs.Tab;
if (targetUrl) {
// Find tabs matching the URL
const matchingTabs = await chrome.tabs.query({ url: targetUrl });
if (matchingTabs.length > 0) {
// Use existing tab
tabToOperateOn = matchingTabs[0];
console.log(`NetworkCaptureV2: Found existing tab with URL: ${targetUrl}`);
} else {
// Create new tab
console.log(`NetworkCaptureV2: Creating new tab with URL: ${targetUrl}`);
tabToOperateOn = await chrome.tabs.create({ url: targetUrl, active: true });
// Wait for page to load
await new Promise((resolve) => setTimeout(resolve, 1000));
}
} else {
// Use current active tab
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tabs[0]) {
return createErrorResponse('No active tab found');
}
tabToOperateOn = tabs[0];
}
if (!tabToOperateOn?.id) {
return createErrorResponse('Failed to identify or create a tab');
}
// Use startCaptureForTab method to start capture
try {
await this.startCaptureForTab(tabToOperateOn.id, {
maxCaptureTime,
inactivityTimeout,
includeStatic,
});
} catch (error: any) {
return createErrorResponse(
`Failed to start capture for tab ${tabToOperateOn.id}: ${error.message || String(error)}`,
);
}
return {
content: [
{
type: 'text',
text: JSON.stringify({
success: true,
message: 'Network capture V2 started successfully, waiting for stop command.',
tabId: tabToOperateOn.id,
url: tabToOperateOn.url,
maxCaptureTime,
inactivityTimeout,
includeStatic,
maxRequests: NetworkCaptureStartTool.MAX_REQUESTS_PER_CAPTURE,
}),
},
],
isError: false,
};
} catch (error: any) {
console.error('NetworkCaptureStartTool: Critical error:', error);
return createErrorResponse(
`Error in NetworkCaptureStartTool: ${error.message || String(error)}`,
);
}
}
}
/**
* Network capture stop tool V2 - Stop webRequest API capture and return results
*/
class NetworkCaptureStopTool extends BaseBrowserToolExecutor {
name = TOOL_NAMES.BROWSER.NETWORK_CAPTURE_STOP;
public static instance: NetworkCaptureStopTool | null = null;
constructor() {
super();
if (NetworkCaptureStopTool.instance) {
return NetworkCaptureStopTool.instance;
}
NetworkCaptureStopTool.instance = this;
}
async execute(): Promise<ToolResult> {
console.log(`NetworkCaptureStopTool: Executing`);
try {
const startTool = NetworkCaptureStartTool.instance;
if (!startTool) {
return createErrorResponse('Network capture V2 start tool instance not found');
}
// Get all tabs currently capturing
const ongoingCaptures = Array.from(startTool.captureData.keys());
console.log(
`NetworkCaptureStopTool: Found ${ongoingCaptures.length} ongoing captures: ${ongoingCaptures.join(', ')}`,
);
if (ongoingCaptures.length === 0) {
return createErrorResponse('No active network captures found in any tab.');
}
// Get current active tab
const activeTabs = await chrome.tabs.query({ active: true, currentWindow: true });
const activeTabId = activeTabs[0]?.id;
// Determine the primary tab to stop
let primaryTabId: number;
if (activeTabId && startTool.captureData.has(activeTabId)) {
// If current active tab is capturing, prioritize stopping it
primaryTabId = activeTabId;
console.log(
`NetworkCaptureStopTool: Active tab ${activeTabId} is capturing, will stop it first.`,
);
} else if (ongoingCaptures.length === 1) {
// If only one tab is capturing, stop it
primaryTabId = ongoingCaptures[0];
console.log(
`NetworkCaptureStopTool: Only one tab ${primaryTabId} is capturing, stopping it.`,
);
} else {
// If multiple tabs are capturing but current active tab is not among them, stop the first one
primaryTabId = ongoingCaptures[0];
console.log(
`NetworkCaptureStopTool: Multiple tabs capturing, active tab not among them. Stopping tab ${primaryTabId} first.`,
);
}
const stopResult = await startTool.stopCapture(primaryTabId);
if (!stopResult.success) {
return createErrorResponse(
stopResult.message || `Failed to stop network capture for tab ${primaryTabId}`,
);
}
// If multiple tabs are capturing, stop other tabs
if (ongoingCaptures.length > 1) {
const otherTabIds = ongoingCaptures.filter((id) => id !== primaryTabId);
console.log(
`NetworkCaptureStopTool: Stopping ${otherTabIds.length} additional captures: ${otherTabIds.join(', ')}`,
);
for (const tabId of otherTabIds) {
try {
await startTool.stopCapture(tabId);
} catch (error) {
console.error(`NetworkCaptureStopTool: Error stopping capture on tab ${tabId}:`, error);
}
}
}
return {
content: [
{
type: 'text',
text: JSON.stringify({
success: true,
message: `Capture complete. ${stopResult.data?.requestCount || 0} requests captured.`,
tabId: primaryTabId,
tabUrl: stopResult.data?.tabUrl || 'N/A',
tabTitle: stopResult.data?.tabTitle || 'Unknown Tab',
requestCount: stopResult.data?.requestCount || 0,
commonRequestHeaders: stopResult.data?.commonRequestHeaders || {},
commonResponseHeaders: stopResult.data?.commonResponseHeaders || {},
requests: stopResult.data?.requests || [],
captureStartTime: stopResult.data?.captureStartTime,
captureEndTime: stopResult.data?.captureEndTime,
totalDurationMs: stopResult.data?.totalDurationMs,
settingsUsed: stopResult.data?.settingsUsed || {},
totalRequestsReceived: stopResult.data?.totalRequestsReceived || 0,
requestLimitReached: stopResult.data?.requestLimitReached || false,
remainingCaptures: Array.from(startTool.captureData.keys()),
}),
},
],
isError: false,
};
} catch (error: any) {
console.error('NetworkCaptureStopTool: Critical error:', error);
return createErrorResponse(
`Error in NetworkCaptureStopTool: ${error.message || String(error)}`,
);
}
}
}
export const networkCaptureStartTool = new NetworkCaptureStartTool();
export const networkCaptureStopTool = new NetworkCaptureStopTool();

View File

@@ -0,0 +1,80 @@
import { createErrorResponse, ToolResult } from '@/common/tool-handler';
import { BaseBrowserToolExecutor } from '../base-browser';
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { TOOL_MESSAGE_TYPES } from '@/common/message-types';
const DEFAULT_NETWORK_REQUEST_TIMEOUT = 30000; // For sending a single request via content script
interface NetworkRequestToolParams {
url: string; // URL is always required
method?: string; // Defaults to GET
headers?: Record<string, string>; // User-provided headers
body?: any; // User-provided body
timeout?: number; // Timeout for the network request itself
}
/**
* NetworkRequestTool - Sends network requests based on provided parameters.
*/
class NetworkRequestTool extends BaseBrowserToolExecutor {
name = TOOL_NAMES.BROWSER.NETWORK_REQUEST;
async execute(args: NetworkRequestToolParams): Promise<ToolResult> {
const {
url,
method = 'GET',
headers = {},
body,
timeout = DEFAULT_NETWORK_REQUEST_TIMEOUT,
} = args;
console.log(`NetworkRequestTool: Executing with options:`, args);
if (!url) {
return createErrorResponse('URL parameter is required.');
}
try {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tabs[0]?.id) {
return createErrorResponse('No active tab found or tab has no ID.');
}
const activeTabId = tabs[0].id;
// Ensure content script is available in the target tab
await this.injectContentScript(activeTabId, ['inject-scripts/network-helper.js']);
console.log(
`NetworkRequestTool: Sending to content script: URL=${url}, Method=${method}, Headers=${Object.keys(headers).join(',')}, BodyType=${typeof body}`,
);
const resultFromContentScript = await this.sendMessageToTab(activeTabId, {
action: TOOL_MESSAGE_TYPES.NETWORK_SEND_REQUEST,
url: url,
method: method,
headers: headers,
body: body,
timeout: timeout,
});
console.log(`NetworkRequestTool: Response from content script:`, resultFromContentScript);
return {
content: [
{
type: 'text',
text: JSON.stringify(resultFromContentScript),
},
],
isError: !resultFromContentScript?.success,
};
} catch (error: any) {
console.error('NetworkRequestTool: Error sending network request:', error);
return createErrorResponse(
`Error sending network request: ${error.message || String(error)}`,
);
}
}
}
export const networkRequestTool = new NetworkRequestTool();

View File

@@ -0,0 +1,388 @@
import { createErrorResponse, ToolResult } from '@/common/tool-handler';
import { BaseBrowserToolExecutor } from '../base-browser';
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { TOOL_MESSAGE_TYPES } from '@/common/message-types';
import { TIMEOUTS, ERROR_MESSAGES } from '@/common/constants';
import {
canvasToDataURL,
createImageBitmapFromUrl,
cropAndResizeImage,
stitchImages,
compressImage,
} from '../../../../utils/image-utils';
// Screenshot-specific constants
const SCREENSHOT_CONSTANTS = {
SCROLL_DELAY_MS: 350, // Time to wait after scroll for rendering and lazy loading
CAPTURE_STITCH_DELAY_MS: 50, // Small delay between captures in a scroll sequence
MAX_CAPTURE_PARTS: 50, // Maximum number of parts to capture (for infinite scroll pages)
MAX_CAPTURE_HEIGHT_PX: 50000, // Maximum height in pixels to capture
PIXEL_TOLERANCE: 1,
SCRIPT_INIT_DELAY: 100, // Delay for script initialization
} as const;
interface ScreenshotToolParams {
name: string;
selector?: string;
width?: number;
height?: number;
storeBase64?: boolean;
fullPage?: boolean;
savePng?: boolean;
maxHeight?: number; // Maximum height to capture in pixels (for infinite scroll pages)
}
/**
* Tool for capturing screenshots of web pages
*/
class ScreenshotTool extends BaseBrowserToolExecutor {
name = TOOL_NAMES.BROWSER.SCREENSHOT;
/**
* Execute screenshot operation
*/
async execute(args: ScreenshotToolParams): Promise<ToolResult> {
const {
name = 'screenshot',
selector,
storeBase64 = false,
fullPage = false,
savePng = true,
} = args;
console.log(`Starting screenshot with options:`, args);
// Get current tab
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tabs[0]) {
return createErrorResponse(ERROR_MESSAGES.TAB_NOT_FOUND);
}
const tab = tabs[0];
// Check URL restrictions
if (
tab.url?.startsWith('chrome://') ||
tab.url?.startsWith('edge://') ||
tab.url?.startsWith('https://chrome.google.com/webstore') ||
tab.url?.startsWith('https://microsoftedge.microsoft.com/')
) {
return createErrorResponse(
'Cannot capture special browser pages or web store pages due to security restrictions.',
);
}
let finalImageDataUrl: string | undefined;
const results: any = { base64: null, fileSaved: false };
let originalScroll = { x: 0, y: 0 };
try {
await this.injectContentScript(tab.id!, ['inject-scripts/screenshot-helper.js']);
// Wait for script initialization
await new Promise((resolve) => setTimeout(resolve, SCREENSHOT_CONSTANTS.SCRIPT_INIT_DELAY));
// 1. Prepare page (hide scrollbars, potentially fixed elements)
await this.sendMessageToTab(tab.id!, {
action: TOOL_MESSAGE_TYPES.SCREENSHOT_PREPARE_PAGE_FOR_CAPTURE,
options: { fullPage },
});
// Get initial page details, including original scroll position
const pageDetails = await this.sendMessageToTab(tab.id!, {
action: TOOL_MESSAGE_TYPES.SCREENSHOT_GET_PAGE_DETAILS,
});
originalScroll = { x: pageDetails.currentScrollX, y: pageDetails.currentScrollY };
if (fullPage) {
this.logInfo('Capturing full page...');
finalImageDataUrl = await this._captureFullPage(tab.id!, args, pageDetails);
} else if (selector) {
this.logInfo(`Capturing element: ${selector}`);
finalImageDataUrl = await this._captureElement(tab.id!, args, pageDetails.devicePixelRatio);
} else {
// Visible area only
this.logInfo('Capturing visible area...');
finalImageDataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, { format: 'png' });
}
if (!finalImageDataUrl) {
throw new Error('Failed to capture image data');
}
// 2. Process output
if (storeBase64 === true) {
// Compress image for base64 output to reduce size
const compressed = await compressImage(finalImageDataUrl, {
scale: 0.7, // Reduce dimensions by 30%
quality: 0.8, // 80% quality for good balance
format: 'image/jpeg', // JPEG for better compression
});
// Include base64 data in response (without prefix)
const base64Data = compressed.dataUrl.replace(/^data:image\/[^;]+;base64,/, '');
results.base64 = base64Data;
return {
content: [
{
type: 'text',
text: JSON.stringify({ base64Data, mimeType: compressed.mimeType }),
},
],
isError: false,
};
}
if (savePng === true) {
// Save PNG file to downloads
this.logInfo('Saving PNG...');
try {
// Generate filename
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `${name.replace(/[^a-z0-9_-]/gi, '_') || 'screenshot'}_${timestamp}.png`;
// Use Chrome's download API to save the file
const downloadId = await chrome.downloads.download({
url: finalImageDataUrl,
filename: filename,
saveAs: false,
});
results.downloadId = downloadId;
results.filename = filename;
results.fileSaved = true;
// Try to get the full file path
try {
// Wait a moment to ensure download info is updated
await new Promise((resolve) => setTimeout(resolve, 100));
// Search for download item to get full path
const [downloadItem] = await chrome.downloads.search({ id: downloadId });
if (downloadItem && downloadItem.filename) {
// Add full path to response
results.fullPath = downloadItem.filename;
}
} catch (pathError) {
console.warn('Could not get full file path:', pathError);
}
} catch (error) {
console.error('Error saving PNG file:', error);
results.saveError = String(error instanceof Error ? error.message : error);
}
}
} catch (error) {
console.error('Error during screenshot execution:', error);
return createErrorResponse(
`Screenshot error: ${error instanceof Error ? error.message : JSON.stringify(error)}`,
);
} finally {
// 3. Reset page
try {
await this.sendMessageToTab(tab.id!, {
action: TOOL_MESSAGE_TYPES.SCREENSHOT_RESET_PAGE_AFTER_CAPTURE,
scrollX: originalScroll.x,
scrollY: originalScroll.y,
});
} catch (err) {
console.warn('Failed to reset page, tab might have closed:', err);
}
}
this.logInfo('Screenshot completed!');
return {
content: [
{
type: 'text',
text: JSON.stringify({
success: true,
message: `Screenshot [${name}] captured successfully`,
tabId: tab.id,
url: tab.url,
name: name,
...results,
}),
},
],
isError: false,
};
}
/**
* Log information
*/
private logInfo(message: string) {
console.log(`[Screenshot Tool] ${message}`);
}
/**
* Capture specific element
*/
async _captureElement(
tabId: number,
options: ScreenshotToolParams,
pageDpr: number,
): Promise<string> {
const elementDetails = await this.sendMessageToTab(tabId, {
action: TOOL_MESSAGE_TYPES.SCREENSHOT_GET_ELEMENT_DETAILS,
selector: options.selector,
});
const dpr = elementDetails.devicePixelRatio || pageDpr || 1;
// Element rect is viewport-relative, in CSS pixels
// captureVisibleTab captures in physical pixels
const cropRectPx = {
x: elementDetails.rect.x * dpr,
y: elementDetails.rect.y * dpr,
width: elementDetails.rect.width * dpr,
height: elementDetails.rect.height * dpr,
};
// Small delay to ensure element is fully rendered after scrollIntoView
await new Promise((resolve) => setTimeout(resolve, SCREENSHOT_CONSTANTS.SCRIPT_INIT_DELAY));
const visibleCaptureDataUrl = await chrome.tabs.captureVisibleTab({ format: 'png' });
if (!visibleCaptureDataUrl) {
throw new Error('Failed to capture visible tab for element cropping');
}
const croppedCanvas = await cropAndResizeImage(
visibleCaptureDataUrl,
cropRectPx,
dpr,
options.width, // Target output width in CSS pixels
options.height, // Target output height in CSS pixels
);
return canvasToDataURL(croppedCanvas);
}
/**
* Capture full page
*/
async _captureFullPage(
tabId: number,
options: ScreenshotToolParams,
initialPageDetails: any,
): Promise<string> {
const dpr = initialPageDetails.devicePixelRatio;
const totalWidthCss = options.width || initialPageDetails.totalWidth; // Use option width if provided
const totalHeightCss = initialPageDetails.totalHeight; // Full page always uses actual height
// Apply maximum height limit for infinite scroll pages
const maxHeightPx = options.maxHeight || SCREENSHOT_CONSTANTS.MAX_CAPTURE_HEIGHT_PX;
const limitedHeightCss = Math.min(totalHeightCss, maxHeightPx / dpr);
const totalWidthPx = totalWidthCss * dpr;
const totalHeightPx = limitedHeightCss * dpr;
// Viewport dimensions (CSS pixels) - logged for debugging
this.logInfo(
`Viewport size: ${initialPageDetails.viewportWidth}x${initialPageDetails.viewportHeight} CSS pixels`,
);
this.logInfo(
`Page dimensions: ${totalWidthCss}x${totalHeightCss} CSS pixels (limited to ${limitedHeightCss} height)`,
);
const viewportHeightCss = initialPageDetails.viewportHeight;
const capturedParts = [];
let currentScrollYCss = 0;
let capturedHeightPx = 0;
let partIndex = 0;
while (capturedHeightPx < totalHeightPx && partIndex < SCREENSHOT_CONSTANTS.MAX_CAPTURE_PARTS) {
this.logInfo(
`Capturing part ${partIndex + 1}... (${Math.round((capturedHeightPx / totalHeightPx) * 100)}%)`,
);
if (currentScrollYCss > 0) {
// Don't scroll for the first part if already at top
const scrollResp = await this.sendMessageToTab(tabId, {
action: TOOL_MESSAGE_TYPES.SCREENSHOT_SCROLL_PAGE,
x: 0,
y: currentScrollYCss,
scrollDelay: SCREENSHOT_CONSTANTS.SCROLL_DELAY_MS,
});
// Update currentScrollYCss based on actual scroll achieved
currentScrollYCss = scrollResp.newScrollY;
}
// Ensure rendering after scroll
await new Promise((resolve) =>
setTimeout(resolve, SCREENSHOT_CONSTANTS.CAPTURE_STITCH_DELAY_MS),
);
const dataUrl = await chrome.tabs.captureVisibleTab({ format: 'png' });
if (!dataUrl) throw new Error('captureVisibleTab returned empty during full page capture');
const yOffsetPx = currentScrollYCss * dpr;
capturedParts.push({ dataUrl, y: yOffsetPx });
const imgForHeight = await createImageBitmapFromUrl(dataUrl); // To get actual captured height
const lastPartEffectiveHeightPx = Math.min(imgForHeight.height, totalHeightPx - yOffsetPx);
capturedHeightPx = yOffsetPx + lastPartEffectiveHeightPx;
if (capturedHeightPx >= totalHeightPx - SCREENSHOT_CONSTANTS.PIXEL_TOLERANCE) break;
currentScrollYCss += viewportHeightCss;
// Prevent overscrolling past the document height for the next scroll command
if (
currentScrollYCss > totalHeightCss - viewportHeightCss &&
currentScrollYCss < totalHeightCss
) {
currentScrollYCss = totalHeightCss - viewportHeightCss;
}
partIndex++;
}
// Check if we hit any limits
if (partIndex >= SCREENSHOT_CONSTANTS.MAX_CAPTURE_PARTS) {
this.logInfo(
`Reached maximum number of capture parts (${SCREENSHOT_CONSTANTS.MAX_CAPTURE_PARTS}). This may be an infinite scroll page.`,
);
}
if (totalHeightCss > limitedHeightCss) {
this.logInfo(
`Page height (${totalHeightCss}px) exceeds maximum capture height (${maxHeightPx / dpr}px). Capturing limited portion.`,
);
}
this.logInfo('Stitching image...');
const finalCanvas = await stitchImages(capturedParts, totalWidthPx, totalHeightPx);
// If user specified width but not height (or vice versa for full page), resize maintaining aspect ratio
let outputCanvas = finalCanvas;
if (options.width && !options.height) {
const targetWidthPx = options.width * dpr;
const aspectRatio = finalCanvas.height / finalCanvas.width;
const targetHeightPx = targetWidthPx * aspectRatio;
outputCanvas = new OffscreenCanvas(targetWidthPx, targetHeightPx);
const ctx = outputCanvas.getContext('2d');
if (ctx) {
ctx.drawImage(finalCanvas, 0, 0, targetWidthPx, targetHeightPx);
}
} else if (options.height && !options.width) {
const targetHeightPx = options.height * dpr;
const aspectRatio = finalCanvas.width / finalCanvas.height;
const targetWidthPx = targetHeightPx * aspectRatio;
outputCanvas = new OffscreenCanvas(targetWidthPx, targetHeightPx);
const ctx = outputCanvas.getContext('2d');
if (ctx) {
ctx.drawImage(finalCanvas, 0, 0, targetWidthPx, targetHeightPx);
}
} else if (options.width && options.height) {
// Both specified, direct resize
const targetWidthPx = options.width * dpr;
const targetHeightPx = options.height * dpr;
outputCanvas = new OffscreenCanvas(targetWidthPx, targetHeightPx);
const ctx = outputCanvas.getContext('2d');
if (ctx) {
ctx.drawImage(finalCanvas, 0, 0, targetWidthPx, targetHeightPx);
}
}
return canvasToDataURL(outputCanvas);
}
}
export const screenshotTool = new ScreenshotTool();

View File

@@ -0,0 +1,308 @@
/**
* Vectorized tab content search tool
* Uses vector database for efficient semantic search
*/
import { createErrorResponse, ToolResult } from '@/common/tool-handler';
import { BaseBrowserToolExecutor } from '../base-browser';
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { ContentIndexer } from '@/utils/content-indexer';
import { LIMITS, ERROR_MESSAGES } from '@/common/constants';
import type { SearchResult } from '@/utils/vector-database';
interface VectorSearchResult {
tabId: number;
url: string;
title: string;
semanticScore: number;
matchedSnippet: string;
chunkSource: string;
timestamp: number;
}
/**
* Tool for vectorized search of tab content using semantic similarity
*/
class VectorSearchTabsContentTool extends BaseBrowserToolExecutor {
name = TOOL_NAMES.BROWSER.SEARCH_TABS_CONTENT;
private contentIndexer: ContentIndexer;
private isInitialized = false;
constructor() {
super();
this.contentIndexer = new ContentIndexer({
autoIndex: true,
maxChunksPerPage: LIMITS.MAX_SEARCH_RESULTS,
skipDuplicates: true,
});
}
private async initializeIndexer(): Promise<void> {
try {
await this.contentIndexer.initialize();
this.isInitialized = true;
console.log('VectorSearchTabsContentTool: Content indexer initialized successfully');
} catch (error) {
console.error('VectorSearchTabsContentTool: Failed to initialize content indexer:', error);
this.isInitialized = false;
}
}
async execute(args: { query: string }): Promise<ToolResult> {
try {
const { query } = args;
if (!query || query.trim().length === 0) {
return createErrorResponse(
ERROR_MESSAGES.INVALID_PARAMETERS + ': Query parameter is required and cannot be empty',
);
}
console.log(`VectorSearchTabsContentTool: Starting vector search with query: "${query}"`);
// Check semantic engine status
if (!this.contentIndexer.isSemanticEngineReady()) {
if (this.contentIndexer.isSemanticEngineInitializing()) {
return createErrorResponse(
'Vector search engine is still initializing (model downloading). Please wait a moment and try again.',
);
} else {
// Try to initialize
console.log('VectorSearchTabsContentTool: Initializing content indexer...');
await this.initializeIndexer();
// Check semantic engine status again
if (!this.contentIndexer.isSemanticEngineReady()) {
return createErrorResponse('Failed to initialize vector search engine');
}
}
}
// Execute vector search, get more results for deduplication
const searchResults = await this.contentIndexer.searchContent(query, 50);
// Convert search results format
const vectorSearchResults = this.convertSearchResults(searchResults);
// Deduplicate by tab, keep only the highest similarity fragment per tab
const deduplicatedResults = this.deduplicateByTab(vectorSearchResults);
// Sort by similarity and get top 10 results
const topResults = deduplicatedResults
.sort((a, b) => b.semanticScore - a.semanticScore)
.slice(0, 10);
// Get index statistics
const stats = this.contentIndexer.getStats();
const result = {
success: true,
totalTabsSearched: stats.totalTabs,
matchedTabsCount: topResults.length,
vectorSearchEnabled: true,
indexStats: {
totalDocuments: stats.totalDocuments,
totalTabs: stats.totalTabs,
indexedPages: stats.indexedPages,
semanticEngineReady: stats.semanticEngineReady,
semanticEngineInitializing: stats.semanticEngineInitializing,
},
matchedTabs: topResults.map((result) => ({
tabId: result.tabId,
url: result.url,
title: result.title,
semanticScore: result.semanticScore,
matchedSnippets: [result.matchedSnippet],
chunkSource: result.chunkSource,
timestamp: result.timestamp,
})),
};
console.log(
`VectorSearchTabsContentTool: Found ${topResults.length} results with vector search`,
);
return {
content: [
{
type: 'text',
text: JSON.stringify(result, null, 2),
},
],
isError: false,
};
} catch (error) {
console.error('VectorSearchTabsContentTool: Search failed:', error);
return createErrorResponse(
`Vector search failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Ensure all tabs are indexed
*/
private async ensureTabsIndexed(tabs: chrome.tabs.Tab[]): Promise<void> {
const indexPromises = tabs
.filter((tab) => tab.id)
.map(async (tab) => {
try {
await this.contentIndexer.indexTabContent(tab.id!);
} catch (error) {
console.warn(`VectorSearchTabsContentTool: Failed to index tab ${tab.id}:`, error);
}
});
await Promise.allSettled(indexPromises);
}
/**
* Convert search results format
*/
private convertSearchResults(searchResults: SearchResult[]): VectorSearchResult[] {
return searchResults.map((result) => ({
tabId: result.document.tabId,
url: result.document.url,
title: result.document.title,
semanticScore: result.similarity,
matchedSnippet: this.extractSnippet(result.document.chunk.text),
chunkSource: result.document.chunk.source,
timestamp: result.document.timestamp,
}));
}
/**
* Deduplicate by tab, keep only the highest similarity fragment per tab
*/
private deduplicateByTab(results: VectorSearchResult[]): VectorSearchResult[] {
const tabMap = new Map<number, VectorSearchResult>();
for (const result of results) {
const existingResult = tabMap.get(result.tabId);
// If this tab has no result yet, or current result has higher similarity, update it
if (!existingResult || result.semanticScore > existingResult.semanticScore) {
tabMap.set(result.tabId, result);
}
}
return Array.from(tabMap.values());
}
/**
* Extract text snippet for display
*/
private extractSnippet(text: string, maxLength: number = 200): string {
if (text.length <= maxLength) {
return text;
}
// Try to truncate at sentence boundary
const truncated = text.substring(0, maxLength);
const lastSentenceEnd = Math.max(
truncated.lastIndexOf('.'),
truncated.lastIndexOf('!'),
truncated.lastIndexOf('?'),
truncated.lastIndexOf('。'),
truncated.lastIndexOf(''),
truncated.lastIndexOf(''),
);
if (lastSentenceEnd > maxLength * 0.7) {
return truncated.substring(0, lastSentenceEnd + 1);
}
// If no suitable sentence boundary found, truncate at word boundary
const lastSpaceIndex = truncated.lastIndexOf(' ');
if (lastSpaceIndex > maxLength * 0.8) {
return truncated.substring(0, lastSpaceIndex) + '...';
}
return truncated + '...';
}
/**
* Get index statistics
*/
public async getIndexStats() {
if (!this.isInitialized) {
// Don't automatically initialize - just return basic stats
return {
totalDocuments: 0,
totalTabs: 0,
indexSize: 0,
indexedPages: 0,
isInitialized: false,
semanticEngineReady: false,
semanticEngineInitializing: false,
};
}
return this.contentIndexer.getStats();
}
/**
* Manually rebuild index
*/
public async rebuildIndex(): Promise<void> {
if (!this.isInitialized) {
await this.initializeIndexer();
}
try {
// Clear existing indexes
await this.contentIndexer.clearAllIndexes();
// Get all tabs and reindex
const windows = await chrome.windows.getAll({ populate: true });
const allTabs: chrome.tabs.Tab[] = [];
for (const window of windows) {
if (window.tabs) {
allTabs.push(...window.tabs);
}
}
const validTabs = allTabs.filter(
(tab) =>
tab.id &&
tab.url &&
!tab.url.startsWith('chrome://') &&
!tab.url.startsWith('chrome-extension://') &&
!tab.url.startsWith('edge://') &&
!tab.url.startsWith('about:'),
);
await this.ensureTabsIndexed(validTabs);
console.log(`VectorSearchTabsContentTool: Rebuilt index for ${validTabs.length} tabs`);
} catch (error) {
console.error('VectorSearchTabsContentTool: Failed to rebuild index:', error);
throw error;
}
}
/**
* Manually index specified tab
*/
public async indexTab(tabId: number): Promise<void> {
if (!this.isInitialized) {
await this.initializeIndexer();
}
await this.contentIndexer.indexTabContent(tabId);
}
/**
* Remove index for specified tab
*/
public async removeTabIndex(tabId: number): Promise<void> {
if (!this.isInitialized) {
return;
}
await this.contentIndexer.removeTabIndex(tabId);
}
}
// Export tool instance
export const vectorSearchTabsContentTool = new VectorSearchTabsContentTool();

View File

@@ -0,0 +1,229 @@
import { createErrorResponse, ToolResult } from '@/common/tool-handler';
import { BaseBrowserToolExecutor } from '../base-browser';
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { TOOL_MESSAGE_TYPES } from '@/common/message-types';
interface WebFetcherToolParams {
htmlContent?: boolean; // get the visible HTML content of the current page. default: false
textContent?: boolean; // get the visible text content of the current page. default: true
url?: string; // optional URL to fetch content from (if not provided, uses active tab)
selector?: string; // optional CSS selector to get content from a specific element
}
class WebFetcherTool extends BaseBrowserToolExecutor {
name = TOOL_NAMES.BROWSER.WEB_FETCHER;
/**
* Execute web fetcher operation
*/
async execute(args: WebFetcherToolParams): Promise<ToolResult> {
// Handle mutually exclusive parameters: if htmlContent is true, textContent is forced to false
const htmlContent = args.htmlContent === true;
const textContent = htmlContent ? false : args.textContent !== false; // Default is true, unless htmlContent is true or textContent is explicitly set to false
const url = args.url;
const selector = args.selector;
console.log(`Starting web fetcher with options:`, {
htmlContent,
textContent,
url,
selector,
});
try {
// Get tab to fetch content from
let tab;
if (url) {
// If URL is provided, check if it's already open
console.log(`Checking if URL is already open: ${url}`);
const allTabs = await chrome.tabs.query({});
// Find tab with matching URL
const matchingTabs = allTabs.filter((t) => {
// Normalize URLs for comparison (remove trailing slashes)
const tabUrl = t.url?.endsWith('/') ? t.url.slice(0, -1) : t.url;
const targetUrl = url.endsWith('/') ? url.slice(0, -1) : url;
return tabUrl === targetUrl;
});
if (matchingTabs.length > 0) {
// Use existing tab
tab = matchingTabs[0];
console.log(`Found existing tab with URL: ${url}, tab ID: ${tab.id}`);
} else {
// Create new tab with the URL
console.log(`No existing tab found with URL: ${url}, creating new tab`);
tab = await chrome.tabs.create({ url, active: true });
// Wait for page to load
console.log('Waiting for page to load...');
await new Promise((resolve) => setTimeout(resolve, 3000));
}
} else {
// Use active tab
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tabs[0]) {
return createErrorResponse('No active tab found');
}
tab = tabs[0];
}
if (!tab.id) {
return createErrorResponse('Tab has no ID');
}
// Make sure tab is active
await chrome.tabs.update(tab.id, { active: true });
// Prepare result object
const result: any = {
success: true,
url: tab.url,
title: tab.title,
};
await this.injectContentScript(tab.id, ['inject-scripts/web-fetcher-helper.js']);
// Get HTML content if requested
if (htmlContent) {
const htmlResponse = await this.sendMessageToTab(tab.id, {
action: TOOL_MESSAGE_TYPES.WEB_FETCHER_GET_HTML_CONTENT,
selector: selector,
});
if (htmlResponse.success) {
result.htmlContent = htmlResponse.htmlContent;
} else {
console.error('Failed to get HTML content:', htmlResponse.error);
result.htmlContentError = htmlResponse.error;
}
}
// Get text content if requested (and htmlContent is not true)
if (textContent) {
const textResponse = await this.sendMessageToTab(tab.id, {
action: TOOL_MESSAGE_TYPES.WEB_FETCHER_GET_TEXT_CONTENT,
selector: selector,
});
if (textResponse.success) {
result.textContent = textResponse.textContent;
// Include article metadata if available
if (textResponse.article) {
result.article = {
title: textResponse.article.title,
byline: textResponse.article.byline,
siteName: textResponse.article.siteName,
excerpt: textResponse.article.excerpt,
lang: textResponse.article.lang,
};
}
// Include page metadata if available
if (textResponse.metadata) {
result.metadata = textResponse.metadata;
}
} else {
console.error('Failed to get text content:', textResponse.error);
result.textContentError = textResponse.error;
}
}
// Interactive elements feature has been removed
return {
content: [
{
type: 'text',
text: JSON.stringify(result),
},
],
isError: false,
};
} catch (error) {
console.error('Error in web fetcher:', error);
return createErrorResponse(
`Error fetching web content: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
export const webFetcherTool = new WebFetcherTool();
interface GetInteractiveElementsToolParams {
textQuery?: string; // Text to search for within interactive elements (fuzzy search)
selector?: string; // CSS selector to filter interactive elements
includeCoordinates?: boolean; // Include element coordinates in the response (default: true)
types?: string[]; // Types of interactive elements to include (default: all types)
}
class GetInteractiveElementsTool extends BaseBrowserToolExecutor {
name = TOOL_NAMES.BROWSER.GET_INTERACTIVE_ELEMENTS;
/**
* Execute get interactive elements operation
*/
async execute(args: GetInteractiveElementsToolParams): Promise<ToolResult> {
const { textQuery, selector, includeCoordinates = true, types } = args;
console.log(`Starting get interactive elements with options:`, args);
try {
// Get current tab
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tabs[0]) {
return createErrorResponse('No active tab found');
}
const tab = tabs[0];
if (!tab.id) {
return createErrorResponse('Active tab has no ID');
}
// Ensure content script is injected
await this.injectContentScript(tab.id, ['inject-scripts/interactive-elements-helper.js']);
// Send message to content script
const result = await this.sendMessageToTab(tab.id, {
action: TOOL_MESSAGE_TYPES.GET_INTERACTIVE_ELEMENTS,
textQuery,
selector,
includeCoordinates,
types,
});
if (!result.success) {
return createErrorResponse(result.error || 'Failed to get interactive elements');
}
return {
content: [
{
type: 'text',
text: JSON.stringify({
success: true,
elements: result.elements,
count: result.elements.length,
query: {
textQuery,
selector,
types: types || 'all',
},
}),
},
],
isError: false,
};
} catch (error) {
console.error('Error in get interactive elements operation:', error);
return createErrorResponse(
`Error getting interactive elements: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
export const getInteractiveElementsTool = new GetInteractiveElementsTool();

View File

@@ -0,0 +1,54 @@
import { createErrorResponse, ToolResult } from '@/common/tool-handler';
import { BaseBrowserToolExecutor } from '../base-browser';
import { TOOL_NAMES } from 'chrome-mcp-shared';
class WindowTool extends BaseBrowserToolExecutor {
name = TOOL_NAMES.BROWSER.GET_WINDOWS_AND_TABS;
async execute(): Promise<ToolResult> {
try {
const windows = await chrome.windows.getAll({ populate: true });
let tabCount = 0;
const structuredWindows = windows.map((window) => {
const tabs =
window.tabs?.map((tab) => {
tabCount++;
return {
tabId: tab.id || 0,
url: tab.url || '',
title: tab.title || '',
active: tab.active || false,
};
}) || [];
return {
windowId: window.id || 0,
tabs: tabs,
};
});
const result = {
windowCount: windows.length,
tabCount: tabCount,
windows: structuredWindows,
};
return {
content: [
{
type: 'text',
text: JSON.stringify(result),
},
],
isError: false,
};
} catch (error) {
console.error('Error in WindowTool.execute:', error);
return createErrorResponse(
`Error getting windows and tabs information: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
export const windowTool = new WindowTool();

View File

@@ -0,0 +1,33 @@
import { createErrorResponse } from '@/common/tool-handler';
import { ERROR_MESSAGES } from '@/common/constants';
import * as browserTools from './browser';
const tools = { ...browserTools };
const toolsMap = new Map(Object.values(tools).map((tool) => [tool.name, tool]));
/**
* Tool call parameter interface
*/
export interface ToolCallParam {
name: string;
args: any;
}
/**
* Handle tool execution
*/
export const handleCallTool = async (param: ToolCallParam) => {
const tool = toolsMap.get(param.name);
if (!tool) {
return createErrorResponse(`Tool ${param.name} not found`);
}
try {
return await tool.execute(param.args);
} catch (error) {
console.error(`Tool execution failed for ${param.name}:`, error);
return createErrorResponse(
error instanceof Error ? error.message : ERROR_MESSAGES.TOOL_EXECUTION_FAILED,
);
}
};