diff --git a/src/tools/console.ts b/src/tools/console.ts index 59b2c9d..29aebe5 100644 --- a/src/tools/console.ts +++ b/src/tools/console.ts @@ -4,7 +4,6 @@ import { successResponse, - errorResponse, jsonResponse, TOKEN_LIMITS, truncateText, @@ -12,7 +11,7 @@ import { truncationFooter, } from '../utils/response-helpers.js'; import { saveOutput } from '../utils/save-output.js'; -import { defineModule, type ToolDefinition } from './module.js'; +import { defineModule, defineToolHandler, type ToolDefinition } from './module.js'; import type { McpToolResponse } from '../types/common.js'; export const listConsoleMessagesTool = { @@ -91,8 +90,8 @@ function formatMessageLine(msg: { return `${time}${msg.level.toUpperCase()}${source}: ${msg.text}`; } -export async function handleListConsoleMessages(args: unknown): Promise { - try { +export const handleListConsoleMessages = defineToolHandler( + async (args: unknown): Promise => { const { level, limit, @@ -286,13 +285,11 @@ export async function handleListConsoleMessages(args: unknown): Promise { - try { +export const handleClearConsoleMessages = defineToolHandler( + async (_args: unknown): Promise => { const { getFirefox } = await import('../index.js'); const firefox = await getFirefox(); @@ -300,10 +297,8 @@ export async function handleClearConsoleMessages(_args: unknown): Promise { - try { +export const handleEnableDebugger = defineToolHandler( + async (_args: unknown): Promise => { const { getFirefox } = await import('../index.js'); const firefox = await getFirefox(); requireDebuggingSupport(firefox); await firefox.sendBiDiCommand('moz:debugging.setDebuggerEnabled', { enabled: true }); return successResponse('Debugger enabled'); - } catch (error) { - return errorResponse(error as Error); } -} +); -export async function handleListScripts(_args: unknown): Promise { - try { +export const handleListScripts = defineToolHandler( + async (_args: unknown): Promise => { const { getFirefox } = await import('../index.js'); const firefox = await getFirefox(); requireDebuggingSupport(firefox); @@ -143,13 +141,11 @@ export async function handleListScripts(_args: unknown): Promise { - try { +export const handleGetScriptSource = defineToolHandler( + async (args: unknown): Promise => { const { scriptUrl } = args as { scriptUrl: string }; const { getFirefox } = await import('../index.js'); const firefox = await getFirefox(); @@ -160,39 +156,33 @@ export async function handleGetScriptSource(args: unknown): Promise { - try { +export const handleSetLogpoint = defineToolHandler( + async (args: unknown): Promise => { const { url, line, expression } = args as { url: string; line: number; expression: string }; const { getFirefox } = await import('../index.js'); const firefox = await getFirefox(); requireDebuggingSupport(firefox); const logpointId = await firefox.setLogpoint(url, line, expression); return successResponse(`Logpoint set (id: ${logpointId})`); - } catch (error) { - return errorResponse(error as Error); } -} +); -export async function handleRemoveLogpoint(args: unknown): Promise { - try { +export const handleRemoveLogpoint = defineToolHandler( + async (args: unknown): Promise => { const { logpoint } = args as { logpoint: string }; const { getFirefox } = await import('../index.js'); const firefox = await getFirefox(); requireDebuggingSupport(firefox); await firefox.removeLogpoint(logpoint); return successResponse('Logpoint removed'); - } catch (error) { - return errorResponse(error as Error); } -} +); -export async function handleGetLogpointResults(args: unknown): Promise { - try { +export const handleGetLogpointResults = defineToolHandler( + async (args: unknown): Promise => { const { logpoint } = args as { logpoint: string }; const { getFirefox } = await import('../index.js'); const firefox = await getFirefox(); @@ -211,10 +201,8 @@ export async function handleGetLogpointResults(args: unknown): Promise { - try { - const { - status, - urlContains, - limit = 50, - format = 'text', - } = (args ?? {}) as { - status?: string; - urlContains?: string; - limit?: number; - format?: string; - }; +export const handleListDownloads = defineToolHandler(async function handleListDownloads( + args: unknown +): Promise { + const { + status, + urlContains, + limit = 50, + format = 'text', + } = (args ?? {}) as { + status?: string; + urlContains?: string; + limit?: number; + format?: string; + }; + + const { getFirefox } = await import('../index.js'); + const firefox = await getFirefox(); + let downloads = firefox.getDownloads(); + + if (status) { + downloads = downloads.filter((d) => d.status === status); + } + if (urlContains) { + const needle = urlContains.toLowerCase(); + downloads = downloads.filter((d) => (d.url || '').toLowerCase().includes(needle)); + } - const { getFirefox } = await import('../index.js'); - const firefox = await getFirefox(); - let downloads = firefox.getDownloads(); - - if (status) { - downloads = downloads.filter((d) => d.status === status); - } - if (urlContains) { - const needle = urlContains.toLowerCase(); - downloads = downloads.filter((d) => (d.url || '').toLowerCase().includes(needle)); - } - - downloads = downloads - .sort((a, b) => (b.startTimestamp || 0) - (a.startTimestamp || 0)) - .slice(0, limit); - - if (format === 'json') { - return jsonResponse(downloads); - } - - if (downloads.length === 0) { - return successResponse('No downloads tracked.'); - } - - const lines = downloads.map((d) => { - const where = d.filepath ? ` -> ${d.filepath}` : ''; - return `[${d.status}] ${d.suggestedFilename || d.url}${where}`; - }); - return successResponse(lines.join('\n')); - } catch (error) { - return errorResponse(error instanceof Error ? error.message : String(error)); + downloads = downloads + .sort((a, b) => (b.startTimestamp || 0) - (a.startTimestamp || 0)) + .slice(0, limit); + + if (format === 'json') { + return jsonResponse(downloads); + } + + if (downloads.length === 0) { + return successResponse('No downloads tracked.'); } -} -export async function handleClearDownloads(): Promise { - try { + const lines = downloads.map((d) => { + const where = d.filepath ? ` -> ${d.filepath}` : ''; + return `[${d.status}] ${d.suggestedFilename || d.url}${where}`; + }); + return successResponse(lines.join('\n')); +}); + +export const handleClearDownloads = defineToolHandler( + async function handleClearDownloads(): Promise { const { getFirefox } = await import('../index.js'); const firefox = await getFirefox(); firefox.clearDownloads(); return successResponse('Downloads cleared.'); - } catch (error) { - return errorResponse(error instanceof Error ? error.message : String(error)); } -} +); -export async function handleSetDownloadBehavior(args: unknown): Promise { - try { - const { behavior } = (args ?? {}) as { - behavior?: 'allowed' | 'denied' | 'default'; - }; +export const handleSetDownloadBehavior = defineToolHandler(async function handleSetDownloadBehavior( + args: unknown +): Promise { + const { behavior } = (args ?? {}) as { + behavior?: 'allowed' | 'denied' | 'default'; + }; - if (!behavior) { - return errorResponse('behavior is required'); - } + if (!behavior) { + return errorResponse('behavior is required'); + } - const { getFirefox } = await import('../index.js'); - const firefox = await getFirefox(); - await firefox.setDownloadBehavior(behavior); + const { getFirefox } = await import('../index.js'); + const firefox = await getFirefox(); + await firefox.setDownloadBehavior(behavior); - return successResponse(`Download behavior set to '${behavior}'.`); - } catch (error) { - return errorResponse(error instanceof Error ? error.message : String(error)); - } -} + return successResponse(`Download behavior set to '${behavior}'.`); +}); export const module = defineModule({ name: 'downloads', diff --git a/src/tools/firefox-management.ts b/src/tools/firefox-management.ts index e8310db..e2b9e80 100644 --- a/src/tools/firefox-management.ts +++ b/src/tools/firefox-management.ts @@ -5,7 +5,7 @@ import { readFileSync, existsSync, statSync } from 'node:fs'; import { errorResponse, successResponse } from '../utils/response-helpers.js'; -import { defineModule, type ToolDefinition } from './module.js'; +import { defineModule, defineToolHandler, type ToolDefinition } from './module.js'; // ============================================================================ // Tool: get_firefox_logs @@ -37,74 +37,70 @@ export const getFirefoxLogsTool = { }, } satisfies ToolDefinition; -export async function handleGetFirefoxLogs(input: unknown) { - try { - const { - lines = 100, - grep, - since, - } = input as { - lines?: number; - grep?: string; - since?: number; - }; +export const handleGetFirefoxLogs = defineToolHandler(async (input: unknown) => { + const { + lines = 100, + grep, + since, + } = input as { + lines?: number; + grep?: string; + since?: number; + }; + + const { getFirefox } = await import('../index.js'); + const firefox = await getFirefox(); + const logFilePath = firefox.getLogFilePath(); + + if (!logFilePath) { + return successResponse( + 'No output capture configured. Use --env to set environment variables or --output-file to enable output capture.' + ); + } - const { getFirefox } = await import('../index.js'); - const firefox = await getFirefox(); - const logFilePath = firefox.getLogFilePath(); + if (!existsSync(logFilePath)) { + return successResponse(`Output file not found: ${logFilePath}`); + } - if (!logFilePath) { + // Check file age if 'since' filter is used + if (since !== undefined) { + const stats = statSync(logFilePath); + const ageSeconds = (Date.now() - stats.mtimeMs) / 1000; + if (ageSeconds > since) { return successResponse( - 'No output capture configured. Use --env to set environment variables or --output-file to enable output capture.' + `Output file is ${Math.floor(ageSeconds)}s old, but only output from last ${since}s was requested. File may not have recent entries.` ); } + } - if (!existsSync(logFilePath)) { - return successResponse(`Output file not found: ${logFilePath}`); - } - - // Check file age if 'since' filter is used - if (since !== undefined) { - const stats = statSync(logFilePath); - const ageSeconds = (Date.now() - stats.mtimeMs) / 1000; - if (ageSeconds > since) { - return successResponse( - `Output file is ${Math.floor(ageSeconds)}s old, but only output from last ${since}s was requested. File may not have recent entries.` - ); - } - } - - // Read output file - const content = readFileSync(logFilePath, 'utf-8'); - let allLines = content.split('\n').filter((line) => line.trim().length > 0); - - // Apply grep filter - if (grep) { - const grepLower = grep.toLowerCase(); - allLines = allLines.filter((line) => line.toLowerCase().includes(grepLower)); - } + // Read output file + const content = readFileSync(logFilePath, 'utf-8'); + let allLines = content.split('\n').filter((line) => line.trim().length > 0); - // Get last N lines - const maxLines = Math.min(lines, 10000); - const recentLines = allLines.slice(-maxLines); - - const result = [ - `Firefox Output File: ${logFilePath}`, - `Total lines in file: ${allLines.length}`, - grep ? `Lines matching "${grep}": ${allLines.length}` : '', - `Showing last ${recentLines.length} lines:`, - '', - '─'.repeat(80), - recentLines.join('\n'), - ] - .filter(Boolean) - .join('\n'); - - return successResponse(result); - } catch (error) { - return errorResponse(error as Error); + // Apply grep filter + if (grep) { + const grepLower = grep.toLowerCase(); + allLines = allLines.filter((line) => line.toLowerCase().includes(grepLower)); } -} + + // Get last N lines + const maxLines = Math.min(lines, 10000); + const recentLines = allLines.slice(-maxLines); + + const result = [ + `Firefox Output File: ${logFilePath}`, + `Total lines in file: ${allLines.length}`, + grep ? `Lines matching "${grep}": ${allLines.length}` : '', + `Showing last ${recentLines.length} lines:`, + '', + '─'.repeat(80), + recentLines.join('\n'), + ] + .filter(Boolean) + .join('\n'); + + return successResponse(result); +}); // ============================================================================ // Tool: get_firefox_info @@ -123,72 +119,68 @@ export const getFirefoxInfoTool = { }, } satisfies ToolDefinition; -export async function handleGetFirefoxInfo(_input: unknown) { - try { - const { getFirefox } = await import('../index.js'); - const firefox = await getFirefox(); - const options = firefox.getOptions(); - const logFilePath = firefox.getLogFilePath(); - const version = firefox.getFirefoxVersion(); +export const handleGetFirefoxInfo = defineToolHandler(async (_input: unknown) => { + const { getFirefox } = await import('../index.js'); + const firefox = await getFirefox(); + const options = firefox.getOptions(); + const logFilePath = firefox.getLogFilePath(); + const version = firefox.getFirefoxVersion(); - const info = []; - info.push('Firefox Instance Configuration'); - info.push(''); + const info = []; + info.push('Firefox Instance Configuration'); + info.push(''); - info.push(`Binary: ${options.firefoxPath ?? 'System Firefox (default)'}`); - info.push(`Firefox version: ${version ?? '(unknown)'}`); - info.push(`Headless: ${options.headless ? 'Yes' : 'No'}`); + info.push(`Binary: ${options.firefoxPath ?? 'System Firefox (default)'}`); + info.push(`Firefox version: ${version ?? '(unknown)'}`); + info.push(`Headless: ${options.headless ? 'Yes' : 'No'}`); - if (options.viewport) { - info.push(`Viewport: ${options.viewport.width}x${options.viewport.height}`); - } + if (options.viewport) { + info.push(`Viewport: ${options.viewport.width}x${options.viewport.height}`); + } - if (options.profilePath) { - info.push(`Profile: ${options.profilePath}`); - } + if (options.profilePath) { + info.push(`Profile: ${options.profilePath}`); + } - if (options.startUrl) { - info.push(`Start URL: ${options.startUrl}`); - } + if (options.startUrl) { + info.push(`Start URL: ${options.startUrl}`); + } - if (options.args && options.args.length > 0) { - info.push(`Arguments: ${options.args.join(' ')}`); - } + if (options.args && options.args.length > 0) { + info.push(`Arguments: ${options.args.join(' ')}`); + } - if (options.env && Object.keys(options.env).length > 0) { - info.push(''); - info.push('Environment Variables:'); - for (const [key, value] of Object.entries(options.env)) { - info.push(` ${key}=${value}`); - } + if (options.env && Object.keys(options.env).length > 0) { + info.push(''); + info.push('Environment Variables:'); + for (const [key, value] of Object.entries(options.env)) { + info.push(` ${key}=${value}`); } + } - if (options.prefs && Object.keys(options.prefs).length > 0) { - info.push(''); - info.push('Preferences:'); - for (const [key, value] of Object.entries(options.prefs)) { - info.push(` ${key} = ${JSON.stringify(value)}`); - } + if (options.prefs && Object.keys(options.prefs).length > 0) { + info.push(''); + info.push('Preferences:'); + for (const [key, value] of Object.entries(options.prefs)) { + info.push(` ${key} = ${JSON.stringify(value)}`); } + } - if (logFilePath) { - info.push(''); - info.push(`Output File: ${logFilePath}`); - if (existsSync(logFilePath)) { - const stats = statSync(logFilePath); - const sizeMB = (stats.size / 1024 / 1024).toFixed(2); - info.push(` Size: ${sizeMB} MB`); - info.push(` Last Modified: ${stats.mtime.toISOString()}`); - } else { - info.push(' (file not created yet)'); - } + if (logFilePath) { + info.push(''); + info.push(`Output File: ${logFilePath}`); + if (existsSync(logFilePath)) { + const stats = statSync(logFilePath); + const sizeMB = (stats.size / 1024 / 1024).toFixed(2); + info.push(` Size: ${sizeMB} MB`); + info.push(` Last Modified: ${stats.mtime.toISOString()}`); + } else { + info.push(' (file not created yet)'); } - - return successResponse(info.join('\n')); - } catch (error) { - return errorResponse(error as Error); } -} + + return successResponse(info.join('\n')); +}); // ============================================================================ // Tool: restart_firefox @@ -241,152 +233,148 @@ export const restartFirefoxTool = { }, } satisfies ToolDefinition; -export async function handleRestartFirefox(input: unknown) { - try { - const { firefoxPath, profilePath, env, headless, startUrl, prefs } = input as { - firefoxPath?: string; - profilePath?: string; - env?: string[]; - headless?: boolean; - startUrl?: string; - prefs?: Record; - }; - - const { args, getFirefoxIfRunning, resetFirefox, setNextLaunchOptions } = await import( - '../index.js' - ); - - // This tool is designed to be robust and never get stuck: - // - Handles disconnected Firefox gracefully (resets stale reference) - // - Handles close() errors (we're restarting anyway) - // - Works both as initial start and restart - // - Always leaves system in a clean state for next tool call - - // Parse new environment variables - let newEnv: Record | undefined; - if (env && Array.isArray(env) && env.length > 0) { - newEnv = {}; - for (const envStr of env) { - const [key, ...valueParts] = envStr.split('='); - if (key && valueParts.length > 0) { - newEnv[key] = valueParts.join('='); - } +export const handleRestartFirefox = defineToolHandler(async (input: unknown) => { + const { firefoxPath, profilePath, env, headless, startUrl, prefs } = input as { + firefoxPath?: string; + profilePath?: string; + env?: string[]; + headless?: boolean; + startUrl?: string; + prefs?: Record; + }; + + const { args, getFirefoxIfRunning, resetFirefox, setNextLaunchOptions } = await import( + '../index.js' + ); + + // This tool is designed to be robust and never get stuck: + // - Handles disconnected Firefox gracefully (resets stale reference) + // - Handles close() errors (we're restarting anyway) + // - Works both as initial start and restart + // - Always leaves system in a clean state for next tool call + + // Parse new environment variables + let newEnv: Record | undefined; + if (env && Array.isArray(env) && env.length > 0) { + newEnv = {}; + for (const envStr of env) { + const [key, ...valueParts] = envStr.split('='); + if (key && valueParts.length > 0) { + newEnv[key] = valueParts.join('='); } } + } - // Check if Firefox is currently running and connected - const currentFirefox = getFirefoxIfRunning(); - const isConnected = currentFirefox ? await currentFirefox.ensureConnected() : false; - - if (currentFirefox && isConnected) { - // Firefox is running - restart with new config - const currentOptions = currentFirefox.getOptions(); - - // Merge prefs: combine existing with new, new takes precedence - const mergedPrefs = - prefs !== undefined ? { ...(currentOptions.prefs || {}), ...prefs } : currentOptions.prefs; - - // Merge with current options, preferring new values - const newOptions = { - ...currentOptions, - firefoxPath: firefoxPath ?? currentOptions.firefoxPath, - profilePath: profilePath ?? currentOptions.profilePath, - env: newEnv !== undefined ? newEnv : currentOptions.env, - headless: headless !== undefined ? headless : currentOptions.headless, - startUrl: startUrl ?? currentOptions.startUrl ?? 'about:blank', - prefs: mergedPrefs, - }; - - // Set options for next launch - setNextLaunchOptions(newOptions); - - // Close current instance - await resetFirefox(); + // Check if Firefox is currently running and connected + const currentFirefox = getFirefoxIfRunning(); + const isConnected = currentFirefox ? await currentFirefox.ensureConnected() : false; + + if (currentFirefox && isConnected) { + // Firefox is running - restart with new config + const currentOptions = currentFirefox.getOptions(); + + // Merge prefs: combine existing with new, new takes precedence + const mergedPrefs = + prefs !== undefined ? { ...(currentOptions.prefs || {}), ...prefs } : currentOptions.prefs; + + // Merge with current options, preferring new values + const newOptions = { + ...currentOptions, + firefoxPath: firefoxPath ?? currentOptions.firefoxPath, + profilePath: profilePath ?? currentOptions.profilePath, + env: newEnv !== undefined ? newEnv : currentOptions.env, + headless: headless !== undefined ? headless : currentOptions.headless, + startUrl: startUrl ?? currentOptions.startUrl ?? 'about:blank', + prefs: mergedPrefs, + }; - // Prepare change summary - const changes = []; - if (firefoxPath && firefoxPath !== currentOptions.firefoxPath) { - changes.push(`Binary: ${firefoxPath}`); - } - if (profilePath && profilePath !== currentOptions.profilePath) { - changes.push(`Profile: ${profilePath}`); - } - if (newEnv !== undefined && JSON.stringify(newEnv) !== JSON.stringify(currentOptions.env)) { - changes.push(`Environment variables updated:`); - for (const [key, value] of Object.entries(newEnv)) { - changes.push(` ${key}=${value}`); - } - } - if (headless !== undefined && headless !== currentOptions.headless) { - changes.push(`Headless: ${headless ? 'enabled' : 'disabled'}`); - } - if (startUrl && startUrl !== currentOptions.startUrl) { - changes.push(`Start URL: ${startUrl}`); - } + // Set options for next launch + setNextLaunchOptions(newOptions); - if (changes.length === 0) { - return successResponse( - 'Firefox closed. Will restart with same configuration on next tool call.' - ); + // Close current instance + await resetFirefox(); + + // Prepare change summary + const changes = []; + if (firefoxPath && firefoxPath !== currentOptions.firefoxPath) { + changes.push(`Binary: ${firefoxPath}`); + } + if (profilePath && profilePath !== currentOptions.profilePath) { + changes.push(`Profile: ${profilePath}`); + } + if (newEnv !== undefined && JSON.stringify(newEnv) !== JSON.stringify(currentOptions.env)) { + changes.push(`Environment variables updated:`); + for (const [key, value] of Object.entries(newEnv)) { + changes.push(` ${key}=${value}`); } + } + if (headless !== undefined && headless !== currentOptions.headless) { + changes.push(`Headless: ${headless ? 'enabled' : 'disabled'}`); + } + if (startUrl && startUrl !== currentOptions.startUrl) { + changes.push(`Start URL: ${startUrl}`); + } + if (changes.length === 0) { return successResponse( - `Firefox closed. Will restart with new configuration on next tool call:\n${changes.join('\n')}` + 'Firefox closed. Will restart with same configuration on next tool call.' ); - } else { - // Firefox not running (or disconnected) - configure for first start - if (currentFirefox) { - // Had a stale disconnected reference, clean it up - await resetFirefox(); - } + } - // Use provided firefoxPath, or fall back to CLI args if available - const resolvedFirefoxPath = firefoxPath ?? args.firefoxPath ?? undefined; + return successResponse( + `Firefox closed. Will restart with new configuration on next tool call:\n${changes.join('\n')}` + ); + } else { + // Firefox not running (or disconnected) - configure for first start + if (currentFirefox) { + // Had a stale disconnected reference, clean it up + await resetFirefox(); + } - if (!resolvedFirefoxPath) { - return errorResponse( - new Error( - 'Firefox is not running and no firefoxPath provided. Please specify firefoxPath to start Firefox.' - ) - ); - } + // Use provided firefoxPath, or fall back to CLI args if available + const resolvedFirefoxPath = firefoxPath ?? args.firefoxPath ?? undefined; - const newOptions = { - firefoxPath: resolvedFirefoxPath, - profilePath: profilePath ?? args.profilePath ?? undefined, - env: newEnv, - headless: headless ?? false, - startUrl: startUrl ?? 'about:blank', - }; + if (!resolvedFirefoxPath) { + return errorResponse( + new Error( + 'Firefox is not running and no firefoxPath provided. Please specify firefoxPath to start Firefox.' + ) + ); + } - setNextLaunchOptions(newOptions); + const newOptions = { + firefoxPath: resolvedFirefoxPath, + profilePath: profilePath ?? args.profilePath ?? undefined, + env: newEnv, + headless: headless ?? false, + startUrl: startUrl ?? 'about:blank', + }; - const config = [`Binary: ${resolvedFirefoxPath}`]; - const resolvedProfilePath = profilePath ?? args.profilePath; - if (resolvedProfilePath) { - config.push(`Profile: ${resolvedProfilePath}`); - } - if (newEnv) { - config.push('Environment variables:'); - for (const [key, value] of Object.entries(newEnv)) { - config.push(` ${key}=${value}`); - } - } - if (headless) { - config.push('Headless: enabled'); - } - if (startUrl) { - config.push(`Start URL: ${startUrl}`); - } + setNextLaunchOptions(newOptions); - return successResponse( - `Firefox configured. Will start on next tool call:\n${config.join('\n')}` - ); + const config = [`Binary: ${resolvedFirefoxPath}`]; + const resolvedProfilePath = profilePath ?? args.profilePath; + if (resolvedProfilePath) { + config.push(`Profile: ${resolvedProfilePath}`); + } + if (newEnv) { + config.push('Environment variables:'); + for (const [key, value] of Object.entries(newEnv)) { + config.push(` ${key}=${value}`); + } + } + if (headless) { + config.push('Headless: enabled'); } - } catch (error) { - return errorResponse(error as Error); + if (startUrl) { + config.push(`Start URL: ${startUrl}`); + } + + return successResponse( + `Firefox configured. Will start on next tool call:\n${config.join('\n')}` + ); } -} +}); export const module = defineModule({ name: 'management', diff --git a/src/tools/firefox-prefs.ts b/src/tools/firefox-prefs.ts index e541290..b12d530 100644 --- a/src/tools/firefox-prefs.ts +++ b/src/tools/firefox-prefs.ts @@ -4,9 +4,9 @@ * Requires MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 */ -import { successResponse, errorResponse } from '../utils/response-helpers.js'; +import { successResponse } from '../utils/response-helpers.js'; import { generatePrefScript } from '../firefox/pref-utils.js'; -import { defineModule, type ToolDefinition } from './module.js'; +import { defineModule, defineToolHandler, type ToolDefinition } from './module.js'; import type { McpToolResponse } from '../types/common.js'; // ============================================================================ @@ -36,92 +36,92 @@ export const setFirefoxPrefsTool = { }, } satisfies ToolDefinition; -export async function handleSetFirefoxPrefs(args: unknown): Promise { - try { - const { prefs } = args as { prefs: Record }; - - if (!prefs || typeof prefs !== 'object') { - throw new Error('prefs parameter is required and must be an object'); - } - - const prefEntries = Object.entries(prefs); - if (prefEntries.length === 0) { - return successResponse('No preferences to set'); - } +export const handleSetFirefoxPrefs = defineToolHandler( + async (args: unknown): Promise => { + try { + const { prefs } = args as { prefs: Record }; - const { getFirefox } = await import('../index.js'); - const firefox = await getFirefox(); + if (!prefs || typeof prefs !== 'object') { + throw new Error('prefs parameter is required and must be an object'); + } - // Get privileged ("chrome") contexts - const result = await firefox.sendBiDiCommand('browsingContext.getTree', { - 'moz:scope': 'chrome', - }); + const prefEntries = Object.entries(prefs); + if (prefEntries.length === 0) { + return successResponse('No preferences to set'); + } - const contexts = result.contexts || []; - if (contexts.length === 0) { - throw new Error( - 'No privileged contexts available. Ensure MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 is set.' - ); - } + const { getFirefox } = await import('../index.js'); + const firefox = await getFirefox(); - const driver = firefox.getDriver(); - const chromeContextId = contexts[0].context; + // Get privileged ("chrome") contexts + const result = await firefox.sendBiDiCommand('browsingContext.getTree', { + 'moz:scope': 'chrome', + }); - // Remember current context - const originalContextId = firefox.getCurrentContextId(); + const contexts = result.contexts || []; + if (contexts.length === 0) { + throw new Error( + 'No privileged contexts available. Ensure MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 is set.' + ); + } - try { - // Switch to chrome context - await driver.switchTo().window(chromeContextId); - await driver.setContext('chrome'); + const driver = firefox.getDriver(); + const chromeContextId = contexts[0].context; - const results: string[] = []; - const errors: string[] = []; + // Remember current context + const originalContextId = firefox.getCurrentContextId(); - // Set each preference - for (const [name, value] of prefEntries) { - try { - const script = generatePrefScript(name, value); - await driver.executeScript(script); - results.push(` ${name} = ${JSON.stringify(value)}`); - } catch (error) { - errors.push(` ${name}: ${error instanceof Error ? error.message : String(error)}`); + try { + // Switch to chrome context + await driver.switchTo().window(chromeContextId); + await driver.setContext('chrome'); + + const results: string[] = []; + const errors: string[] = []; + + // Set each preference + for (const [name, value] of prefEntries) { + try { + const script = generatePrefScript(name, value); + await driver.executeScript(script); + results.push(` ${name} = ${JSON.stringify(value)}`); + } catch (error) { + errors.push(` ${name}: ${error instanceof Error ? error.message : String(error)}`); + } } - } - const output: string[] = []; - if (results.length > 0) { - output.push(`Set ${results.length} preference(s):`); - output.push(...results); - } - if (errors.length > 0) { - output.push(`\nFailed to set ${errors.length} preference(s):`); - output.push(...errors); - } + const output: string[] = []; + if (results.length > 0) { + output.push(`Set ${results.length} preference(s):`); + output.push(...results); + } + if (errors.length > 0) { + output.push(`\nFailed to set ${errors.length} preference(s):`); + output.push(...errors); + } - return successResponse(output.join('\n')); - } finally { - // Restore previous context (skip if already on the right chrome context) - try { - if (originalContextId && originalContextId !== chromeContextId) { - await driver.setContext('content'); - await driver.switchTo().window(originalContextId); + return successResponse(output.join('\n')); + } finally { + // Restore previous context (skip if already on the right chrome context) + try { + if (originalContextId && originalContextId !== chromeContextId) { + await driver.setContext('content'); + await driver.switchTo().window(originalContextId); + } + } catch { + // Ignore errors restoring context } - } catch { - // Ignore errors restoring context } - } - } catch (error) { - if (error instanceof Error && error.message.includes('UnsupportedOperationError')) { - return errorResponse( - new Error( + } catch (error) { + if (error instanceof Error && error.message.includes('UnsupportedOperationError')) { + throw new Error( 'Chrome context access not enabled. Set MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 environment variable and restart Firefox.' - ) - ); + ); + } + throw error; } - return errorResponse(error as Error); } -} +); // ============================================================================ // Tool: get_firefox_prefs @@ -147,48 +147,49 @@ export const getFirefoxPrefsTool = { }, } satisfies ToolDefinition; -export async function handleGetFirefoxPrefs(args: unknown): Promise { - try { - const { names } = args as { names: string[] }; - - if (!names || !Array.isArray(names) || names.length === 0) { - throw new Error('names parameter is required and must be a non-empty array'); - } +export const handleGetFirefoxPrefs = defineToolHandler( + async (args: unknown): Promise => { + try { + const { names } = args as { names: string[] }; - const { getFirefox } = await import('../index.js'); - const firefox = await getFirefox(); + if (!names || !Array.isArray(names) || names.length === 0) { + throw new Error('names parameter is required and must be a non-empty array'); + } - // Get privileged ("chrome") contexts - const result = await firefox.sendBiDiCommand('browsingContext.getTree', { - 'moz:scope': 'chrome', - }); + const { getFirefox } = await import('../index.js'); + const firefox = await getFirefox(); - const contexts = result.contexts || []; - if (contexts.length === 0) { - throw new Error( - 'No privileged contexts available. Ensure MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 is set.' - ); - } + // Get privileged ("chrome") contexts + const result = await firefox.sendBiDiCommand('browsingContext.getTree', { + 'moz:scope': 'chrome', + }); - const driver = firefox.getDriver(); - const chromeContextId = contexts[0].context; - - // Remember current context - const originalContextId = firefox.getCurrentContextId(); + const contexts = result.contexts || []; + if (contexts.length === 0) { + throw new Error( + 'No privileged contexts available. Ensure MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 is set.' + ); + } - try { - // Switch to chrome context - await driver.switchTo().window(chromeContextId); - await driver.setContext('chrome'); + const driver = firefox.getDriver(); + const chromeContextId = contexts[0].context; - const results: string[] = []; - const errors: string[] = []; + // Remember current context + const originalContextId = firefox.getCurrentContextId(); - // Read each preference - for (const name of names) { - try { - // Use getPrefType to determine how to read the pref - const script = ` + try { + // Switch to chrome context + await driver.switchTo().window(chromeContextId); + await driver.setContext('chrome'); + + const results: string[] = []; + const errors: string[] = []; + + // Read each preference + for (const name of names) { + try { + // Use getPrefType to determine how to read the pref + const script = ` (function() { const type = Services.prefs.getPrefType(${JSON.stringify(name)}); if (type === Services.prefs.PREF_INVALID) { @@ -202,54 +203,53 @@ export async function handleGetFirefoxPrefs(args: unknown): Promise 0) { - output.push(`Firefox Preferences:`); - output.push(...results); - } - if (errors.length > 0) { - output.push(`\nFailed to read ${errors.length} preference(s):`); - output.push(...errors); - } + const output: string[] = []; + if (results.length > 0) { + output.push(`Firefox Preferences:`); + output.push(...results); + } + if (errors.length > 0) { + output.push(`\nFailed to read ${errors.length} preference(s):`); + output.push(...errors); + } - return successResponse(output.join('\n')); - } finally { - // Restore previous context (skip if already on the right chrome context) - try { - if (originalContextId && originalContextId !== chromeContextId) { - await driver.setContext('content'); - await driver.switchTo().window(originalContextId); + return successResponse(output.join('\n')); + } finally { + // Restore previous context (skip if already on the right chrome context) + try { + if (originalContextId && originalContextId !== chromeContextId) { + await driver.setContext('content'); + await driver.switchTo().window(originalContextId); + } + } catch { + // Ignore errors restoring context } - } catch { - // Ignore errors restoring context } - } - } catch (error) { - if (error instanceof Error && error.message.includes('UnsupportedOperationError')) { - return errorResponse( - new Error( + } catch (error) { + if (error instanceof Error && error.message.includes('UnsupportedOperationError')) { + throw new Error( 'Chrome context access not enabled. Set MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 environment variable and restart Firefox.' - ) - ); + ); + } + throw error; } - return errorResponse(error as Error); } -} +); export const module = defineModule({ name: 'prefs', diff --git a/src/tools/input.ts b/src/tools/input.ts index 1e235c5..1e6b9d9 100644 --- a/src/tools/input.ts +++ b/src/tools/input.ts @@ -3,9 +3,9 @@ * Require valid UIDs from take_snapshot */ -import { successResponse, errorResponse } from '../utils/response-helpers.js'; +import { successResponse } from '../utils/response-helpers.js'; import { handleUidError } from '../utils/uid-helpers.js'; -import { defineModule, type ToolDefinition } from './module.js'; +import { defineModule, defineToolHandler, type ToolDefinition } from './module.js'; import type { McpToolResponse } from '../types/common.js'; // Tool definitions @@ -148,184 +148,172 @@ export const uploadFileByUidTool = { } satisfies ToolDefinition; // Handlers -export async function handleClickByUid(args: unknown): Promise { - try { - const { uid, dblClick } = args as { uid: string; dblClick?: boolean }; +export const handleClickByUid = defineToolHandler(async function handleClickByUid( + args: unknown +): Promise { + const { uid, dblClick } = args as { uid: string; dblClick?: boolean }; - if (!uid || typeof uid !== 'string') { - throw new Error('uid parameter is required and must be a string'); - } + if (!uid || typeof uid !== 'string') { + throw new Error('uid parameter is required and must be a string'); + } - const { getFirefox } = await import('../index.js'); - const firefox = await getFirefox(); + const { getFirefox } = await import('../index.js'); + const firefox = await getFirefox(); - try { - await firefox.clickByUid(uid, dblClick); - return successResponse(`${dblClick ? 'dblclick' : 'click'} ${uid}`); - } catch (error) { - throw handleUidError(error as Error, uid); - } + try { + await firefox.clickByUid(uid, dblClick); + return successResponse(`${dblClick ? 'dblclick' : 'click'} ${uid}`); } catch (error) { - return errorResponse(error as Error); + throw handleUidError(error as Error, uid); } -} +}); -export async function handleHoverByUid(args: unknown): Promise { - try { - const { uid } = args as { uid: string }; +export const handleHoverByUid = defineToolHandler(async function handleHoverByUid( + args: unknown +): Promise { + const { uid } = args as { uid: string }; - if (!uid || typeof uid !== 'string') { - throw new Error('uid parameter is required and must be a string'); - } + if (!uid || typeof uid !== 'string') { + throw new Error('uid parameter is required and must be a string'); + } - const { getFirefox } = await import('../index.js'); - const firefox = await getFirefox(); + const { getFirefox } = await import('../index.js'); + const firefox = await getFirefox(); - try { - await firefox.hoverByUid(uid); - return successResponse(`hover ${uid}`); - } catch (error) { - throw handleUidError(error as Error, uid); - } + try { + await firefox.hoverByUid(uid); + return successResponse(`hover ${uid}`); } catch (error) { - return errorResponse(error as Error); + throw handleUidError(error as Error, uid); } -} +}); -export async function handleFillByUid(args: unknown): Promise { - try { - const { uid, value } = args as { uid: string; value: string }; +export const handleFillByUid = defineToolHandler(async function handleFillByUid( + args: unknown +): Promise { + const { uid, value } = args as { uid: string; value: string }; - if (!uid || typeof uid !== 'string') { - throw new Error('uid parameter is required and must be a string'); - } + if (!uid || typeof uid !== 'string') { + throw new Error('uid parameter is required and must be a string'); + } - if (value === undefined || typeof value !== 'string') { - throw new Error('value parameter is required and must be a string'); - } + if (value === undefined || typeof value !== 'string') { + throw new Error('value parameter is required and must be a string'); + } - const { getFirefox } = await import('../index.js'); - const firefox = await getFirefox(); + const { getFirefox } = await import('../index.js'); + const firefox = await getFirefox(); - try { - await firefox.fillByUid(uid, value); - return successResponse(`fill ${uid}`); - } catch (error) { - throw handleUidError(error as Error, uid); - } + try { + await firefox.fillByUid(uid, value); + return successResponse(`fill ${uid}`); } catch (error) { - return errorResponse(error as Error); + throw handleUidError(error as Error, uid); } -} +}); -export async function handleDragByUidToUid(args: unknown): Promise { - try { - const { fromUid, toUid } = args as { fromUid: string; toUid: string }; +export const handleDragByUidToUid = defineToolHandler(async function handleDragByUidToUid( + args: unknown +): Promise { + const { fromUid, toUid } = args as { fromUid: string; toUid: string }; - if (!fromUid || typeof fromUid !== 'string') { - throw new Error('fromUid parameter is required and must be a string'); - } + if (!fromUid || typeof fromUid !== 'string') { + throw new Error('fromUid parameter is required and must be a string'); + } - if (!toUid || typeof toUid !== 'string') { - throw new Error('toUid parameter is required and must be a string'); - } + if (!toUid || typeof toUid !== 'string') { + throw new Error('toUid parameter is required and must be a string'); + } - const { getFirefox } = await import('../index.js'); - const firefox = await getFirefox(); - - try { - await firefox.dragByUidToUid(fromUid, toUid); - return successResponse(`drag ${fromUid}→${toUid}`); - } catch (error) { - // Check both UIDs for staleness - const errorMsg = (error as Error).message; - if (errorMsg.includes('stale') || errorMsg.includes('Snapshot') || errorMsg.includes('UID')) { - throw new Error(`UIDs stale/invalid. Call take_snapshot first.`); - } - throw error; - } + const { getFirefox } = await import('../index.js'); + const firefox = await getFirefox(); + + try { + await firefox.dragByUidToUid(fromUid, toUid); + return successResponse(`drag ${fromUid}→${toUid}`); } catch (error) { - return errorResponse(error as Error); + // Check both UIDs for staleness + const errorMsg = (error as Error).message; + if (errorMsg.includes('stale') || errorMsg.includes('Snapshot') || errorMsg.includes('UID')) { + throw new Error(`UIDs stale/invalid. Call take_snapshot first.`); + } + throw error; } -} +}); -export async function handleFillFormByUid(args: unknown): Promise { - try { - const { elements } = args as { elements: Array<{ uid: string; value: string }> }; +export const handleFillFormByUid = defineToolHandler(async function handleFillFormByUid( + args: unknown +): Promise { + const { elements } = args as { elements: Array<{ uid: string; value: string }> }; - if (!elements || !Array.isArray(elements) || elements.length === 0) { - throw new Error('elements parameter is required and must be a non-empty array'); - } + if (!elements || !Array.isArray(elements) || elements.length === 0) { + throw new Error('elements parameter is required and must be a non-empty array'); + } - // Validate all elements - for (const el of elements) { - if (!el.uid || typeof el.uid !== 'string') { - throw new Error(`Invalid element: uid is required and must be a string`); - } - if (el.value === undefined || typeof el.value !== 'string') { - throw new Error(`Invalid element for uid "${el.uid}": value must be a string`); - } + // Validate all elements + for (const el of elements) { + if (!el.uid || typeof el.uid !== 'string') { + throw new Error(`Invalid element: uid is required and must be a string`); } - - const { getFirefox } = await import('../index.js'); - const firefox = await getFirefox(); - - try { - await firefox.fillFormByUid(elements); - return successResponse(`filled ${elements.length} fields`); - } catch (error) { - const errorMsg = (error as Error).message; - if (errorMsg.includes('stale') || errorMsg.includes('Snapshot') || errorMsg.includes('UID')) { - throw new Error(`UIDs stale/invalid. Call take_snapshot first.`); - } - throw error; + if (el.value === undefined || typeof el.value !== 'string') { + throw new Error(`Invalid element for uid "${el.uid}": value must be a string`); } - } catch (error) { - return errorResponse(error as Error); } -} -export async function handleUploadFileByUid(args: unknown): Promise { - try { - const { uid, filePath } = args as { uid: string; filePath: string }; + const { getFirefox } = await import('../index.js'); + const firefox = await getFirefox(); - if (!uid || typeof uid !== 'string') { - throw new Error('uid parameter is required and must be a string'); + try { + await firefox.fillFormByUid(elements); + return successResponse(`filled ${elements.length} fields`); + } catch (error) { + const errorMsg = (error as Error).message; + if (errorMsg.includes('stale') || errorMsg.includes('Snapshot') || errorMsg.includes('UID')) { + throw new Error(`UIDs stale/invalid. Call take_snapshot first.`); } + throw error; + } +}); - if (!filePath || typeof filePath !== 'string') { - throw new Error('filePath parameter is required and must be a string'); - } +export const handleUploadFileByUid = defineToolHandler(async function handleUploadFileByUid( + args: unknown +): Promise { + const { uid, filePath } = args as { uid: string; filePath: string }; - const { getFirefox } = await import('../index.js'); - const firefox = await getFirefox(); + if (!uid || typeof uid !== 'string') { + throw new Error('uid parameter is required and must be a string'); + } - try { - await firefox.uploadFileByUid(uid, filePath); - return successResponse(`upload ${uid}`); - } catch (error) { - const errorMsg = (error as Error).message; + if (!filePath || typeof filePath !== 'string') { + throw new Error('filePath parameter is required and must be a string'); + } - // Check for UID staleness - if (errorMsg.includes('stale') || errorMsg.includes('Snapshot') || errorMsg.includes('UID')) { - throw handleUidError(error as Error, uid); - } + const { getFirefox } = await import('../index.js'); + const firefox = await getFirefox(); - // Check for file input specific errors - if (errorMsg.includes('not a file input') || errorMsg.includes('type="file"')) { - throw new Error(`${uid} is not a file input`); - } + try { + await firefox.uploadFileByUid(uid, filePath); + return successResponse(`upload ${uid}`); + } catch (error) { + const errorMsg = (error as Error).message; - if (errorMsg.includes('hidden') || errorMsg.includes('not visible')) { - throw new Error(`${uid} is hidden/not interactable`); - } + // Check for UID staleness + if (errorMsg.includes('stale') || errorMsg.includes('Snapshot') || errorMsg.includes('UID')) { + throw handleUidError(error as Error, uid); + } - throw error; + // Check for file input specific errors + if (errorMsg.includes('not a file input') || errorMsg.includes('type="file"')) { + throw new Error(`${uid} is not a file input`); } - } catch (error) { - return errorResponse(error as Error); + + if (errorMsg.includes('hidden') || errorMsg.includes('not visible')) { + throw new Error(`${uid} is hidden/not interactable`); + } + + throw error; } -} +}); export const module = defineModule({ name: 'input', diff --git a/src/tools/module.ts b/src/tools/module.ts index 4811af3..afc191b 100644 --- a/src/tools/module.ts +++ b/src/tools/module.ts @@ -7,6 +7,7 @@ */ import type { McpToolResponse } from '../types/common.js'; +import { errorResponse } from '../utils/response-helpers.js'; export type JsonSchemaType = 'array' | 'boolean' | 'integer' | 'number' | 'object' | 'string'; @@ -63,6 +64,18 @@ export interface ModuleConfig { tools: Array<[ToolDefinition, ToolHandler]>; } +export function defineToolHandler( + handler: (...args: TArgs) => Promise +): (...args: TArgs) => Promise { + return async (...args: TArgs): Promise => { + try { + return await handler(...args); + } catch (error) { + return errorResponse(error instanceof Error ? error : String(error)); + } + }; +} + export function defineModule(config: ModuleConfig): ToolModule { return { name: config.name, diff --git a/src/tools/network.ts b/src/tools/network.ts index aed6318..7aeece1 100644 --- a/src/tools/network.ts +++ b/src/tools/network.ts @@ -14,7 +14,7 @@ import { TOKEN_LIMITS, } from '../utils/response-helpers.js'; import { saveOutput } from '../utils/save-output.js'; -import { defineModule, type ToolDefinition } from './module.js'; +import { defineModule, defineToolHandler, type ToolDefinition } from './module.js'; import type { McpToolResponse } from '../types/common.js'; import type { NetworkBodyResult } from '../firefox/events/network.js'; @@ -204,8 +204,8 @@ function renderBodyForFile(result: NetworkBodyResult): { } // Tool handlers -export async function handleListNetworkRequests(args: unknown): Promise { - try { +export const handleListNetworkRequests = defineToolHandler( + async (args: unknown): Promise => { const { limit, sinceMs, @@ -448,13 +448,11 @@ export async function handleListNetworkRequests(args: unknown): Promise { - try { +export const handleGetNetworkRequest = defineToolHandler( + async (args: unknown): Promise => { const { id, url, format, saveTo, preview } = args as { id?: string; url?: string; @@ -575,10 +573,8 @@ export async function handleGetNetworkRequest(args: unknown): Promise { - try { - const { getFirefox } = await import('../index.js'); - const firefox = await getFirefox(); - - await firefox.refreshTabs(); - const tabs = firefox.getTabs(); - const selectedIdx = firefox.getSelectedTabIdx(); - - return successResponse(formatPageList(tabs, selectedIdx)); - } catch (error) { - return errorResponse(error as Error); - } -} +export const handleListPages = defineToolHandler(async function handleListPages( + _args: unknown +): Promise { + const { getFirefox } = await import('../index.js'); + const firefox = await getFirefox(); -export async function handleNewPage(args: unknown): Promise { - try { - const { url, wait } = args as { url: string; wait?: unknown }; + await firefox.refreshTabs(); + const tabs = firefox.getTabs(); + const selectedIdx = firefox.getSelectedTabIdx(); - if (!url || typeof url !== 'string') { - throw new Error('url parameter is required and must be a string'); - } + return successResponse(formatPageList(tabs, selectedIdx)); +}); - const waitFor = parseWait(wait); +export const handleNewPage = defineToolHandler(async function handleNewPage( + args: unknown +): Promise { + const { url, wait } = args as { url: string; wait?: unknown }; - const { getFirefox } = await import('../index.js'); - const firefox = await getFirefox(); + if (!url || typeof url !== 'string') { + throw new Error('url parameter is required and must be a string'); + } - const newIdx = await firefox.createNewPage(url, waitFor); + const waitFor = parseWait(wait); - return successResponse(`new page [${newIdx}] → ${url}${waitSuffix(waitFor)}`); - } catch (error) { - return errorResponse(error as Error); - } -} + const { getFirefox } = await import('../index.js'); + const firefox = await getFirefox(); -export async function handleNavigatePage(args: unknown): Promise { - try { - const { url, wait } = args as { url: string; wait?: unknown }; + const newIdx = await firefox.createNewPage(url, waitFor); - if (!url || typeof url !== 'string') { - throw new Error('url parameter is required and must be a string'); - } + return successResponse(`new page [${newIdx}] → ${url}${waitSuffix(waitFor)}`); +}); - const waitFor = parseWait(wait); +export const handleNavigatePage = defineToolHandler(async function handleNavigatePage( + args: unknown +): Promise { + const { url, wait } = args as { url: string; wait?: unknown }; - const { getFirefox } = await import('../index.js'); - const firefox = await getFirefox(); + if (!url || typeof url !== 'string') { + throw new Error('url parameter is required and must be a string'); + } - // Refresh tabs to get latest list - await firefox.refreshTabs(); - const tabs = firefox.getTabs(); - const selectedIdx = firefox.getSelectedTabIdx(); - const page = tabs[selectedIdx]; + const waitFor = parseWait(wait); - if (!page) { - throw new Error('No page selected'); - } + const { getFirefox } = await import('../index.js'); + const firefox = await getFirefox(); - await firefox.navigate(url, waitFor); + // Refresh tabs to get latest list + await firefox.refreshTabs(); + const tabs = firefox.getTabs(); + const selectedIdx = firefox.getSelectedTabIdx(); + const page = tabs[selectedIdx]; - return successResponse(`[${selectedIdx}] → ${url}${waitSuffix(waitFor)}`); - } catch (error) { - return errorResponse(error as Error); + if (!page) { + throw new Error('No page selected'); } -} -export async function handleSelectPage(args: unknown): Promise { - try { - const { pageIdx, url, title } = args as { pageIdx?: number; url?: string; title?: string }; + await firefox.navigate(url, waitFor); - const { getFirefox } = await import('../index.js'); - const firefox = await getFirefox(); + return successResponse(`[${selectedIdx}] → ${url}${waitSuffix(waitFor)}`); +}); - // Refresh tabs to get latest list - await firefox.refreshTabs(); - const tabs = firefox.getTabs(); +export const handleSelectPage = defineToolHandler(async function handleSelectPage( + args: unknown +): Promise { + const { pageIdx, url, title } = args as { pageIdx?: number; url?: string; title?: string }; - let selectedIdx: number; + const { getFirefox } = await import('../index.js'); + const firefox = await getFirefox(); - // Priority 1: Select by index - if (typeof pageIdx === 'number') { - selectedIdx = pageIdx; - } - // Priority 2: Select by URL pattern - else if (url && typeof url === 'string') { - const urlLower = url.toLowerCase(); - const foundIdx = tabs.findIndex((tab) => tab.url?.toLowerCase().includes(urlLower)); - if (foundIdx === -1) { - throw new Error(`No page matching URL "${url}"`); - } - selectedIdx = foundIdx; - } - // Priority 3: Select by title pattern - else if (title && typeof title === 'string') { - const titleLower = title.toLowerCase(); - const foundIdx = tabs.findIndex((tab) => tab.title?.toLowerCase().includes(titleLower)); - if (foundIdx === -1) { - throw new Error(`No page matching title "${title}"`); - } - selectedIdx = foundIdx; - } else { - throw new Error('Provide pageIdx, url, or title'); - } + // Refresh tabs to get latest list + await firefox.refreshTabs(); + const tabs = firefox.getTabs(); - // Validate the selected index - if (!tabs[selectedIdx]) { - throw new Error(`Page [${selectedIdx}] not found`); - } + let selectedIdx: number; - // Select the tab - await firefox.selectTab(selectedIdx); + // Priority 1: Select by index + if (typeof pageIdx === 'number') { + selectedIdx = pageIdx; + } + // Priority 2: Select by URL pattern + else if (url && typeof url === 'string') { + const urlLower = url.toLowerCase(); + const foundIdx = tabs.findIndex((tab) => tab.url?.toLowerCase().includes(urlLower)); + if (foundIdx === -1) { + throw new Error(`No page matching URL "${url}"`); + } + selectedIdx = foundIdx; + } + // Priority 3: Select by title pattern + else if (title && typeof title === 'string') { + const titleLower = title.toLowerCase(); + const foundIdx = tabs.findIndex((tab) => tab.title?.toLowerCase().includes(titleLower)); + if (foundIdx === -1) { + throw new Error(`No page matching title "${title}"`); + } + selectedIdx = foundIdx; + } else { + throw new Error('Provide pageIdx, url, or title'); + } - return successResponse(`selected [${selectedIdx}]`); - } catch (error) { - return errorResponse(error as Error); + // Validate the selected index + if (!tabs[selectedIdx]) { + throw new Error(`Page [${selectedIdx}] not found`); } -} -export async function handleClosePage(args: unknown): Promise { - try { - const { pageIdx } = args as { pageIdx: number }; + // Select the tab + await firefox.selectTab(selectedIdx); - if (typeof pageIdx !== 'number') { - throw new Error('pageIdx parameter is required and must be a number'); - } + return successResponse(`selected [${selectedIdx}]`); +}); - const { getFirefox } = await import('../index.js'); - const firefox = await getFirefox(); +export const handleClosePage = defineToolHandler(async function handleClosePage( + args: unknown +): Promise { + const { pageIdx } = args as { pageIdx: number }; - // Refresh tabs to get latest list - await firefox.refreshTabs(); - const tabs = firefox.getTabs(); - const pageToClose = tabs[pageIdx]; + if (typeof pageIdx !== 'number') { + throw new Error('pageIdx parameter is required and must be a number'); + } - if (!pageToClose) { - throw new Error(`Page with index ${pageIdx} not found`); - } + const { getFirefox } = await import('../index.js'); + const firefox = await getFirefox(); - await firefox.closeTab(pageIdx); + // Refresh tabs to get latest list + await firefox.refreshTabs(); + const tabs = firefox.getTabs(); + const pageToClose = tabs[pageIdx]; - return successResponse(`closed [${pageIdx}]`); - } catch (error) { - return errorResponse(error as Error); + if (!pageToClose) { + throw new Error(`Page with index ${pageIdx} not found`); } -} + + await firefox.closeTab(pageIdx); + + return successResponse(`closed [${pageIdx}]`); +}); async function respondWithContent( content: string, @@ -383,20 +373,18 @@ async function respondWithContent( return successResponse(content.slice(0, maxLength) + '\n\n' + footer); } -export async function handleGetPageText(args: unknown): Promise { - try { - const { getFirefox } = await import('../index.js'); - const firefox = await getFirefox(); +export const handleGetPageText = defineToolHandler(async function handleGetPageText( + args: unknown +): Promise { + const { getFirefox } = await import('../index.js'); + const firefox = await getFirefox(); - const text = (await firefox.evaluate( - 'document.body ? document.body.innerText : document.documentElement.innerText' - )) as string | null | undefined; + const text = (await firefox.evaluate( + 'document.body ? document.body.innerText : document.documentElement.innerText' + )) as string | null | undefined; - return respondWithContent(text ?? '', args, 'page-text', 'txt'); - } catch (error) { - return errorResponse(error as Error); - } -} + return respondWithContent(text ?? '', args, 'page-text', 'txt'); +}); export const module = defineModule({ name: 'pages', diff --git a/src/tools/privileged-context.ts b/src/tools/privileged-context.ts index 325f8e0..c20a719 100644 --- a/src/tools/privileged-context.ts +++ b/src/tools/privileged-context.ts @@ -7,7 +7,7 @@ import { successResponse, errorResponse, previewExcerpt } from '../utils/respons import { validateFunction } from '../utils/js-validation.js'; import { remoteValueToNative } from '../utils/remote-value.js'; import { saveOutput } from '../utils/save-output.js'; -import { defineModule, type ToolDefinition } from './module.js'; +import { defineModule, defineToolHandler, type ToolDefinition } from './module.js'; // list_extensions lives with the other extension tools in webextension.ts, but // it needs parent access (AddonManager), so it is registered here under the // privileged module rather than the unprivileged webextension module. @@ -119,28 +119,30 @@ async function assertPrivilegedContext(firefox: any, contextId: string): Promise } } -export async function handleListPrivilegedContexts(_args: unknown): Promise { - try { - const { getFirefox } = await import('../index.js'); - const firefox = await getFirefox(); +export const handleListPrivilegedContexts = defineToolHandler( + async (_args: unknown): Promise => { + try { + const { getFirefox } = await import('../index.js'); + const firefox = await getFirefox(); - const result = await firefox.sendBiDiCommand('browsingContext.getTree', { - 'moz:scope': 'chrome', - }); + const result = await firefox.sendBiDiCommand('browsingContext.getTree', { + 'moz:scope': 'chrome', + }); - const contexts = result.contexts || []; + const contexts = result.contexts || []; - return successResponse(formatContextList(contexts)); - } catch (error) { - if (error instanceof Error && error.message.includes('UnsupportedOperationError')) { - return errorResponse(new Error(SYSTEM_ACCESS_ERROR)); + return successResponse(formatContextList(contexts)); + } catch (error) { + if (error instanceof Error && error.message.includes('UnsupportedOperationError')) { + throw new Error(SYSTEM_ACCESS_ERROR); + } + throw error; } - return errorResponse(error as Error); } -} +); -export async function handleSelectPrivilegedContext(args: unknown): Promise { - try { +export const handleSelectPrivilegedContext = defineToolHandler( + async (args: unknown): Promise => { const { contextId } = args as { contextId: string }; if (!contextId || typeof contextId !== 'string') { @@ -172,18 +174,16 @@ export async function handleSelectPrivilegedContext(args: unknown): Promise { - try { +export const handleEvaluatePrivilegedScript = defineToolHandler( + async (args: unknown): Promise => { const { function: fnString, context, @@ -248,10 +248,8 @@ export async function handleEvaluatePrivilegedScript(args: unknown): Promise { - try { +export const handleProfilerIsActive = defineToolHandler( + async (_args: unknown): Promise => { const { getFirefox } = await import('../index.js'); const firefox = await getFirefox(); checkProfilerSupported(firefox); @@ -51,10 +51,8 @@ export async function handleProfilerIsActive(_args: unknown): Promise { - try { +export const handleProfilerStart = defineToolHandler( + async (args: unknown): Promise => { const { preset, entries, interval, features, threads, activeContext } = args as { preset?: string; entries?: number; @@ -146,10 +144,8 @@ export async function handleProfilerStart(args: unknown): Promise { - try { +export const handleProfilerStop = defineToolHandler( + async (args: unknown): Promise => { const { discard } = args as { discard?: boolean }; const params: Record = {}; @@ -193,10 +189,8 @@ export async function handleProfilerStop(args: unknown): Promise { - try { +export const handleScreencastStart = defineToolHandler( + async (args: unknown): Promise => { const { context, frameRate, width, height, mimeType } = (args ?? {}) as { context?: string; frameRate?: number; @@ -108,10 +108,8 @@ export async function handleScreencastStart(args: unknown): Promise { - try { +export const handleScreencastStop = defineToolHandler( + async (args: unknown): Promise => { const { screencast } = (args ?? {}) as { screencast?: string }; const { getFirefox } = await import('../index.js'); @@ -170,10 +168,8 @@ export async function handleScreencastStop(args: unknown): Promise { - try { - const { saveTo } = (args ?? {}) as { saveTo?: boolean | string }; +export const handleScreenshotPage = defineToolHandler(async function handleScreenshotPage( + args: unknown +): Promise { + const { saveTo } = (args ?? {}) as { saveTo?: boolean | string }; - const { getFirefox } = await import('../index.js'); - const firefox = await getFirefox(); + const { getFirefox } = await import('../index.js'); + const firefox = await getFirefox(); - const base64Png = await firefox.takeScreenshotPage(); + const base64Png = await firefox.takeScreenshotPage(); - if (!base64Png || typeof base64Png !== 'string') { - throw new Error('Invalid screenshot data'); - } - - if (saveTo) { - return await saveScreenshot(base64Png, saveTo); - } + if (!base64Png || typeof base64Png !== 'string') { + throw new Error('Invalid screenshot data'); + } - return imageResponse(base64Png); - } catch (error) { - return errorResponse(error as Error); + if (saveTo) { + return await saveScreenshot(base64Png, saveTo); } -} -export async function handleScreenshotByUid(args: unknown): Promise { - try { - const { uid, saveTo } = args as { uid: string; saveTo?: boolean | string }; + return imageResponse(base64Png); +}); - if (!uid || typeof uid !== 'string') { - throw new Error('uid required'); - } +export const handleScreenshotByUid = defineToolHandler(async function handleScreenshotByUid( + args: unknown +): Promise { + const { uid, saveTo } = args as { uid: string; saveTo?: boolean | string }; - const { getFirefox } = await import('../index.js'); - const firefox = await getFirefox(); + if (!uid || typeof uid !== 'string') { + throw new Error('uid required'); + } - try { - const base64Png = await firefox.takeScreenshotByUid(uid); + const { getFirefox } = await import('../index.js'); + const firefox = await getFirefox(); - if (!base64Png || typeof base64Png !== 'string') { - throw new Error('Invalid screenshot data'); - } + try { + const base64Png = await firefox.takeScreenshotByUid(uid); - if (saveTo) { - return await saveScreenshot(base64Png, saveTo); - } + if (!base64Png || typeof base64Png !== 'string') { + throw new Error('Invalid screenshot data'); + } - return imageResponse(base64Png); - } catch (error) { - throw handleUidError(error as Error, uid); + if (saveTo) { + return await saveScreenshot(base64Png, saveTo); } + + return imageResponse(base64Png); } catch (error) { - return errorResponse(error as Error); + throw handleUidError(error as Error, uid); } -} +}); export const module = defineModule({ name: 'screenshot', diff --git a/src/tools/script.ts b/src/tools/script.ts index 686eb1c..34cc490 100644 --- a/src/tools/script.ts +++ b/src/tools/script.ts @@ -6,7 +6,7 @@ import { successResponse, errorResponse, previewExcerpt } from '../utils/respons import { remoteValueToNative } from '../utils/remote-value.js'; import { validateFunction } from '../utils/js-validation.js'; import { saveOutput } from '../utils/save-output.js'; -import { defineModule, type ToolDefinition } from './module.js'; +import { defineModule, defineToolHandler, type ToolDefinition } from './module.js'; import type { McpToolResponse } from '../types/common.js'; export const evaluateScriptTool = { @@ -71,8 +71,8 @@ const EvaluateResultType = { Success: 'success', }; -export async function handleEvaluateScript(args: unknown): Promise { - try { +export const handleEvaluateScript = defineToolHandler( + async (args: unknown): Promise => { const { function: fnString, args: fnArgs, @@ -180,10 +180,8 @@ export async function handleEvaluateScript(args: unknown): Promise { +export const handleTakeSnapshot = defineToolHandler(async function handleTakeSnapshot( + args: unknown +): Promise { try { const { maxLines: requestedMaxLines = DEFAULT_SNAPSHOT_LINES, @@ -208,17 +209,15 @@ export async function handleTakeSnapshot(args: unknown): Promise { - try { +export const handleResolveUidToSelector = defineToolHandler( + async function handleResolveUidToSelector(args: unknown): Promise { const { uid } = args as { uid: string }; if (!uid || typeof uid !== 'string') { @@ -234,23 +233,19 @@ export async function handleResolveUidToSelector(args: unknown): Promise { - try { - const { getFirefox } = await import('../index.js'); - const firefox = await getFirefox(); +export const handleClearSnapshot = defineToolHandler(async function handleClearSnapshot( + _args: unknown +): Promise { + const { getFirefox } = await import('../index.js'); + const firefox = await getFirefox(); - await firefox.clearSnapshot(); + await firefox.clearSnapshot(); - return successResponse('🧹 Snapshot cleared'); - } catch (error) { - return errorResponse(error as Error); - } -} + return successResponse('🧹 Snapshot cleared'); +}); export const module = defineModule({ name: 'snapshot', diff --git a/src/tools/utilities.ts b/src/tools/utilities.ts index bb34439..372239e 100644 --- a/src/tools/utilities.ts +++ b/src/tools/utilities.ts @@ -2,8 +2,8 @@ * Page utility tools (dialogs, history, viewport) */ -import { successResponse, errorResponse } from '../utils/response-helpers.js'; -import { defineModule, type ToolDefinition } from './module.js'; +import { successResponse } from '../utils/response-helpers.js'; +import { defineModule, defineToolHandler, type ToolDefinition } from './module.js'; import type { McpToolResponse } from '../types/common.js'; // Tool definitions - Dialogs @@ -80,101 +80,93 @@ export const setViewportSizeTool = { } satisfies ToolDefinition; // Handlers - Dialogs -export async function handleAcceptDialog(args: unknown): Promise { - try { - const { promptText } = (args as { promptText?: string }) || {}; - - const { getFirefox } = await import('../index.js'); - const firefox = await getFirefox(); +export const handleAcceptDialog = defineToolHandler(async function handleAcceptDialog( + args: unknown +): Promise { + const { promptText } = (args as { promptText?: string }) || {}; - try { - await firefox.acceptDialog(promptText); - return successResponse(promptText ? `Accepted: "${promptText}"` : 'Accepted'); - } catch (error) { - const errorMsg = (error as Error).message; + const { getFirefox } = await import('../index.js'); + const firefox = await getFirefox(); - // Concise error for no active dialog - if (errorMsg.includes('no such alert') || errorMsg.includes('No dialog')) { - throw new Error('No active dialog'); - } + try { + await firefox.acceptDialog(promptText); + return successResponse(promptText ? `Accepted: "${promptText}"` : 'Accepted'); + } catch (error) { + const errorMsg = (error as Error).message; - throw error; + // Concise error for no active dialog + if (errorMsg.includes('no such alert') || errorMsg.includes('No dialog')) { + throw new Error('No active dialog'); } - } catch (error) { - return errorResponse(error as Error); - } -} -export async function handleDismissDialog(_args: unknown): Promise { - try { - const { getFirefox } = await import('../index.js'); - const firefox = await getFirefox(); + throw error; + } +}); - try { - await firefox.dismissDialog(); - return successResponse('Dismissed'); - } catch (error) { - const errorMsg = (error as Error).message; +export const handleDismissDialog = defineToolHandler(async function handleDismissDialog( + _args: unknown +): Promise { + const { getFirefox } = await import('../index.js'); + const firefox = await getFirefox(); - // Concise error for no active dialog - if (errorMsg.includes('no such alert') || errorMsg.includes('No dialog')) { - throw new Error('No active dialog'); - } + try { + await firefox.dismissDialog(); + return successResponse('Dismissed'); + } catch (error) { + const errorMsg = (error as Error).message; - throw error; + // Concise error for no active dialog + if (errorMsg.includes('no such alert') || errorMsg.includes('No dialog')) { + throw new Error('No active dialog'); } - } catch (error) { - return errorResponse(error as Error); + + throw error; } -} +}); // Handlers - History -export async function handleNavigateHistory(args: unknown): Promise { - try { - const { direction } = args as { direction: 'back' | 'forward' }; +export const handleNavigateHistory = defineToolHandler(async function handleNavigateHistory( + args: unknown +): Promise { + const { direction } = args as { direction: 'back' | 'forward' }; - if (!direction || (direction !== 'back' && direction !== 'forward')) { - throw new Error('direction parameter is required and must be "back" or "forward"'); - } - - const { getFirefox } = await import('../index.js'); - const firefox = await getFirefox(); + if (!direction || (direction !== 'back' && direction !== 'forward')) { + throw new Error('direction parameter is required and must be "back" or "forward"'); + } - if (direction === 'back') { - await firefox.navigateBack(); - } else { - await firefox.navigateForward(); - } + const { getFirefox } = await import('../index.js'); + const firefox = await getFirefox(); - return successResponse(`${direction}`); - } catch (error) { - return errorResponse(error as Error); + if (direction === 'back') { + await firefox.navigateBack(); + } else { + await firefox.navigateForward(); } -} + + return successResponse(`${direction}`); +}); // Handlers - Viewport -export async function handleSetViewportSize(args: unknown): Promise { - try { - const { width, height } = args as { width: number; height: number }; +export const handleSetViewportSize = defineToolHandler(async function handleSetViewportSize( + args: unknown +): Promise { + const { width, height } = args as { width: number; height: number }; - if (typeof width !== 'number' || width <= 0) { - throw new Error('width parameter is required and must be a positive number'); - } + if (typeof width !== 'number' || width <= 0) { + throw new Error('width parameter is required and must be a positive number'); + } - if (typeof height !== 'number' || height <= 0) { - throw new Error('height parameter is required and must be a positive number'); - } + if (typeof height !== 'number' || height <= 0) { + throw new Error('height parameter is required and must be a positive number'); + } - const { getFirefox } = await import('../index.js'); - const firefox = await getFirefox(); + const { getFirefox } = await import('../index.js'); + const firefox = await getFirefox(); - await firefox.setViewportSize(width, height); + await firefox.setViewportSize(width, height); - return successResponse(`${width}x${height}`); - } catch (error) { - return errorResponse(error as Error); - } -} + return successResponse(`${width}x${height}`); +}); export const module = defineModule({ name: 'utilities', diff --git a/src/tools/webextension.ts b/src/tools/webextension.ts index f68c3a8..c3cc553 100644 --- a/src/tools/webextension.ts +++ b/src/tools/webextension.ts @@ -9,8 +9,8 @@ * Note: list_extensions requires MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 */ -import { successResponse, errorResponse } from '../utils/response-helpers.js'; -import { defineModule, type ToolDefinition } from './module.js'; +import { successResponse } from '../utils/response-helpers.js'; +import { defineModule, defineToolHandler, type ToolDefinition } from './module.js'; import type { McpToolResponse } from '../types/common.js'; // ============================================================================ @@ -51,8 +51,8 @@ export const installExtensionTool = { }, } satisfies ToolDefinition; -export async function handleInstallExtension(args: unknown): Promise { - try { +export const handleInstallExtension = defineToolHandler( + async (args: unknown): Promise => { const { type, path, value, permanent } = args as { type: 'archivePath' | 'base64' | 'path'; path?: string; @@ -98,10 +98,8 @@ export async function handleInstallExtension(args: unknown): Promise { - try { +export const handleUninstallExtension = defineToolHandler( + async (args: unknown): Promise => { const { id } = args as { id: string }; if (!id || typeof id !== 'string') { @@ -140,10 +138,8 @@ export async function handleUninstallExtension(args: unknown): Promise { - try { - const { ids, name, isActive, isSystem } = - (args as { - ids?: string[]; - name?: string; - isActive?: boolean; - isSystem?: boolean; - }) || {}; - - const { getFirefox } = await import('../index.js'); - const firefox = await getFirefox(); - - // Get privileged ("chrome") contexts - const result = await firefox.sendBiDiCommand('browsingContext.getTree', { - 'moz:scope': 'chrome', - }); - - const contexts = result.contexts || []; - if (contexts.length === 0) { - throw new Error( - 'No privileged contexts available. Ensure MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 is set.' - ); - } +export const handleListExtensions = defineToolHandler( + async (args: unknown): Promise => { + try { + const { ids, name, isActive, isSystem } = + (args as { + ids?: string[]; + name?: string; + isActive?: boolean; + isSystem?: boolean; + }) || {}; + + const { getFirefox } = await import('../index.js'); + const firefox = await getFirefox(); + + // Get privileged ("chrome") contexts + const result = await firefox.sendBiDiCommand('browsingContext.getTree', { + 'moz:scope': 'chrome', + }); + + const contexts = result.contexts || []; + if (contexts.length === 0) { + throw new Error( + 'No privileged contexts available. Ensure MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 is set.' + ); + } - const driver = firefox.getDriver(); - const chromeContextId = contexts[0].context; - const originalContextId = firefox.getCurrentContextId(); + const driver = firefox.getDriver(); + const chromeContextId = contexts[0].context; + const originalContextId = firefox.getCurrentContextId(); - try { - // Switch to chrome context - await driver.switchTo().window(chromeContextId); - await driver.setContext('chrome'); - - // Execute chrome-privileged script to get extensions - // Use executeAsyncScript for async operations - const filterParams = { ids, name, isActive, isSystem }; - const script = ` + try { + // Switch to chrome context + await driver.switchTo().window(chromeContextId); + await driver.setContext('chrome'); + + // Execute chrome-privileged script to get extensions + // Use executeAsyncScript for async operations + const filterParams = { ids, name, isActive, isSystem }; + const script = ` const callback = arguments[arguments.length - 1]; const filter = ${JSON.stringify(filterParams)}; (async () => { @@ -317,41 +314,40 @@ export async function handleListExtensions(args: unknown): Promise 0 ? `ids: [${ids.join(', ')}]` : null, - name ? `name: "${name}"` : null, - typeof isActive === 'boolean' ? `active: ${isActive}` : null, - typeof isSystem === 'boolean' ? `system: ${isSystem}` : null, - ] - .filter(Boolean) - .join(', '); - - return successResponse(formatExtensionList(extensions, filterDesc || undefined)); - } finally { - // Restore previous context (skip if already on the right chrome context) - try { - if (originalContextId && originalContextId !== chromeContextId) { - await driver.setContext('content'); - await driver.switchTo().window(originalContextId); + const extensions = (await driver.executeAsyncScript(script)) as ExtensionInfo[]; + + // Build filter description for output + const filterDesc = [ + ids && ids.length > 0 ? `ids: [${ids.join(', ')}]` : null, + name ? `name: "${name}"` : null, + typeof isActive === 'boolean' ? `active: ${isActive}` : null, + typeof isSystem === 'boolean' ? `system: ${isSystem}` : null, + ] + .filter(Boolean) + .join(', '); + + return successResponse(formatExtensionList(extensions, filterDesc || undefined)); + } finally { + // Restore previous context (skip if already on the right chrome context) + try { + if (originalContextId && originalContextId !== chromeContextId) { + await driver.setContext('content'); + await driver.switchTo().window(originalContextId); + } + } catch { + // Ignore errors restoring context } - } catch { - // Ignore errors restoring context } - } - } catch (error) { - if (error instanceof Error && error.message.includes('UnsupportedOperationError')) { - return errorResponse( - new Error( + } catch (error) { + if (error instanceof Error && error.message.includes('UnsupportedOperationError')) { + throw new Error( 'Chrome context access not enabled. Set MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 environment variable and restart Firefox.' - ) - ); + ); + } + throw error; } - return errorResponse(error as Error); } -} +); export const module = defineModule({ name: 'webextension', diff --git a/tests/tools/module.test.ts b/tests/tools/module.test.ts new file mode 100644 index 0000000..8533b28 --- /dev/null +++ b/tests/tools/module.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from 'vitest'; +import { defineToolHandler } from '../../src/tools/module.js'; +import type { McpToolResponse } from '../../src/types/common.js'; + +describe('defineToolHandler', () => { + it('passes successful responses through unchanged', async () => { + const response: McpToolResponse = { + content: [{ type: 'text', text: 'ok' }], + }; + const handler = defineToolHandler(async () => response); + + await expect(handler()).resolves.toBe(response); + }); + + it('converts thrown errors to MCP error responses', async () => { + const handler = defineToolHandler(() => { + throw new Error('boom'); + }); + + await expect(handler()).resolves.toEqual({ + content: [{ type: 'text', text: 'Error: boom' }], + isError: true, + }); + }); + + it('normalizes non-Error rejections', async () => { + const rejectedHandler = vi.fn<() => Promise>().mockRejectedValue('rejected'); + const handler = defineToolHandler(rejectedHandler); + + await expect(handler()).resolves.toEqual({ + content: [{ type: 'text', text: 'Error: rejected' }], + isError: true, + }); + }); +});