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 { 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 { 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 { 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 { // 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 { 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 { 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 { 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();