UNPKG

rabbit-browser

Version:

Browser automation tool for detecting interactive elements on web pages

859 lines 38 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.RabbitBrowserMcpServer = void 0; const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js"); const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js"); const sse_js_1 = require("@modelcontextprotocol/sdk/server/sse.js"); const zod_1 = require("zod"); const RabbitBrowser_1 = require("../RabbitBrowser"); /** * Creates and configures an MCP server for RabbitBrowser */ class RabbitBrowserMcpServer { /** * Creates a new RabbitBrowser MCP server * @param options Options to pass to RabbitBrowser */ constructor(options = {}) { // Initialize RabbitBrowser this.browser = new RabbitBrowser_1.RabbitBrowser(options); // Create MCP server this.server = new mcp_js_1.McpServer({ name: "RabbitBrowser", version: "1.0.0", description: "Browser automation tool for detecting interactive elements on web pages", }); // Register resources and tools this.registerResources(); this.registerTools(); this.registerPrompts(); } /** * Register resources with the MCP server */ registerResources() { // Web page resource - provides the entire page context this.server.resource("webpage", new mcp_js_1.ResourceTemplate("webpage://{url}", { list: undefined }), async (uri, params) => { const url = params.url; // Navigate to the URL await this.browser.go(url); // Get the complete data const data = this.browser.getCompleteData(); return { contents: [ { uri: uri.href, text: JSON.stringify(data, null, 2), }, ], }; }); // Elements resource - provides just the interactive elements this.server.resource("elements", new mcp_js_1.ResourceTemplate("elements://{url}", { list: undefined }), async (uri, params) => { const url = params.url; // Navigate to the URL if not already there if (this.browser.getCurrentUrl() !== url) { await this.browser.go(url); } // Get just the elements const elements = this.browser.getElements(); return { contents: [ { uri: uri.href, text: JSON.stringify(elements, null, 2), }, ], }; }); // Text blocks resource - provides the text blocks from the page this.server.resource("textblocks", new mcp_js_1.ResourceTemplate("textblocks://{url}", { list: undefined }), async (uri, params) => { const url = params.url; // Navigate to the URL if not already there if (this.browser.getCurrentUrl() !== url) { await this.browser.go(url); } // Get just the text blocks const textBlocks = this.browser.getTextBlocks(); return { contents: [ { uri: uri.href, text: JSON.stringify(textBlocks, null, 2), }, ], }; }); // Page context resource - provides the page context (title, meta, etc.) this.server.resource("pagecontext", new mcp_js_1.ResourceTemplate("pagecontext://{url}", { list: undefined }), async (uri, params) => { const url = params.url; // Navigate to the URL if not already there if (this.browser.getCurrentUrl() !== url) { await this.browser.go(url); } // Get just the page context const pageContext = this.browser.getPageContext(); return { contents: [ { uri: uri.href, text: JSON.stringify(pageContext, null, 2), }, ], }; }); } /** * Refreshes the browser state to ensure we get the latest elements and context * This will be called after any navigation or interaction */ async refreshState(waitTime = 500) { // Wait a bit for page to stabilize await new Promise((resolve) => setTimeout(resolve, waitTime)); // Force a re-detection of elements if possible if (this.browser.getCurrentUrl() !== "") { try { // Trigger re-detection of elements const refreshedElements = this.browser.getElements(); const refreshedContext = this.browser.getPageContext(); const currentUrl = this.browser.getCurrentUrl(); return { elements: refreshedElements, pageContext: refreshedContext, currentUrl: currentUrl, }; } catch (error) { console.error("Error refreshing browser state:", error); } } // Return current state if refresh fails return { elements: this.browser.getElements(), pageContext: this.browser.getPageContext(), currentUrl: this.browser.getCurrentUrl(), }; } /** * Register tools with the MCP server */ registerTools() { // Tool for navigating to a URL this.server.tool("navigate", { url: zod_1.z.string().url(), }, async ({ url }) => { await this.browser.go(url); // Use the refresh mechanism to get updated state const refreshedState = await this.refreshState(1000); return { content: [ { type: "text", text: `Successfully navigated to ${url}\n\nPage elements: ${refreshedState.elements.length}\nCurrent URL: ${refreshedState.currentUrl}`, }, { type: "text", text: JSON.stringify(refreshedState, null, 2), }, ], }; }); // Enhanced tool to click elements by various attributes (replacing click-by-text) this.server.tool("click-element", { identifier: zod_1.z.string().min(1), identifierType: zod_1.z .enum([ "id", "name", "value", "selector", "class", "role", "tag", "index", ]) .optional() .default("id"), index: zod_1.z.number().optional().default(0), waitForNavigation: zod_1.z.boolean().optional().default(true), }, async ({ identifier, identifierType, index, waitForNavigation }) => { try { let elements = []; switch (identifierType) { case "id": elements = this.browser.filterElements((e) => e.id === identifier); break; case "name": elements = this.browser.filterElements((e) => e.name === identifier); break; case "value": elements = this.browser.filterElements((e) => e.value === identifier); break; case "selector": elements = this.browser.filterElements((e) => Boolean(e.puppet?.selector && e.puppet.selector.includes(identifier))); break; case "class": elements = this.browser.filterElements((e) => Boolean(e.attributes?.class && e.attributes.class.includes(identifier))); break; case "role": elements = this.browser.filterElements((e) => e.role === identifier || e.attributes?.role === identifier); break; case "tag": elements = this.browser.filterElements((e) => e.tagName.toLowerCase() === identifier.toLowerCase()); break; case "index": // Convert identifier to number and find element at that index const elementIndex = parseInt(identifier); if (!isNaN(elementIndex) && elementIndex >= 0 && elementIndex < this.browser.getElements().length) { elements = [this.browser.getElements()[elementIndex]]; } break; } if (elements.length === 0) { return { content: [ { type: "text", text: `No element found with ${identifierType} "${identifier}"`, }, ], isError: true, }; } // Use the specified index if there are multiple matches const targetElement = elements.length > index ? elements[index] : elements[0]; await this.browser.clickElement(targetElement); // Wait for navigation if needed if (waitForNavigation) { // Navigation wait is handled internally by clickElement await new Promise((resolve) => setTimeout(resolve, 500)); // Small delay to ensure page stabilizes } // After clicking, use refreshState to get updated elements const refreshedState = await this.refreshState(waitForNavigation ? 1000 : 500); return { content: [ { type: "text", text: `Clicked ${targetElement.tagName} element with ${identifierType} "${identifier}" (index: ${index})\n\nPage elements: ${refreshedState.elements.length}\nCurrent URL: ${refreshedState.currentUrl}`, }, { type: "text", text: JSON.stringify(refreshedState, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error clicking element: ${error instanceof Error ? error.message : String(error)}`, }, ], isError: true, }; } }); // New tool specifically for search form submission this.server.tool("search", { query: zod_1.z.string().min(1), searchInputIdentifier: zod_1.z.string().optional().default("search"), identifierType: zod_1.z .enum(["text", "placeholder", "name", "id"]) .optional() .default("placeholder"), }, async ({ query, searchInputIdentifier, identifierType }) => { try { // First, find the search input field let inputElements; switch (identifierType) { case "text": inputElements = this.browser.findElementsByText(searchInputIdentifier); break; case "placeholder": inputElements = this.browser.filterElements((e) => Boolean(e.placeholder && e.placeholder .toLowerCase() .includes(searchInputIdentifier.toLowerCase()))); break; case "name": inputElements = this.browser.filterElements((e) => Boolean(e.name && e.name .toLowerCase() .includes(searchInputIdentifier.toLowerCase()))); break; case "id": inputElements = this.browser.filterElements((e) => Boolean(e.id && e.id .toLowerCase() .includes(searchInputIdentifier.toLowerCase()))); break; } if (!inputElements || inputElements.length === 0) { // Try common search input identifiers if nothing found inputElements = this.browser.filterElements((e) => { const hasPlaceholder = e.placeholder && e.placeholder.toLowerCase().includes("search"); const hasNameQ = e.name && e.name.toLowerCase().includes("q"); const isSearchType = e.type === "search"; const isSearchbox = e.role === "searchbox"; return Boolean(hasPlaceholder || hasNameQ || isSearchType || isSearchbox); }); } if (!inputElements || inputElements.length === 0) { return { content: [ { type: "text", text: `No search input field found`, }, ], isError: true, }; } // Find the most likely search input const searchInput = inputElements.find((e) => e.tagName === "input" || e.tagName === "textarea") || inputElements[0]; // Fill the search input with the query await this.browser.fillInput(searchInput, query); // Find the search form let form = null; if (searchInput.formId) { const forms = this.browser.filterElements((e) => e.tagName === "form" && e.id === searchInput.formId); if (forms.length > 0) { form = forms[0]; } } // If no form found by id, look for any form containing the input if (!form) { const forms = this.browser.filterElements((e) => e.tagName === "form"); // We'd need more logic to determine which form contains the input, for now we'll take the first form if (forms.length > 0) { form = forms[0]; } } // If we found a form, submit it directly if (form) { await this.browser.submitForm(form); } else { // No form found, try to find a submit button or search button const searchButtons = this.browser.filterElements((e) => { const isSubmitType = e.type === "submit"; const hasSearchText = e.text && e.text.toLowerCase().includes("search"); const hasSearchAriaLabel = e.ariaLabel && e.ariaLabel.toLowerCase().includes("search"); return Boolean(isSubmitType || hasSearchText || hasSearchAriaLabel); }); if (searchButtons.length > 0) { // Prioritize actual submit buttons const submitButton = searchButtons.find((e) => e.type === "submit") || searchButtons[0]; await this.browser.clickElement(submitButton); } else { // Last resort: press Enter key in the search field await this.browser.pressKey("Enter"); } } // After search, get the latest browser state with longer wait time for search results const refreshedState = await this.refreshState(2000); return { content: [ { type: "text", text: `Performed search for "${query}"\n\nPage elements: ${refreshedState.elements.length}\nCurrent URL: ${refreshedState.currentUrl}`, }, { type: "text", text: JSON.stringify(refreshedState, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error performing search: ${error instanceof Error ? error.message : String(error)}`, }, ], isError: true, }; } }); // Tool for filling a form input this.server.tool("fill-input", { inputIdentifier: zod_1.z.string().min(1), value: zod_1.z.string(), identifierType: zod_1.z .enum(["text", "placeholder", "name", "id"]) .optional() .default("text"), }, async ({ inputIdentifier, value, identifierType }) => { try { let elements; switch (identifierType) { case "text": elements = this.browser.findElementsByText(inputIdentifier); break; case "placeholder": elements = this.browser.filterElements((e) => e.placeholder === inputIdentifier); break; case "name": elements = this.browser.filterElements((e) => e.name === inputIdentifier); break; case "id": elements = this.browser.filterElements((e) => e.id === inputIdentifier); break; } if (!elements || elements.length === 0) { return { content: [ { type: "text", text: `No input found with ${identifierType} "${inputIdentifier}"`, }, ], isError: true, }; } // Find the first form input element const inputElement = elements.find((e) => e.isFormInput || e.tagName === "input" || e.tagName === "textarea"); if (!inputElement) { return { content: [ { type: "text", text: `Found elements with ${identifierType} "${inputIdentifier}", but none are form inputs`, }, ], isError: true, }; } await this.browser.fillInput(inputElement, value); // Use the refresh mechanism to get updated state const refreshedState = await this.refreshState(500); return { content: [ { type: "text", text: `Filled input with ${identifierType} "${inputIdentifier}" with value "${value}"\n\nPage elements: ${refreshedState.elements.length}\nCurrent URL: ${refreshedState.currentUrl}`, }, { type: "text", text: JSON.stringify(refreshedState, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error filling input: ${error instanceof Error ? error.message : String(error)}`, }, ], isError: true, }; } }); // Tool for submitting a form this.server.tool("submit-form", { formIdentifier: zod_1.z.string().optional(), }, async ({ formIdentifier }) => { try { let formElement; if (formIdentifier) { // Try to find the form by identifier const elements = this.browser.filterElements((e) => e.tagName === "form" && (e.id === formIdentifier || e.text.includes(formIdentifier))); if (elements.length === 0) { return { content: [ { type: "text", text: `No form found with identifier "${formIdentifier}"`, }, ], isError: true, }; } formElement = elements[0]; } else { // Try to find any form const forms = this.browser.filterElements((e) => e.tagName === "form"); if (forms.length === 0) { return { content: [ { type: "text", text: "No forms found on the page", }, ], isError: true, }; } formElement = forms[0]; } await this.browser.submitForm(formElement); // Use the refresh mechanism to get updated state with longer wait for form submission const refreshedState = await this.refreshState(2000); return { content: [ { type: "text", text: `Form submitted successfully\n\nPage elements: ${refreshedState.elements.length}\nCurrent URL: ${refreshedState.currentUrl}`, }, { type: "text", text: JSON.stringify(refreshedState, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error submitting form: ${error instanceof Error ? error.message : String(error)}`, }, ], isError: true, }; } }); // Tool for getting current URL this.server.tool("get-current-url", {}, async () => { // Always refresh the state to ensure we have up-to-date information const refreshedState = await this.refreshState(500); return { content: [ { type: "text", text: `Current URL: ${refreshedState.currentUrl}\n\nPage elements: ${refreshedState.elements.length}`, }, { type: "text", text: JSON.stringify(refreshedState, null, 2), }, ], }; }); // New tool to handle cookie consents and common UI patterns this.server.tool("handle-common-dialog", { dialogType: zod_1.z .enum(["cookie-consent", "notification", "popup"]) .default("cookie-consent"), action: zod_1.z.enum(["accept", "decline", "close"]).default("accept"), customText: zod_1.z.string().optional(), }, async ({ dialogType, action, customText }) => { try { // Common text patterns for different dialog types and actions const commonPatterns = { "cookie-consent-accept": [ "Accept all", "Accept all cookies", "I accept", "Accept cookies", "Accept", "Agree", "Agree to all", "I agree", "Allow all", "Allow", "Continue", "OK", "Got it", "Yes", // German "Alle akzeptieren", "Akzeptieren", "Zustimmen", "Alles akzeptieren", // French "Accepter tout", "Tout accepter", "J'accepte", "Accepter", // Spanish "Aceptar todo", "Aceptar", "Acepto", // Italian "Accetta tutto", "Accetta", "Accetto", ], "cookie-consent-decline": [ "Decline", "Decline all", "Reject", "Reject all", "No thanks", "No", "Decline cookies", "Reject cookies", "Necessary cookies only", // German "Ablehnen", "Alle ablehnen", "Nein, danke", // French "Refuser", "Tout refuser", "Non merci", // Spanish "Rechazar", "Rechazar todo", "No gracias", // Italian "Rifiuta", "Rifiuta tutto", "No grazie", ], "notification-close": [ "Close", "Dismiss", "Got it", "OK", "I understand", "Skip", "Later", "Not now", "X", "×", "✕", "✖", "Cancel", "No thanks", // German "Schließen", "Verstanden", "Später", "Nicht jetzt", "Abbrechen", // French "Fermer", "Compris", "Plus tard", "Pas maintenant", "Annuler", // Spanish "Cerrar", "Entendido", "Más tarde", "Ahora no", "Cancelar", // Italian "Chiudi", "Capito", "Più tardi", "Non ora", "Annulla", ], "popup-close": [ "Close", "Dismiss", "Cancel", "X", "×", "✕", "✖", "No thanks", ], }; // Determine which text patterns to use based on dialogType and action const patternKey = `${dialogType}-${action}`; let textPatterns = commonPatterns[patternKey] || []; // Add custom text if provided if (customText) { textPatterns = [customText, ...textPatterns]; } // Track if we found and clicked anything let elementFound = false; let clickedElement = null; // Try each text pattern for (const pattern of textPatterns) { // First, try to find by exact text using the enhanced click-by-text internal logic let elements = this.browser.findElementsByText(pattern); // Also look for buttons with value attribute const valueElements = this.browser.filterElements((e) => Boolean((e.tagName === "input" || e.tagName === "button") && e.value === pattern)); // Merge results elements = [...valueElements, ...elements]; // If no exact matches, try loose partial match if (elements.length === 0) { elements = this.browser.filterElements((e) => { // Check text content for pattern (case insensitive) const textMatch = e.text && e.text.toLowerCase().includes(pattern.toLowerCase()); // Check value attribute for pattern const valueMatch = e.value && e.value.toLowerCase().includes(pattern.toLowerCase()); // Check aria-label for pattern const ariaLabelMatch = e.ariaLabel && e.ariaLabel.toLowerCase().includes(pattern.toLowerCase()); return Boolean(textMatch || valueMatch || ariaLabelMatch); }); } if (elements.length > 0) { // Prioritize buttons and clickable elements const priorityElements = elements.filter((e) => e.tagName === "button" || e.type === "submit" || e.role === "button" || (e.tagName === "a" && e.href) || e.isClickable); const targetElement = priorityElements.length > 0 ? priorityElements[0] : elements[0]; try { await this.browser.clickElement(targetElement); elementFound = true; clickedElement = targetElement; break; // Stop after successfully clicking an element } catch (error) { console.error(`Failed to click element with text "${pattern}": ${error}`); // Continue trying other patterns } } } // Check for specific selectors if no text match worked if (!elementFound) { // Common cookie consent button selectors const commonSelectors = [ "#accept-cookies", "#acceptAllButton", "#onetrust-accept-btn-handler", ".cookie-accept", ".accept-cookies-button", ".consent-accept", "[aria-label*='accept' i]", "[aria-label*='cookie' i][role='button']", "button[class*='accept' i]", "button[class*='cookie' i]", "#consent_prompt_submit", ".js-accept-cookies", ".js-cookie-consent-agree", ]; for (const selector of commonSelectors) { try { const selectorElements = this.browser.filterElements((e) => Boolean(e.puppet && e.puppet.selector && e.puppet.selector.match(new RegExp(selector.replace(/[\[\]\.\#]/g, ""), "i")))); if (selectorElements.length > 0) { await this.browser.clickElement(selectorElements[0]); elementFound = true; clickedElement = selectorElements[0]; break; } } catch (error) { // Continue trying other selectors } } } // Get updated elements after action const refreshedState = await this.refreshState(1000); if (elementFound && clickedElement) { return { content: [ { type: "text", text: `Successfully ${action}ed ${dialogType} dialog by clicking ${clickedElement.tagName} element with text "${clickedElement.text || clickedElement.value || ""}"\n\nPage elements: ${refreshedState.elements.length}\nCurrent URL: ${refreshedState.currentUrl}`, }, { type: "text", text: JSON.stringify(refreshedState, null, 2), }, ], }; } else { return { content: [ { type: "text", text: `Could not find any ${dialogType} ${action} buttons on the page. You may need to handle this manually.`, }, { type: "text", text: JSON.stringify(refreshedState, null, 2), }, ], isError: true, }; } } catch (error) { return { content: [ { type: "text", text: `Error handling ${dialogType} dialog: ${error instanceof Error ? error.message : String(error)}`, }, ], isError: true, }; } }); } /** * Register prompts with the MCP server */ registerPrompts() { // Prompt for analyzing a web page this.server.prompt("analyze-webpage", "Analyze a webpage and provide insights about its structure and content", () => ({ messages: [ { role: "user", content: { type: "text", text: `Please analyze the webpage. Tell me about its structure, main content, and interactive elements.`, }, }, ], })); // Prompt for filling out a form - using standard text instead of zod schemas this.server.prompt("fill-form", "Fill out a form on a webpage using provided data", () => ({ messages: [ { role: "user", content: { type: "text", text: `Please fill out the form with the provided information, then submit it.`, }, }, ], })); } /** * Start the MCP server with stdio transport */ async startWithStdio() { const transport = new stdio_js_1.StdioServerTransport(); await this.server.connect(transport); return transport; } /** * Start the MCP server with SSE (Server-Sent Events) transport * @param res Express response object * @param postUrl URL for clients to post messages to */ async startWithSSE(res, postUrl) { const transport = new sse_js_1.SSEServerTransport(postUrl, res); await this.server.connect(transport); return transport; } /** * Close the browser */ async close() { await this.browser.close(); } } exports.RabbitBrowserMcpServer = RabbitBrowserMcpServer; //# sourceMappingURL=server.js.map