import { createErrorResponse, ToolResult } from '@/common/tool-handler'; import { BaseBrowserToolExecutor } from '../base-browser'; import { TOOL_NAMES } from 'chrome-mcp-shared'; import { captureFrameOnAction, isAutoCaptureActive } from './gif-recorder'; // 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; tabId?: number; windowId?: number; background?: boolean; // when true, do not activate tab or focus window } /** * Tool for navigating to URLs in browser tabs or windows */ class NavigateTool extends BaseBrowserToolExecutor { name = TOOL_NAMES.BROWSER.NAVIGATE; /** * Trigger GIF auto-capture after successful navigation */ private async triggerAutoCapture(tabId: number, url?: string): Promise { if (!isAutoCaptureActive(tabId)) { return; } try { await captureFrameOnAction(tabId, { type: 'navigate', url }); } catch (error) { console.warn('[NavigateTool] Auto-capture failed:', error); } } async execute(args: NavigateToolParams): Promise { const { newWindow = false, width, height, url, refresh = false, tabId, background, windowId, } = 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'); const explicit = await this.tryGetTab(tabId); // Get target tab (explicit or active in provided window) const targetTab = explicit || (await this.getActiveTabOrThrowInWindow(windowId)); if (!targetTab.id) return createErrorResponse('No target tab found to refresh'); await chrome.tabs.reload(targetTab.id); console.log(`Refreshed tab ID: ${targetTab.id}`); // Get updated tab information const updatedTab = await chrome.tabs.get(targetTab.id); // Trigger auto-capture on refresh await this.triggerAutoCapture(updatedTab.id!, updatedTab.url); 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'); } // Handle history navigation: url="back" or url="forward" if (url === 'back' || url === 'forward') { const explicitTab = await this.tryGetTab(tabId); const targetTab = explicitTab || (await this.getActiveTabOrThrowInWindow(windowId)); if (!targetTab.id) { return createErrorResponse('No target tab found for history navigation'); } // Respect background flag for focus behavior await this.ensureFocus(targetTab, { activate: background !== true, focusWindow: background !== true, }); if (url === 'forward') { await chrome.tabs.goForward(targetTab.id); console.log(`Navigated forward in tab ID: ${targetTab.id}`); } else { await chrome.tabs.goBack(targetTab.id); console.log(`Navigated back in tab ID: ${targetTab.id}`); } const updatedTab = await chrome.tabs.get(targetTab.id); // Trigger auto-capture on history navigation await this.triggerAutoCapture(updatedTab.id!, updatedTab.url); return { content: [ { type: 'text', text: JSON.stringify({ success: true, message: `Successfully navigated ${url} in browser history`, tabId: updatedTab.id, windowId: updatedTab.windowId, url: updatedTab.url, }), }, ], isError: false, }; } // 1. Check if URL is already open // Prefer Chrome's URL match patterns for robust matching (host/path variations) console.log(`Checking if URL is already open: ${url}`); // Build robust match patterns from the provided URL. // This mirrors the approach in CloseTabsTool: ensure wildcard path and // add common variants (www/no-www, http/https) to handle real-world redirects. const buildUrlPatterns = (input: string): string[] => { const patterns = new Set(); try { if (!input.includes('*')) { const u = new URL(input); // Use host-level wildcard to include all paths; we'll do precise selection later const pathWildcard = '/*'; const hostNoWww = u.host.replace(/^www\./, ''); const hostWithWww = hostNoWww.startsWith('www.') ? hostNoWww : `www.${hostNoWww}`; // Keep original host patterns.add(`${u.protocol}//${u.host}${pathWildcard}`); // Add no-www variant patterns.add(`${u.protocol}//${hostNoWww}${pathWildcard}`); // Add www variant patterns.add(`${u.protocol}//${hostWithWww}${pathWildcard}`); // Add protocol variant to catch http↔https redirects const altProtocol = u.protocol === 'https:' ? 'http:' : 'https:'; patterns.add(`${altProtocol}//${u.host}${pathWildcard}`); patterns.add(`${altProtocol}//${hostNoWww}${pathWildcard}`); patterns.add(`${altProtocol}//${hostWithWww}${pathWildcard}`); } else { patterns.add(input); } } catch { // Fallback: best-effort wildcard suffix patterns.add(input.endsWith('/') ? `${input}*` : `${input}/*`); } return Array.from(patterns); }; const urlPatterns = buildUrlPatterns(url); const candidateTabs = await chrome.tabs.query({ url: urlPatterns }); console.log(`Found ${candidateTabs.length} matching tabs with patterns:`, urlPatterns); // Prefer strict match when user specifies a concrete path/query. // Only fall back to host-level activation when the target is site root. const pickBestMatch = (target: string, tabsToPick: chrome.tabs.Tab[]) => { let targetUrl: URL | undefined; try { targetUrl = new URL(target); } catch { // Not a fully-qualified URL; cannot do structured comparison return tabsToPick[0]; } const normalizePath = (p: string) => { if (!p) return '/'; // Ensure leading slash const withLeading = p.startsWith('/') ? p : `/${p}`; // Remove trailing slash except when root return withLeading !== '/' && withLeading.endsWith('/') ? withLeading.slice(0, -1) : withLeading; }; const hostBase = (h: string) => h.replace(/^www\./, '').toLowerCase(); const isRootTarget = normalizePath(targetUrl.pathname) === '/' && !targetUrl.search; const targetPath = normalizePath(targetUrl.pathname); const targetSearch = targetUrl.search || ''; const targetHostBase = hostBase(targetUrl.host); let best: { tab?: chrome.tabs.Tab; score: number } = { score: -1 }; for (const tab of tabsToPick) { const tabUrlStr = tab.url || ''; let tabUrl: URL | undefined; try { tabUrl = new URL(tabUrlStr); } catch { continue; } const tabHostBase = hostBase(tabUrl.host); if (tabHostBase !== targetHostBase) continue; const tabPath = normalizePath(tabUrl.pathname); const tabSearch = tabUrl.search || ''; // Scoring: // 3 - exact path match and (if target has query) exact query match // 2 - exact path match ignoring query (target without query) // 1 - same host, any path (only if target is root) let score = -1; const pathEqual = tabPath === targetPath; const searchEqual = tabSearch === targetSearch; if (pathEqual && (targetSearch ? searchEqual : true)) { score = 3; } else if (pathEqual && !targetSearch) { score = 2; } if (score > best.score) { best = { tab, score }; if (score === 3) break; // Cannot do better } } return best.tab; }; const explicitTab = await this.tryGetTab(tabId); const existingTab = explicitTab || pickBestMatch(url, candidateTabs); if (existingTab?.id !== undefined) { console.log( `URL already open in Tab ID: ${existingTab.id}, Window ID: ${existingTab.windowId}`, ); // Update URL only when explicit tab specified and url differs if (explicitTab && typeof explicitTab.id === 'number') { await chrome.tabs.update(explicitTab.id, { url }); } // Optionally bring to foreground based on background flag await this.ensureFocus(existingTab, { activate: background !== true, focusWindow: background !== true, }); console.log(`Activated existing Tab ID: ${existingTab.id}`); // Get updated tab information and return it const updatedTab = await chrome.tabs.get(existingTab.id); // Trigger auto-capture on existing tab activation await this.triggerAutoCapture(updatedTab.id!, updatedTab.url); 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: background === true ? false : true, }); if (newWindow && newWindow.id !== undefined) { console.log(`URL opened in new Window ID: ${newWindow.id}`); // Trigger auto-capture if the new window has a tab const firstTab = newWindow.tabs?.[0]; if (firstTab?.id) { await this.triggerAutoCapture(firstTab.id, firstTab.url); } 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 specified window, otherwise the most recently active window let targetWindow: chrome.windows.Window | null = null; if (typeof windowId === 'number') { targetWindow = await chrome.windows.get(windowId, { populate: false }); } if (!targetWindow) { targetWindow = await chrome.windows.getLastFocused({ populate: false }); } if (targetWindow && targetWindow.id !== undefined) { console.log(`Found target Window ID: ${targetWindow.id}`); const newTab = await chrome.tabs.create({ url: url, windowId: targetWindow.id, active: background === true ? false : true, }); if (background !== true) { await chrome.windows.update(targetWindow.id, { focused: true }); } console.log( `URL opened in new Tab ID: ${newTab.id} in existing Window ID: ${targetWindow.id}`, ); // Trigger auto-capture on new tab if (newTab.id) { await this.triggerAutoCapture(newTab.id, newTab.url); } return { content: [ { type: 'text', text: JSON.stringify({ success: true, message: 'Opened URL in new tab in existing window', tabId: newTab.id, windowId: targetWindow.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}`); // Trigger auto-capture if fallback window has a tab const firstTab = fallbackWindow.tabs?.[0]; if (firstTab?.id) { await this.triggerAutoCapture(firstTab.id, firstTab.url); } 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 { 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}`); try { // Build a proper Chrome match pattern from a concrete URL. // If caller already provided a match pattern with '*', use as-is. if (!urlPattern.includes('*')) { // Ignore search/hash; match by origin + pathname prefix. // Use URL to normalize; fallback to simple suffixing when parsing fails. try { const u = new URL(urlPattern); const basePath = u.pathname || '/'; const pathWithWildcard = basePath.endsWith('/') ? `${basePath}*` : `${basePath}/*`; urlPattern = `${u.protocol}//${u.host}${pathWithWildcard}`; } catch { // Not a fully-qualified URL; ensure it ends with wildcard urlPattern = urlPattern.endsWith('/') ? `${urlPattern}*` : `${urlPattern}/*`; } } } catch { // Best-effort: ensure we have some wildcard urlPattern = urlPattern.endsWith('*') ? urlPattern : urlPattern.endsWith('/') ? `${urlPattern}*` : `${urlPattern}/*`; } const tabs = await chrome.tabs.query({ url: urlPattern }); if (!tabs || tabs.length === 0) { console.log(`No tabs found with URL pattern: ${urlPattern}`); return { content: [ { type: 'text', text: JSON.stringify({ success: false, message: `No tabs found with URL pattern: ${urlPattern}`, closedCount: 0, }), }, ], isError: false, }; } console.log(`Found ${tabs.length} tabs with URL pattern: ${urlPattern}`); 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 SwitchTabToolParams { tabId: number; windowId?: number; } /** * Tool for switching the active tab */ class SwitchTabTool extends BaseBrowserToolExecutor { name = TOOL_NAMES.BROWSER.SWITCH_TAB; async execute(args: SwitchTabToolParams): Promise { const { tabId, windowId } = args; console.log(`Attempting to switch to tab ID: ${tabId} in window ID: ${windowId}`); try { if (windowId !== undefined) { await chrome.windows.update(windowId, { focused: true }); } await chrome.tabs.update(tabId, { active: true }); const updatedTab = await chrome.tabs.get(tabId); return { content: [ { type: 'text', text: JSON.stringify({ success: true, message: `Successfully switched to tab ID: ${tabId}`, 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 SwitchTabTool.execute:', error); return createErrorResponse( `Error switching tab: ${error instanceof Error ? error.message : String(error)}`, ); } } } } export const switchTabTool = new SwitchTabTool();