UNPKG

mcp-browser-inspector

Version:

MCP tool for browser inspection, element selection, interactive messaging, and responsive testing

735 lines (734 loc) 35.4 kB
#!/usr/bin/env node import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js'; import puppeteer from 'puppeteer'; // Browser state let browser = null; let page = null; let elementInfo = null; class BrowserInspectorServer { constructor() { this.server = new Server({ name: 'browser-inspector', version: '0.1.0', }, { capabilities: { tools: {}, }, }); this.setupToolHandlers(); // Error handling this.server.onerror = (error) => console.error('[MCP Error]', error); process.on('SIGINT', async () => { await this.closeBrowser(); await this.server.close(); process.exit(0); }); } async closeBrowser() { if (browser) { await browser.close(); browser = null; page = null; elementInfo = null; } } async closeBrowserTool() { try { await this.closeBrowser(); return { content: [ { type: 'text', text: 'Browser closed successfully.', }, ], }; } catch (error) { console.error('Error closing browser:', error); return { content: [ { type: 'text', text: `Error closing browser: ${error}`, }, ], isError: true, }; } } async getElementInfo() { try { if (!browser || !page) { return { content: [ { type: 'text', text: 'Browser is not launched. Please launch the browser first.', }, ], isError: true, }; } // Get the selected element info and message const result = await page.evaluate(() => { return { selectedElement: window.selectedElement, message: window.selectedElementMessage || '' }; }); if (!result.selectedElement) { return { content: [ { type: 'text', text: 'No element selected. Please use the inspect_element tool and click on an element first.', }, ], isError: true, }; } const selectedElement = result.selectedElement; const message = result.message; // Store element info for later use elementInfo = { selector: selectedElement.selector, html: selectedElement.html, css: JSON.stringify(selectedElement.css, null, 2) }; // Take a screenshot const screenshot = await page.screenshot({ encoding: 'base64' }); // Prepare response content const content = [ { type: 'text', text: `Selected element: ${selectedElement.selector}`, }, { type: 'text', text: `HTML: ${selectedElement.html.substring(0, 500)}${selectedElement.html.length > 500 ? '...' : ''}`, }, { type: 'text', text: 'CSS (computed styles):', }, { type: 'code', language: 'json', text: JSON.stringify(selectedElement.css, null, 2), }, { type: 'image', data: screenshot, mimeType: 'image/png', } ]; // Add message if provided if (message) { content.unshift({ type: 'text', text: `Message from user: ${message}`, }); } return { content }; } catch (error) { console.error('Error getting element info:', error); return { content: [ { type: 'text', text: `Error getting element info: ${error}`, }, ], isError: true, }; } } async modifyElement(args) { try { if (!browser || !page) { return { content: [ { type: 'text', text: 'Browser is not launched. Please launch the browser first.', }, ], isError: true, }; } if (!elementInfo) { return { content: [ { type: 'text', text: 'No element selected. Please use the inspect_element tool and click on an element first.', }, ], isError: true, }; } const { selector } = elementInfo; const { css, html } = args; // Apply modifications if (css) { await page.evaluate((data) => { const element = document.querySelector(data.selector); if (element) { Object.entries(data.css).forEach(([property, value]) => { element.style[property] = value; }); } }, { selector, css }); } if (html) { await page.evaluate((data) => { const element = document.querySelector(data.selector); if (element) { element.outerHTML = data.html; } }, { selector, html }); } // Take a screenshot const screenshot = await page.screenshot({ encoding: 'base64' }); return { content: [ { type: 'text', text: `Element ${selector} modified successfully.`, }, { type: 'image', data: screenshot, mimeType: 'image/png', }, ], }; } catch (error) { console.error('Error modifying element:', error); return { content: [ { type: 'text', text: `Error modifying element: ${error}`, }, ], isError: true, }; } } setupToolHandlers() { this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: 'launch_browser', description: 'Launch a browser and navigate to a URL', inputSchema: { type: 'object', properties: { url: { type: 'string', description: 'URL to navigate to', }, }, required: ['url'], }, }, { name: 'inspect_element', description: 'Enable element inspection mode in the browser', inputSchema: { type: 'object', properties: {}, }, }, { name: 'get_element_info', description: 'Get information about the currently selected element', inputSchema: { type: 'object', properties: {}, }, }, { name: 'modify_element', description: 'Modify the currently selected element', inputSchema: { type: 'object', properties: { css: { type: 'object', description: 'CSS properties to modify', additionalProperties: { type: 'string', }, }, html: { type: 'string', description: 'New HTML content for the element', }, }, }, }, { name: 'close_browser', description: 'Close the browser', inputSchema: { type: 'object', properties: {}, }, }, ], })); this.server.setRequestHandler(CallToolRequestSchema, async (request) => { switch (request.params.name) { case 'launch_browser': return this.launchBrowser(request.params.arguments); case 'inspect_element': return this.inspectElement(); case 'get_element_info': return this.getElementInfo(); case 'modify_element': return this.modifyElement(request.params.arguments); case 'close_browser': return this.closeBrowserTool(); default: throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`); } }); } async launchBrowser(args) { try { if (browser) { await this.closeBrowser(); } if (!args.url) { return { content: [ { type: 'text', text: 'URL is required', }, ], isError: true, }; } browser = await puppeteer.launch({ headless: false, defaultViewport: null, // Allow viewport to be fully resizable args: ['--window-size=1280,800', '--start-maximized'], }); page = await browser.newPage(); await page.goto(args.url); // Take a screenshot const screenshot = await page.screenshot({ encoding: 'base64' }); return { content: [ { type: 'text', text: `Browser launched and navigated to ${args.url}`, }, { type: 'image', data: screenshot, mimeType: 'image/png', }, ], }; } catch (error) { console.error('Error launching browser:', error); return { content: [ { type: 'text', text: `Error launching browser: ${error}`, }, ], isError: true, }; } } async inspectElement() { try { if (!browser || !page) { return { content: [ { type: 'text', text: 'Browser is not launched. Please launch the browser first.', }, ], isError: true, }; } // Inject element selection script with floating icon and toolbar await page.evaluate(() => { // Implementation of the element selection UI with floating icon // This includes the toolbar with "Select Element" and "Change Resolution" buttons // And the resolution picker popup // Create floating icon const floatingIcon = document.createElement('div'); floatingIcon.id = 'mcp-floating-icon'; floatingIcon.style.position = 'fixed'; floatingIcon.style.bottom = '10px'; floatingIcon.style.right = '10px'; floatingIcon.style.width = '50px'; floatingIcon.style.height = '50px'; floatingIcon.style.borderRadius = '50%'; floatingIcon.style.backgroundColor = '#007bff'; floatingIcon.style.boxShadow = '0 2px 10px rgba(0, 0, 0, 0.3)'; floatingIcon.style.cursor = 'pointer'; floatingIcon.style.zIndex = '2147483647'; // Maximum z-index to ensure visibility floatingIcon.style.display = 'flex'; floatingIcon.style.alignItems = 'center'; floatingIcon.style.justifyContent = 'center'; floatingIcon.style.transition = 'all 0.3s ease'; floatingIcon.style.transform = 'scale(0.9)'; // Slightly smaller to ensure it fits // Add SVG icon inside the floating button floatingIcon.innerHTML = ` <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <path d="M17 8l4 4-4 4"></path> <path d="M3 12h18"></path> </svg> `; document.body.appendChild(floatingIcon); // Create toolbar (initially hidden) const toolbar = document.createElement('div'); toolbar.id = 'mcp-toolbar'; toolbar.style.position = 'fixed'; toolbar.style.bottom = '20px'; toolbar.style.right = '80px'; toolbar.style.backgroundColor = 'white'; toolbar.style.borderRadius = '8px'; toolbar.style.boxShadow = '0 2px 10px rgba(0, 0, 0, 0.2)'; toolbar.style.padding = '10px'; toolbar.style.display = 'none'; toolbar.style.zIndex = '10002'; // Add buttons to toolbar const selectElementButton = document.createElement('button'); selectElementButton.id = 'mcp-select-element-btn'; selectElementButton.innerHTML = 'Select Element'; selectElementButton.style.backgroundColor = '#007bff'; selectElementButton.style.color = 'white'; selectElementButton.style.border = 'none'; selectElementButton.style.borderRadius = '5px'; selectElementButton.style.padding = '8px 12px'; selectElementButton.style.marginRight = '10px'; selectElementButton.style.cursor = 'pointer'; const changeResolutionButton = document.createElement('button'); changeResolutionButton.id = 'mcp-change-resolution-btn'; changeResolutionButton.innerHTML = 'Change Resolution'; changeResolutionButton.style.backgroundColor = '#6c757d'; changeResolutionButton.style.color = 'white'; changeResolutionButton.style.border = 'none'; changeResolutionButton.style.borderRadius = '5px'; changeResolutionButton.style.padding = '8px 12px'; changeResolutionButton.style.cursor = 'pointer'; toolbar.appendChild(selectElementButton); toolbar.appendChild(changeResolutionButton); document.body.appendChild(toolbar); // Create resolution picker (initially hidden) const resolutionPicker = document.createElement('div'); resolutionPicker.id = 'mcp-resolution-picker'; resolutionPicker.style.position = 'fixed'; resolutionPicker.style.bottom = '80px'; resolutionPicker.style.right = '80px'; resolutionPicker.style.backgroundColor = 'white'; resolutionPicker.style.borderRadius = '8px'; resolutionPicker.style.boxShadow = '0 2px 10px rgba(0, 0, 0, 0.2)'; resolutionPicker.style.padding = '15px'; resolutionPicker.style.display = 'none'; resolutionPicker.style.zIndex = '10004'; resolutionPicker.style.width = '300px'; // Add close button to resolution picker const closeButton = document.createElement('div'); closeButton.id = 'mcp-close-resolution'; closeButton.style.position = 'absolute'; closeButton.style.top = '10px'; closeButton.style.right = '10px'; closeButton.style.cursor = 'pointer'; closeButton.innerHTML = '✕'; resolutionPicker.appendChild(closeButton); // Add device options to resolution picker // ... (implementation details) document.body.appendChild(resolutionPicker); // Add event listeners for the floating icon, toolbar, and resolution picker let isToolbarVisible = false; let isResolutionPickerVisible = false; let isSelectionModeActive = false; // Toggle toolbar when floating icon is clicked floatingIcon.addEventListener('click', () => { isToolbarVisible = !isToolbarVisible; toolbar.style.display = isToolbarVisible ? 'block' : 'none'; }); // Toggle resolution picker when change resolution button is clicked changeResolutionButton.addEventListener('click', () => { isResolutionPickerVisible = !isResolutionPickerVisible; resolutionPicker.style.display = isResolutionPickerVisible ? 'block' : 'none'; }); // Close resolution picker when close button is clicked closeButton.addEventListener('click', () => { isResolutionPickerVisible = false; resolutionPicker.style.display = 'none'; }); // Toggle selection mode when select element button is clicked selectElementButton.addEventListener('click', () => { isSelectionModeActive = !isSelectionModeActive; if (isSelectionModeActive) { // Change floating icon to red X floatingIcon.style.backgroundColor = '#dc3545'; floatingIcon.innerHTML = ` <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <line x1="18" y1="6" x2="6" y2="18"></line> <line x1="6" y1="6" x2="18" y2="18"></line> </svg> `; // Hide toolbar isToolbarVisible = false; toolbar.style.display = 'none'; // Add element selection functionality const highlightElement = (element) => { // Remove previous highlight const prevHighlight = document.querySelector('.mcp-element-highlight'); if (prevHighlight) { document.body.removeChild(prevHighlight); } // Get element position and dimensions const rect = element.getBoundingClientRect(); // Create highlight overlay const highlight = document.createElement('div'); highlight.className = 'mcp-element-highlight'; highlight.style.position = 'fixed'; highlight.style.top = rect.top + 'px'; highlight.style.left = rect.left + 'px'; highlight.style.width = rect.width + 'px'; highlight.style.height = rect.height + 'px'; highlight.style.border = '2px solid #ff0000'; highlight.style.backgroundColor = 'rgba(255, 0, 0, 0.2)'; highlight.style.pointerEvents = 'none'; highlight.style.zIndex = '2147483646'; // Just below the floating icon document.body.appendChild(highlight); }; // Add mouseover event to highlight elements document.addEventListener('mouseover', (e) => { if (isSelectionModeActive) { highlightElement(e.target); } }); // Add click event to select elements document.addEventListener('click', (e) => { if (isSelectionModeActive) { e.preventDefault(); e.stopPropagation(); const target = e.target; // Don't select the floating icon or toolbar if (floatingIcon.contains(target) || toolbar.contains(target) || resolutionPicker.contains(target)) { return; } // Get element info const computedStyle = window.getComputedStyle(target); const cssProperties = {}; for (let i = 0; i < computedStyle.length; i++) { const prop = computedStyle[i]; cssProperties[prop] = computedStyle.getPropertyValue(prop); } // Store selected element info in window object window.selectedElement = { selector: getUniqueSelector(target), html: target.outerHTML, css: cssProperties }; // Exit selection mode isSelectionModeActive = false; // Change floating icon back to original floatingIcon.style.backgroundColor = '#007bff'; floatingIcon.innerHTML = ` <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <path d="M17 8l4 4-4 4"></path> <path d="M3 12h18"></path> </svg> `; // Remove highlight const highlight = document.querySelector('.mcp-element-highlight'); if (highlight) { document.body.removeChild(highlight); } // Turn the selected element green const originalBackgroundColor = target.style.backgroundColor; target.style.backgroundColor = 'rgba(40, 167, 69, 0.3)'; // Green with transparency target.style.transition = 'background-color 0.3s ease'; // Create dialog box for sending a message const dialogBox = document.createElement('div'); dialogBox.id = 'mcp-dialog-box'; dialogBox.style.position = 'fixed'; dialogBox.style.top = '50%'; dialogBox.style.left = '50%'; dialogBox.style.transform = 'translate(-50%, -50%)'; dialogBox.style.backgroundColor = 'white'; dialogBox.style.borderRadius = '8px'; dialogBox.style.boxShadow = '0 4px 20px rgba(0, 0, 0, 0.3)'; dialogBox.style.padding = '20px'; dialogBox.style.zIndex = '2147483647'; dialogBox.style.width = '400px'; dialogBox.style.maxWidth = '90%'; // Add dialog content dialogBox.innerHTML = ` <h3 style="margin-top: 0; color: #333; font-family: sans-serif;">Element Selected</h3> <p style="color: #666; font-family: sans-serif;">Selected element: ${getUniqueSelector(target)}</p> <textarea id="mcp-message-input" placeholder="Enter your message here..." style="width: 100%; height: 100px; padding: 8px; margin: 10px 0; border-radius: 4px; border: 1px solid #ddd; font-family: sans-serif; resize: vertical;"></textarea> <div style="display: flex; justify-content: flex-end; gap: 10px;"> <button id="mcp-cancel-btn" style="padding: 8px 16px; border: none; border-radius: 4px; background-color: #6c757d; color: white; cursor: pointer; font-family: sans-serif;">Cancel</button> <button id="mcp-send-btn" style="padding: 8px 16px; border: none; border-radius: 4px; background-color: #28a745; color: white; cursor: pointer; font-family: sans-serif;">Send</button> </div> `; document.body.appendChild(dialogBox); // Focus the textarea setTimeout(() => { const textarea = document.getElementById('mcp-message-input'); if (textarea) { textarea.focus(); } }, 100); // Add event listeners for dialog buttons document.getElementById('mcp-cancel-btn')?.addEventListener('click', () => { // Remove dialog box document.body.removeChild(dialogBox); // Restore original background color target.style.backgroundColor = originalBackgroundColor; }); document.getElementById('mcp-send-btn')?.addEventListener('click', () => { const messageInput = document.getElementById('mcp-message-input'); const message = messageInput.value.trim(); if (message) { // Store the message in the window object window.selectedElementMessage = message; // Show success notification const notification = document.createElement('div'); notification.style.position = 'fixed'; notification.style.top = '20px'; notification.style.left = '50%'; notification.style.transform = 'translateX(-50%)'; notification.style.backgroundColor = '#28a745'; notification.style.color = 'white'; notification.style.padding = '10px 20px'; notification.style.borderRadius = '5px'; notification.style.boxShadow = '0 2px 10px rgba(0, 0, 0, 0.2)'; notification.style.zIndex = '2147483647'; notification.textContent = 'Message sent!'; document.body.appendChild(notification); // Remove notification after 2 seconds setTimeout(() => { document.body.removeChild(notification); }, 2000); } // Remove dialog box document.body.removeChild(dialogBox); // Keep the element green for a moment, then restore original color setTimeout(() => { target.style.backgroundColor = originalBackgroundColor; }, 2000); }); } }, true); // Function to get a unique CSS selector for an element function getUniqueSelector(element) { if (element.id) { return '#' + element.id; } if (element.tagName === 'BODY') { return 'body'; } const parent = element.parentElement; if (!parent) { return element.tagName.toLowerCase(); } // Get all siblings with the same tag const siblings = Array.from(parent.children).filter(child => child.tagName === element.tagName); // If there's only one element with this tag, use the tag name if (siblings.length === 1) { return getUniqueSelector(parent) + ' > ' + element.tagName.toLowerCase(); } // Find the index of the element among its siblings const index = siblings.indexOf(element); // Use nth-child selector return getUniqueSelector(parent) + ' > ' + element.tagName.toLowerCase() + ':nth-child(' + (index + 1) + ')'; } } else { // Change floating icon back to original floatingIcon.style.backgroundColor = '#007bff'; floatingIcon.innerHTML = ` <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <path d="M17 8l4 4-4 4"></path> <path d="M3 12h18"></path> </svg> `; // Remove highlight const highlight = document.querySelector('.mcp-element-highlight'); if (highlight) { document.body.removeChild(highlight); } } }); // Close toolbar and resolution picker when clicking outside document.addEventListener('click', (e) => { const target = e.target; // Don't close if clicking on the floating icon, toolbar, or resolution picker if (floatingIcon.contains(target) || toolbar.contains(target) || resolutionPicker.contains(target)) { return; } // Don't close if in selection mode if (isSelectionModeActive) { return; } // Close toolbar and resolution picker isToolbarVisible = false; isResolutionPickerVisible = false; toolbar.style.display = 'none'; resolutionPicker.style.display = 'none'; }); }); // Wait a moment for the UI to be fully injected await new Promise(resolve => setTimeout(resolve, 500)); // Take a screenshot const screenshot = await page.screenshot({ encoding: 'base64' }); return { content: [ { type: 'text', text: 'Element inspection mode enabled. You should see a blue floating icon in the bottom-right corner. Click on it to show the toolbar.', }, { type: 'image', data: screenshot, mimeType: 'image/png', }, ], }; } catch (error) { console.error('Error enabling inspection mode:', error); return { content: [ { type: 'text', text: `Error enabling inspection mode: ${error}`, }, ], isError: true, }; } } async run() { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error('Browser Inspector MCP server running on stdio'); } } const server = new BrowserInspectorServer(); server.run().catch(console.error);