UNPKG

aironin-browse-mcp

Version:

aiRonin Browse MCP server with headed Chrome support

692 lines (607 loc) • 19.7 kB
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import { z } from "zod" // Try to import browser dependencies, but don't fail if they're not available let BrowserSession: any = null let discoverChromeHostUrl: any = null try { const browserModule = await import("aironin-browse-core") BrowserSession = browserModule.BrowserSession discoverChromeHostUrl = browserModule.discoverChromeHostUrl } catch (error) { // Browser dependencies not available, but we can still provide the MCP server console.error("Browser dependencies not available. Install with: npm install aironin-browse-core@^1.2.0") } // Create the server const server = new McpServer({ name: "aironin-browse-mcp", version: "1.0.0", }) // Browser session instance let browserSession: any = null // Configuration helper function function getConfigValue(key: string, defaultValue: string): string { return process.env[key] || defaultValue } // Helper function to ensure browser is launched with remote detection async function ensureBrowserLaunched(forceRemote: boolean = false): Promise<any> { if (!BrowserSession) { throw new Error("Browser dependencies not available. Please install: npm install aironin-browse-core@^1.2.0") } if (!browserSession) { browserSession = new BrowserSession() // Set default configuration values if not provided if (!process.env.SCREENSHOT_QUALITY) { process.env.SCREENSHOT_QUALITY = getConfigValue("SCREENSHOT_QUALITY", "75") } if (!process.env.BROWSER_VIEWPORT_SIZE) { process.env.BROWSER_VIEWPORT_SIZE = getConfigValue("BROWSER_VIEWPORT_SIZE", "900x600") } if (!process.env.BROWSER_NAVIGATION_TIMEOUT) { process.env.BROWSER_NAVIGATION_TIMEOUT = getConfigValue("BROWSER_NAVIGATION_TIMEOUT", "15000") } // Try remote browser detection first (unless explicitly disabled) const remoteBrowserEnabled = getConfigValue("REMOTE_BROWSER_ENABLED", "true") if (forceRemote || remoteBrowserEnabled !== "false") { const remoteHostUrl = await discoverChromeHostUrl(9222) if (remoteHostUrl) { process.env.REMOTE_BROWSER_ENABLED = "true" process.env.REMOTE_BROWSER_HOST = remoteHostUrl } else { process.env.REMOTE_BROWSER_ENABLED = "false" } } else { process.env.REMOTE_BROWSER_ENABLED = "false" } await browserSession.launchBrowser() } return browserSession } // Register tools server.registerTool( "launch_browser", { title: "Launch Browser", description: "Launch browser and navigate to URL", inputSchema: { url: z.string().describe("URL to navigate to"), remote: z.boolean().optional().describe("Force remote browser connection (default: auto-detect)") } }, async ({ url, remote }) => { const forceRemote = remote || false const browser = await ensureBrowserLaunched(forceRemote) const result = await browser.navigateToUrl(url) return { content: [ { type: "text", text: `šŸš€ Browser launched and navigated to ${url}\n\n` + `Current URL: ${result.currentUrl}\n` + `Browser Mode: ${process.env.REMOTE_BROWSER_ENABLED === "true" ? "Remote" : "Local"}\n` + `Screenshot captured: ${result.screenshot ? "Yes" : "No"}\n` + `Page loaded successfully.` } ] } } ) server.registerTool( "click_element", { title: "Click Element", description: "Click at specified coordinates", inputSchema: { coordinates: z.string().describe("Coordinates in format 'x,y'") } }, async ({ coordinates }) => { const browser = await ensureBrowserLaunched() await browser.click(coordinates) return { content: [ { type: "text", text: `šŸ–±ļø Clicked at coordinates ${coordinates}` } ] } } ) server.registerTool( "type_text", { title: "Type Text", description: "Type text into the browser", inputSchema: { text: z.string().describe("Text to type") } }, async ({ text }) => { const browser = await ensureBrowserLaunched() await browser.type(text) return { content: [ { type: "text", text: `āŒØļø Typed text: "${text}"` } ] } } ) server.registerTool( "scroll_page", { title: "Scroll Page", description: "Scroll the page up or down", inputSchema: { direction: z.enum(["up", "down"]).optional().describe("Scroll direction") } }, async ({ direction }) => { const scrollDirection = direction || "down" const browser = await ensureBrowserLaunched() if (scrollDirection === "up") { await browser.scrollUp() } else { await browser.scrollDown() } return { content: [ { type: "text", text: `šŸ“œ Scrolled page ${scrollDirection}` } ] } } ) server.registerTool( "hover_element", { title: "Hover Element", description: "Hover at specified coordinates", inputSchema: { coordinates: z.string().describe("Coordinates in format 'x,y'") } }, async ({ coordinates }) => { const browser = await ensureBrowserLaunched() await browser.hover(coordinates) return { content: [ { type: "text", text: `šŸ–±ļø Hovered at coordinates ${coordinates}` } ] } } ) server.registerTool( "resize_browser", { title: "Resize Browser", description: "Resize browser window", inputSchema: { size: z.string().describe("Size in format 'width,height'") } }, async ({ size }) => { const browser = await ensureBrowserLaunched() await browser.resize(size) return { content: [ { type: "text", text: `šŸ“ Resized browser to ${size}` } ] } } ) server.registerTool( "take_screenshot", { title: "Take Screenshot", description: "Take a screenshot for AI agent analysis of the current page", inputSchema: { quality: z.number().optional().describe("Screenshot quality (1-100)"), fullPage: z.boolean().optional().describe("Capture full page (default: false)") } }, async ({ quality, fullPage = false }) => { const browser = await ensureBrowserLaunched() // Set screenshot quality if provided if (quality) { process.env.SCREENSHOT_QUALITY = quality.toString() } // Take screenshot using the browser session's doActionWithOptions method const result = await browser.doActionWithOptions(async (page: any) => { // Just wait a moment to ensure page is stable, screenshot is taken automatically await new Promise(resolve => setTimeout(resolve, 100)) }, { fullPage }) const defaultQuality = parseInt(getConfigValue("SCREENSHOT_QUALITY", "75")) const actualQuality = quality || defaultQuality return { content: [ { type: "text", text: `šŸ“ø Screenshot captured for analysis!\n\n` + `Current URL: ${result.currentUrl}\n` + `Image size: ${Math.round((result.screenshot?.length || 0) / 1024)}KB\n` + `Quality: ${actualQuality}%\n` + `Full page capture: ${fullPage ? "Yes" : "No"}\n` + `Screenshot data available for AI analysis` } ] } } ) server.registerTool( "capture_page", { title: "Capture Full Page", description: "Capture a full page screenshot (entire page, not just viewport) for AI agent analysis", inputSchema: { quality: z.number().optional().describe("Screenshot quality (1-100)"), fullPage: z.boolean().optional().describe("Capture full page (default: true)") } }, async ({ quality, fullPage = true }) => { const browser = await ensureBrowserLaunched() // Set screenshot quality if provided if (quality) { process.env.SCREENSHOT_QUALITY = quality.toString() } // Take full page screenshot using the browser session's doActionWithOptions method const result = await browser.doActionWithOptions(async (page: any) => { // Just wait a moment to ensure page is stable, screenshot is taken automatically await new Promise(resolve => setTimeout(resolve, 100)) }, { fullPage }) const defaultQuality = parseInt(getConfigValue("SCREENSHOT_QUALITY", "75")) const actualQuality = quality || defaultQuality return { content: [ { type: "text", text: `šŸ“ø Full page screenshot captured for analysis!\n\n` + `Current URL: ${result.currentUrl}\n` + `Image size: ${Math.round((result.screenshot?.length || 0) / 1024)}KB\n` + `Quality: ${actualQuality}%\n` + `Full page capture: ${fullPage ? "Yes" : "No"}\n` + `Screenshot data available for AI analysis` } ] } } ) server.registerTool( "save_screenshot", { title: "Save Screenshot", description: "Take and save a screenshot of the current page to disk", inputSchema: { quality: z.number().optional().describe("Screenshot quality (1-100)"), filename: z.string().optional().describe("Custom filename (without extension)"), fullPage: z.boolean().optional().describe("Capture full page (default: false)") } }, async ({ quality, filename, fullPage = false }) => { const browser = await ensureBrowserLaunched() // Set screenshot quality if provided if (quality) { process.env.SCREENSHOT_QUALITY = quality.toString() } // Take screenshot using the browser session's doActionWithOptions method const result = await browser.doActionWithOptions(async (page: any) => { // Just wait a moment to ensure page is stable, screenshot is taken automatically await new Promise(resolve => setTimeout(resolve, 100)) }, { fullPage }) // Save screenshot to file if available let filePath = "" if (result.screenshot) { const fs = await import("node:fs/promises") const path = await import("node:path") const timestamp = new Date().toISOString().replace(/[:.]/g, '-') const customName = filename ? filename.replace(/[^a-zA-Z0-9-_]/g, '_') : 'screenshot' const finalFilename = `${customName}-${timestamp}.webp` filePath = path.join(process.cwd(), finalFilename) // Extract base64 data and save to file const base64Data = result.screenshot.replace(/^data:image\/[^;]+;base64,/, '') await fs.writeFile(filePath, Buffer.from(base64Data, 'base64')) } const defaultQuality = parseInt(getConfigValue("SCREENSHOT_QUALITY", "75")) const actualQuality = quality || defaultQuality return { content: [ { type: "text", text: `šŸ“ø Screenshot saved successfully!\n\n` + `Image size: ${Math.round((result.screenshot?.length || 0) / 1024)}KB\n` + `Quality: ${actualQuality}%\n` + `Full page capture: ${fullPage ? "Yes" : "No"}\n` + `Current URL: ${result.currentUrl}\n` + `${filePath ? `Saved to: ${filePath}` : 'Failed to save screenshot'}` } ] } } ) server.registerTool( "analyze_page", { title: "Analyze Page", description: "Analyze the current page content for AI agent understanding", inputSchema: { includeScreenshot: z.boolean().optional().describe("Include screenshot data for visual analysis") } }, async ({ includeScreenshot }) => { const browser = await ensureBrowserLaunched() // Take screenshot and get page info const result = await browser.doAction(async (page: any) => { // Just wait a moment to ensure page is stable, screenshot is taken automatically await new Promise(resolve => setTimeout(resolve, 100)) }) // Build analysis text let analysis = `šŸ” Page Analysis\n\n` analysis += `šŸ“„ URL: ${result.currentUrl}\n` analysis += `šŸ“Š Screenshot: ${result.screenshot ? "Available" : "Not available"}\n` analysis += `šŸ“ Console Logs: ${result.logs ? "Available" : "None"}\n\n` analysis += `šŸ’” AI Agent Notes:\n` analysis += `- Screenshot data available for visual analysis\n` analysis += `- Use this data to understand the page structure\n` analysis += `- Plan next actions based on available elements\n` if (includeScreenshot) { analysis += `- Visual analysis enabled\n` } return { content: [ { type: "text", text: analysis } ] } } ) server.registerTool( "get_page_source", { title: "Get Page Source", description: "Get the full HTML source code of the current page", inputSchema: { includeComments: z.boolean().optional().describe("Include HTML comments in the output (default: true)") } }, async ({ includeComments = true }) => { const browser = await ensureBrowserLaunched() const result = await browser.getPageSource(includeComments) const sourceLength = (result as any).htmlSource?.length || 0 const originalLength = (result as any).originalLength || sourceLength const processedLength = (result as any).processedLength || sourceLength return { content: [ { type: "text", text: `šŸ“„ Page Source Retrieved\n\n` + `šŸ“Š Source Statistics:\n` + `- Original length: ${originalLength} characters\n` + `- Processed length: ${processedLength} characters\n` + `- Comments included: ${includeComments ? "Yes" : "No"}\n` + `- Current URL: ${result.currentUrl}\n\n` + `šŸ“ HTML Source:\n\`\`\`html\n${(result as any).htmlSource || "No source available"}\n\`\`\`` } ] } } ) server.registerTool( "get_page_title", { title: "Get Page Title", description: "Get the title of the current page", inputSchema: {} }, async () => { const browser = await ensureBrowserLaunched() const result = await browser.getPageTitle() return { content: [ { type: "text", text: `šŸ“‹ Page Title\n\n` + `šŸ“„ Title: "${(result as any).pageTitle || "No title found"}"\n` + `🌐 URL: ${result.currentUrl}\n` + `šŸ“ Console Logs: ${result.logs ? "Available" : "None"}` } ] } } ) server.registerTool( "get_page_meta", { title: "Get Page Meta Tags", description: "Extract meta tags and other page metadata from the current page", inputSchema: { includeAllMeta: z.boolean().optional().describe("Include all meta tags, not just common ones (default: false)") } }, async ({ includeAllMeta = false }) => { const browser = await ensureBrowserLaunched() const result = await browser.getPageMeta(includeAllMeta) const meta = (result as any).metaData || {} let metaText = `šŸ“‹ Page Meta Tags\n\n` metaText += `šŸ“„ Title: "${meta.title || "No title"}"\n` metaText += `šŸ“ Description: "${meta.description || "No description"}"\n` metaText += `šŸ·ļø Keywords: "${meta.keywords || "No keywords"}"\n` metaText += `šŸ‘¤ Author: "${meta.author || "No author"}"\n` metaText += `šŸ“± Viewport: "${meta.viewport || "No viewport"}"\n` metaText += `šŸ¤– Robots: "${meta.robots || "No robots"}"\n\n` if (Object.keys(meta.ogTags).length > 0) { metaText += `šŸ“˜ Open Graph Tags:\n` Object.entries(meta.ogTags).forEach(([key, value]) => { metaText += `- ${key}: "${value}"\n` }) metaText += `\n` } if (Object.keys(meta.twitterTags).length > 0) { metaText += `🐦 Twitter Tags:\n` Object.entries(meta.twitterTags).forEach(([key, value]) => { metaText += `- ${key}: "${value}"\n` }) metaText += `\n` } if (includeAllMeta && meta.allMeta.length > 0) { metaText += `šŸ“‹ All Meta Tags:\n` meta.allMeta.forEach((tag: any) => { metaText += `- ${tag.name}: "${tag.content}"\n` }) } metaText += `🌐 URL: ${result.currentUrl}\n` metaText += `šŸ“ Console Logs: ${result.logs ? "Available" : "None"}` return { content: [ { type: "text", text: metaText } ] } } ) server.registerTool( "inspect_element", { title: "Inspect Element", description: "Get HTML and properties of an element at specified coordinates", inputSchema: { coordinates: z.string().describe("Coordinates in format 'x,y'"), includeChildren: z.boolean().optional().describe("Include child elements in the output (default: false)") } }, async ({ coordinates, includeChildren = false }) => { const browser = await ensureBrowserLaunched() const result = await browser.inspectElement(coordinates, includeChildren) const element = (result as any).elementInfo if (!element) { return { content: [ { type: "text", text: `āŒ No element found at coordinates ${coordinates}` } ] } } let elementText = `šŸ” Element Inspection\n\n` elementText += `šŸ“ Coordinates: ${coordinates}\n` elementText += `šŸ·ļø Tag: <${element.tagName}>\n` elementText += `šŸ†” ID: "${element.id || "No ID"}"\n` elementText += `šŸŽØ Classes: "${element.className || "No classes"}"\n` elementText += `šŸ“ Position: x=${element.rect.x}, y=${element.rect.y}\n` elementText += `šŸ“ Size: ${element.rect.width}x${element.rect.height}\n\n` elementText += `šŸ“ Text Content: "${element.textContent || "No text"}"\n\n` elementText += `šŸŽØ Computed Styles:\n` elementText += `- Display: ${element.computedStyle.display}\n` elementText += `- Visibility: ${element.computedStyle.visibility}\n` elementText += `- Opacity: ${element.computedStyle.opacity}\n` elementText += `- Background: ${element.computedStyle.backgroundColor}\n` elementText += `- Color: ${element.computedStyle.color}\n` elementText += `- Font Size: ${element.computedStyle.fontSize}\n` elementText += `- Font Weight: ${element.computedStyle.fontWeight}\n\n` elementText += `šŸ·ļø Attributes:\n` element.attributes.forEach((attr: any) => { elementText += `- ${attr.name}: "${attr.value}"\n` }) if (includeChildren) { elementText += `\nšŸ“¦ Inner HTML:\n\`\`\`html\n${element.innerHTML || "No inner HTML"}\n\`\`\`\n` } elementText += `\nšŸ“„ Outer HTML:\n\`\`\`html\n${element.outerHTML}\n\`\`\`\n` elementText += `🌐 URL: ${result.currentUrl}\n` elementText += `šŸ“ Console Logs: ${result.logs ? "Available" : "None"}` return { content: [ { type: "text", text: elementText } ] } } ) server.registerTool( "get_console_logs", { title: "Get Console Logs", description: "Retrieve JavaScript console logs from the current page", inputSchema: { clearLogs: z.boolean().optional().describe("Clear logs after retrieving them (default: false)") } }, async ({ clearLogs = false }) => { const browser = await ensureBrowserLaunched() // Simply get the current state without performing any action // This will return the last captured logs from navigation const result = { logs: browser.getLastCapturedLogs() || "", currentUrl: browser.getCurrentUrl() || "" } const logs = result.logs || "" const logLines = logs.split('\n').filter((line: string) => line.trim()) let logText = `šŸ“ JavaScript Console Logs\n\n` logText += `🌐 URL: ${result.currentUrl}\n` logText += `šŸ“Š Total log entries: ${logLines.length}\n\n` if (logLines.length > 0) { logText += `šŸ“‹ Log Entries:\n` logLines.forEach((line: string, index: number) => { logText += `${index + 1}. ${line}\n` }) } else { logText += `šŸ“­ No console logs captured\n` logText += `šŸ’” This could mean:\n` logText += `- No JavaScript errors or logs occurred\n` logText += `- Page is static or has minimal JavaScript\n` logText += `- Logs were cleared recently\n` } return { content: [ { type: "text", text: logText } ] } } ) server.registerTool( "close_browser", { title: "Close Browser", description: "Close the browser", inputSchema: {} }, async () => { if (browserSession) { await browserSession.closeBrowser() browserSession = null } return { content: [ { type: "text", text: `šŸ”’ Browser closed successfully` } ] } } ) // Start the server async function main() { const transport = new StdioServerTransport() await server.connect(transport) } main().catch(console.error)