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,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();