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<void> {
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<ToolResult> {
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<string>();
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<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}`);
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<ToolResult> {
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();