rabbit-browser
Version:
Browser automation tool for detecting interactive elements on web pages
859 lines • 38 kB
JavaScript
"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