hangwin--mcp-chrome
ab23b65626
Fixed inconsistent key references throughout the codebase where getMessage() calls were using legacy keys without "Label", "Button", "Status" suffixes while messages.json files contained the correct keys with these suffixes. Changes: - App.vue: Fixed 50+ getMessage() calls to use correct keys - ConfirmDialog.vue: Fixed button text keys - ModelCacheManagement.vue: Fixed cache management UI keys - bookmark.ts: Fixed bookmarksBarLabel key reference - Replaced hardcoded Chinese strings with proper i18n calls This ensures the i18n system works correctly with both Chrome's i18n API and the fallback system, displaying proper localized text in all languages. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
603 行
20 KiB
TypeScript
603 行
20 KiB
TypeScript
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();
|