aironin-browse-mcp
Version:
aiRonin Browse MCP server with headed Chrome support
692 lines (607 loc) ⢠19.7 kB
text/typescript
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)