From 87a0892f0e6d03ab0839c3cc3f96010cdd65cfb2 Mon Sep 17 00:00:00 2001 From: robertheadley Date: Fri, 7 Mar 2025 13:01:40 -0600 Subject: [PATCH 1/2] Add comprehensive command documentation --- README.md | 2 + docs/COMMANDS.md | 152 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 docs/COMMANDS.md diff --git a/README.md b/README.md index 440d7ae..2b6fcd7 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,8 @@ A Model Context Protocol (MCP) server for controlling Chrome with debugging capa ## Usage +For a complete reference of all available commands, tools, and functions, see [COMMANDS.md](docs/COMMANDS.md). + ### Basic Chrome Launch ```javascript use_mcp_tool({ diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md new file mode 100644 index 0000000..9b3b980 --- /dev/null +++ b/docs/COMMANDS.md @@ -0,0 +1,152 @@ +# Chrome Debug MCP Commands Reference + +This document provides a comprehensive reference for all commands and functions available in the Chrome Debug MCP server. + +## MCP Tools + +### launch_chrome +Launches Chrome in debug mode with customizable configuration. + +**Parameters:** +- `url` (string, optional): URL to navigate to after launch +- `executablePath` (string, optional): Path to Chrome executable (uses bundled Chrome if not provided) +- `userDataDir` (string, optional): Path to user data directory (uses default Chrome profile if not provided) +- `loadExtension` (string, optional): Path to unpacked extension directory to load +- `disableExtensionsExcept` (string, optional): Path to extension that should remain enabled while others are disabled +- `disableAutomationControlled` (boolean, optional): Disable Chrome's "Automation Controlled" mode +- `userscriptPath` (string, optional): Path to userscript file to inject + +**Example:** +```javascript + +chrome-debug +launch_chrome + +{ + "url": "https://example.com", + "loadExtension": "C:\\path\\to\\extension", + "disableAutomationControlled": true +} + + +``` + +### get_console_logs +Retrieves console logs from the Chrome instance. + +**Parameters:** +- `clear` (boolean, optional): Whether to clear logs after retrieving + +**Example:** +```javascript + +chrome-debug +get_console_logs + +{ + "clear": true +} + + +``` + +### evaluate +Executes JavaScript code in the browser context. + +**Parameters:** +- `expression` (string, required): JavaScript code to evaluate + +**Example:** +```javascript + +chrome-debug +evaluate + +{ + "expression": "document.title" +} + + +``` + +## Userscript Functions + +### GM_setValue(key: string, value: any) +Stores a value persistently using Chrome's localStorage. + +```javascript +// Store values +GM_setValue('myKey', 'myValue'); +GM_setValue('myObject', { foo: 'bar' }); +``` + +### GM_getValue(key: string, defaultValue?: any) +Retrieves a previously stored value. + +```javascript +// Retrieve values +const myValue = GM_getValue('myKey', 'default'); +const myObject = GM_getValue('myObject', {}); +``` + +### GM_xmlhttpRequest(details: object) +Makes HTTP requests that bypass same-origin policy restrictions. + +```javascript +GM_xmlhttpRequest({ + url: 'https://api.example.com/data', + onload: function(response) { + console.log(response.responseText); + }, + onerror: function(error) { + console.error('Request failed:', error); + } +}); +``` + +### GM_addStyle(css: string) +Adds custom CSS styles to the page. + +```javascript +GM_addStyle(` + .my-custom-class { + background: red; + color: white; + padding: 10px; + } +`); +``` + +### GM_openInTab(url: string) +Opens a URL in a new browser tab. + +```javascript +GM_openInTab('https://example.com'); +``` + +### GM_registerMenuCommand(name: string, fn: function) +Registers a command in the userscript menu (stub implementation). + +```javascript +GM_registerMenuCommand('My Command', function() { + console.log('Command executed'); +}); +``` + +## Error Handling + +The server may return the following error codes: + +- `ErrorCode.InternalError`: Server-side error occurred +- `ErrorCode.InvalidParams`: Invalid tool parameters provided +- `ErrorCode.MethodNotFound`: Requested tool does not exist + +## Best Practices + +1. Always check for successful Chrome launch before using other tools +2. Use absolute paths for extension and userscript loading +3. Clear console logs periodically to prevent memory issues +4. Handle errors appropriately in userscripts +5. Test userscripts in a standard browser before using with the MCP server +6. Use `disableAutomationControlled` when websites detect automation +7. Monitor memory usage and restart Chrome if needed \ No newline at end of file From 78d3ec183af517f4544f924e726be56fe2fd3400 Mon Sep 17 00:00:00 2001 From: robertheadley Date: Fri, 7 Mar 2025 13:40:58 -0600 Subject: [PATCH 2/2] feat: add Puppeteer commands and update documentation - Add new Puppeteer-based MCP tools for page automation - Add type definitions and handlers for Puppeteer commands - Create test page and test script - Update documentation: - Add Page Automation section to README - Add Puppeteer integration learnings - Standardize llms-install.md format - Update COMMANDS.md with new tools --- LEARNINGS.md | 49 +++++- README.md | 86 +++++++++ docs/COMMANDS.md | 264 ++++++++++++++++++++++++--- docs/llms-install.md | 211 ++++++---------------- src/handlers/puppeteer-handlers.ts | 223 +++++++++++++++++++++++ src/index.ts | 158 ++++++++++------- src/tool-definitions.ts | 274 +++++++++++++++++++++++++++++ src/types/puppeteer-tools.ts | 85 +++++++++ test/run-tests.js | 131 ++++++++++++++ test/test-screenshot.png | Bin 0 -> 21490 bytes test/test.html | 93 ++++++++-- 11 files changed, 1316 insertions(+), 258 deletions(-) create mode 100644 src/handlers/puppeteer-handlers.ts create mode 100644 src/tool-definitions.ts create mode 100644 src/types/puppeteer-tools.ts create mode 100644 test/run-tests.js create mode 100644 test/test-screenshot.png diff --git a/LEARNINGS.md b/LEARNINGS.md index 626ba64..dc61657 100644 --- a/LEARNINGS.md +++ b/LEARNINGS.md @@ -137,5 +137,52 @@ This document captures key learnings from implementing a Chrome debug MCP server - Proper handling of profile paths and permissions required - Prevent Puppeteer from disabling extensions using ignoreDefaultArgs +## Puppeteer Integration Insights + +### 1. MCP Tool Architecture +- **Modular Command Structure** + - Separate handlers for each Puppeteer command + - Type guards ensure parameter validation + - Clear separation of concerns in tool definitions + +### 2. Type Safety +- Created comprehensive TypeScript interfaces for all commands +- Runtime type checking prevents invalid parameter passing +- Error messages provide clear feedback on parameter issues + +### 3. Testing Approach +- HTML test page for verifying all commands +- Sequential testing ensures command interdependencies +- Real-time verification of command results +- Screenshot capability helps verify visual outcomes + +### 4. Error Handling +- Proper handling of missing pages/contexts +- Timeout handling for element waiting +- Graceful failure handling for element interactions + +### 5. Page Management +- Active page tracking improves reliability +- Automatic page creation when needed +- Proper cleanup of page resources + +### 6. Performance Considerations +- Delayed typing mimics human interaction +- Wait conditions prevent race conditions +- Screenshot optimization options + +### 7. Documentation +- Clear command reference with examples +- Parameter descriptions and requirements +- Consistent documentation format across commands + +### 8. Code Organization +- Separate files for different concerns: + - Tool definitions + - Command handlers + - Type definitions + - Tests +- Makes codebase more maintainable + ## Conclusion -The Chrome debug MCP server implementation provided valuable insights into browser automation, script injection, and debugging. The solutions developed here can serve as a foundation for future browser automation and userscript management projects. \ No newline at end of file +The Chrome debug MCP server implementation provided valuable insights into browser automation, script injection, and debugging. The addition of Puppeteer commands significantly enhanced the server's capabilities, providing a robust foundation for automated browser interaction through the MCP interface. \ No newline at end of file diff --git a/README.md b/README.md index 2b6fcd7..4a4badd 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,15 @@ A Model Context Protocol (MCP) server for controlling Chrome with debugging capa - Extension support and management - Disable Chrome's "Automation Controlled" banner +### Page Automation +- Click, type, and interact with page elements +- Handle dropdowns and form inputs +- Hover and wait for elements +- Take screenshots of full page or elements +- Navigate between pages +- Set viewport size and device emulation +- Extract text and attributes from elements + ### Debugging Capabilities - Remote debugging via Chrome DevTools Protocol (CDP) - Console log capture and monitoring @@ -141,6 +150,83 @@ use_mcp_tool({ }) ``` +### Page Interaction Examples + +#### Click an Element +```javascript +use_mcp_tool({ + server_name: "chrome-debug", + tool_name: "click", + arguments: { + selector: "#submit-button", + delay: 500 + } +}) +``` + +#### Type into Input +```javascript +use_mcp_tool({ + server_name: "chrome-debug", + tool_name: "type", + arguments: { + selector: "#search-input", + text: "search query", + delay: 100 + } +}) +``` + +#### Select from Dropdown +```javascript +use_mcp_tool({ + server_name: "chrome-debug", + tool_name: "select", + arguments: { + selector: "#country-select", + value: "US" + } +}) +``` + +#### Wait for Element +```javascript +use_mcp_tool({ + server_name: "chrome-debug", + tool_name: "wait_for_selector", + arguments: { + selector: ".loading-complete", + visible: true, + timeout: 5000 + } +}) +``` + +#### Take Screenshot +```javascript +use_mcp_tool({ + server_name: "chrome-debug", + tool_name: "screenshot", + arguments: { + path: "screenshot.png", + fullPage: true + } +}) +``` + +#### Set Viewport Size +```javascript +use_mcp_tool({ + server_name: "chrome-debug", + tool_name: "set_viewport", + arguments: { + width: 1920, + height: 1080, + deviceScaleFactor: 1 + } +}) +``` + ## Dependencies This project uses the following open-source packages: diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 9b3b980..80d26f9 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -4,17 +4,19 @@ This document provides a comprehensive reference for all commands and functions ## MCP Tools -### launch_chrome +### Browser Control + +#### launch_chrome Launches Chrome in debug mode with customizable configuration. **Parameters:** - `url` (string, optional): URL to navigate to after launch -- `executablePath` (string, optional): Path to Chrome executable (uses bundled Chrome if not provided) -- `userDataDir` (string, optional): Path to user data directory (uses default Chrome profile if not provided) -- `loadExtension` (string, optional): Path to unpacked extension directory to load -- `disableExtensionsExcept` (string, optional): Path to extension that should remain enabled while others are disabled -- `disableAutomationControlled` (boolean, optional): Disable Chrome's "Automation Controlled" mode -- `userscriptPath` (string, optional): Path to userscript file to inject +- `executablePath` (string, optional): Path to Chrome executable +- `userDataDir` (string, optional): Path to user data directory +- `loadExtension` (string, optional): Path to unpacked extension +- `disableExtensionsExcept` (string, optional): Keep only specified extension enabled +- `disableAutomationControlled` (boolean, optional): Hide automation banner +- `userscriptPath` (string, optional): Path to userscript to inject **Example:** ```javascript @@ -24,15 +26,14 @@ Launches Chrome in debug mode with customizable configuration. { "url": "https://example.com", - "loadExtension": "C:\\path\\to\\extension", "disableAutomationControlled": true } ``` -### get_console_logs -Retrieves console logs from the Chrome instance. +#### get_console_logs +Retrieves console logs from Chrome. **Parameters:** - `clear` (boolean, optional): Whether to clear logs after retrieving @@ -50,7 +51,7 @@ Retrieves console logs from the Chrome instance. ``` -### evaluate +#### evaluate Executes JavaScript code in the browser context. **Parameters:** @@ -69,13 +70,237 @@ Executes JavaScript code in the browser context. ``` -## Userscript Functions +### Page Interaction + +#### click +Clicks an element on the page. + +**Parameters:** +- `selector` (string, required): CSS selector for element to click +- `delay` (number, optional): Delay before clicking (milliseconds) +- `button` (string, optional): Mouse button ('left', 'right', 'middle') + +**Example:** +```javascript + +chrome-debug +click + +{ + "selector": "#submit-button", + "delay": 500 +} + + +``` + +#### type +Types text into an input field. + +**Parameters:** +- `selector` (string, required): CSS selector for input field +- `text` (string, required): Text to type +- `delay` (number, optional): Delay between keystrokes (milliseconds) + +**Example:** +```javascript + +chrome-debug +type + +{ + "selector": "#search-input", + "text": "search query", + "delay": 100 +} + + +``` + +#### select +Selects an option in a dropdown. + +**Parameters:** +- `selector` (string, required): CSS selector for select element +- `value` (string, required): Option value to select + +**Example:** +```javascript + +chrome-debug +select + +{ + "selector": "#country-select", + "value": "US" +} + + +``` + +#### hover +Hovers over an element. + +**Parameters:** +- `selector` (string, required): CSS selector for element to hover + +**Example:** +```javascript + +chrome-debug +hover + +{ + "selector": "#dropdown-menu" +} + + +``` + +### Page Control + +#### wait_for_selector +Waits for an element to appear on the page. + +**Parameters:** +- `selector` (string, required): CSS selector to wait for +- `timeout` (number, optional): Timeout in milliseconds +- `visible` (boolean, optional): Whether element should be visible + +**Example:** +```javascript + +chrome-debug +wait_for_selector + +{ + "selector": ".loading-complete", + "timeout": 5000, + "visible": true +} + + +``` + +#### navigate +Navigates to a URL. + +**Parameters:** +- `url` (string, required): URL to navigate to +- `waitUntil` (string, optional): Navigation completion condition +- `timeout` (number, optional): Navigation timeout in milliseconds + +**Example:** +```javascript + +chrome-debug +navigate + +{ + "url": "https://example.com", + "waitUntil": "networkidle0" +} + + +``` + +### Page State + +#### get_text +Gets text content of an element. + +**Parameters:** +- `selector` (string, required): CSS selector for element + +**Example:** +```javascript + +chrome-debug +get_text + +{ + "selector": ".article-content" +} + + +``` + +#### get_attribute +Gets attribute value of an element. + +**Parameters:** +- `selector` (string, required): CSS selector for element +- `attribute` (string, required): Attribute name to get + +**Example:** +```javascript + +chrome-debug +get_attribute + +{ + "selector": "img", + "attribute": "src" +} + + +``` + +### Page Configuration + +#### set_viewport +Sets the viewport size and properties. + +**Parameters:** +- `width` (number, required): Viewport width in pixels +- `height` (number, required): Viewport height in pixels +- `deviceScaleFactor` (number, optional): Device scale factor +- `isMobile` (boolean, optional): Whether to emulate mobile device + +**Example:** +```javascript + +chrome-debug +set_viewport + +{ + "width": 1920, + "height": 1080, + "deviceScaleFactor": 1 +} + + +``` + +#### screenshot +Takes a screenshot of the page or element. + +**Parameters:** +- `path` (string, required): Output path for screenshot +- `selector` (string, optional): CSS selector for specific element +- `fullPage` (boolean, optional): Capture full scrollable page +- `quality` (number, optional): Image quality for JPEG (0-100) + +**Example:** +```javascript + +chrome-debug +screenshot + +{ + "path": "screenshot.png", + "fullPage": true +} + + +``` + +## GM Functions ### GM_setValue(key: string, value: any) Stores a value persistently using Chrome's localStorage. ```javascript -// Store values GM_setValue('myKey', 'myValue'); GM_setValue('myObject', { foo: 'bar' }); ``` @@ -84,7 +309,6 @@ GM_setValue('myObject', { foo: 'bar' }); Retrieves a previously stored value. ```javascript -// Retrieve values const myValue = GM_getValue('myKey', 'default'); const myObject = GM_getValue('myObject', {}); ``` @@ -144,9 +368,9 @@ The server may return the following error codes: ## Best Practices 1. Always check for successful Chrome launch before using other tools -2. Use absolute paths for extension and userscript loading -3. Clear console logs periodically to prevent memory issues -4. Handle errors appropriately in userscripts -5. Test userscripts in a standard browser before using with the MCP server -6. Use `disableAutomationControlled` when websites detect automation -7. Monitor memory usage and restart Chrome if needed \ No newline at end of file +2. Use appropriate selectors that uniquely identify elements +3. Add appropriate delays when interacting with dynamic content +4. Handle timeouts and errors appropriately +5. Use viewport settings that match your automation needs +6. Clean up resources (close browser, clear logs) when done +7. Monitor memory usage for long-running automations \ No newline at end of file diff --git a/docs/llms-install.md b/docs/llms-install.md index 0893954..a63fd63 100644 --- a/docs/llms-install.md +++ b/docs/llms-install.md @@ -1,17 +1,7 @@ -# Using Chrome Debug MCP with LLMs +# Chrome Debug MCP Server -This guide explains how to set up and use the Chrome Debug MCP server with various Large Language Models (LLMs) and AI assistants. +MCP server providing Chrome browser automation capabilities with support for userscripts and extensions. -## Prerequisites - -- Chrome Debug MCP server installed and configured (see main [README.md](../README.md)) -- An LLM platform or AI assistant that supports the Model Context Protocol (MCP) - -## Configuration - -### VSCode + Roo Code Extension - -1. Ensure Chrome Debug MCP is properly configured in `cline_mcp_settings.json`: ```json { "mcpServers": { @@ -25,164 +15,63 @@ This guide explains how to set up and use the Chrome Debug MCP server with vario } ``` -2. The LLM will automatically detect and connect to the Chrome Debug MCP server when launched through VSCode. +## Installation -### Claude Desktop App +1. Install dependencies: +```bash +npm install +``` -1. Add the Chrome Debug MCP configuration to `claude_desktop_config.json`: -```json -{ - "mcpServers": { - "chrome-debug": { - "command": "node", - "args": ["path/to/chrome-debug-mcp/build/index.js"], - "disabled": false, - "alwaysAllow": [] - } - } -} +2. Build the server: +```bash +npm run build ``` -2. Restart the Claude Desktop App to apply changes. +3. Add the configuration above to your Roo Code settings at: +- VSCode: `%APPDATA%/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/cline_mcp_settings.json` +- Claude Desktop: `%APPDATA%/Anthropic/Claude/config/claude_desktop_config.json` -## Usage Examples +4. Replace `path/to/chrome-debug-mcp` with the actual path to your installation. -### Basic Chrome Control +## Requirements -```javascript -// Launch Chrome and navigate to a URL -use_mcp_tool({ - server_name: "chrome-debug", - tool_name: "launch_chrome", - arguments: { - url: "https://example.com" - } -}) - -// Evaluate JavaScript in the page -use_mcp_tool({ - server_name: "chrome-debug", - tool_name: "evaluate", - arguments: { - expression: "document.title" - } -}) -``` +- Node.js v14 or higher +- Chrome browser installed +- VSCode + Roo Code extension or Claude Desktop App -### Web Automation Tasks - -```javascript -// Fill out a form -use_mcp_tool({ - server_name: "chrome-debug", - tool_name: "evaluate", - arguments: { - expression: ` - document.querySelector('#username').value = 'test'; - document.querySelector('#password').value = 'password'; - document.querySelector('form').submit(); - ` - } -}) - -// Extract data from the page -use_mcp_tool({ - server_name: "chrome-debug", - tool_name: "evaluate", - arguments: { - expression: ` - Array.from(document.querySelectorAll('.item')).map(el => ({ - title: el.querySelector('.title').textContent, - price: el.querySelector('.price').textContent - })) - ` - } -}) -``` +## Tools -### Userscript Integration +- `launch_chrome`: Launch Chrome with various configurations +- `evaluate`: Execute JavaScript in the browser context +- `get_console_logs`: Retrieve browser console logs +- `click`: Click on page elements +- `type`: Type text into input fields +- `select`: Select options from dropdowns +- `hover`: Hover over elements +- `wait_for_selector`: Wait for elements to appear +- `screenshot`: Capture page screenshots +- `get_text`: Get element text content +- `get_attribute`: Get element attributes +- `set_viewport`: Configure viewport size +- `navigate`: Navigate to URLs -```javascript -// Inject a userscript for enhanced functionality -use_mcp_tool({ - server_name: "chrome-debug", - tool_name: "launch_chrome", - arguments: { - url: "https://example.com", - userscriptPath: "path/to/userscript.js" - } -}) +For full tool documentation, see [COMMANDS.md](./COMMANDS.md). + +## Testing + +```bash +npm run build +npm test ``` -## Common Tasks - -### Web Testing -- Launch Chrome with specific configurations -- Interact with web elements -- Extract page content -- Monitor console logs -- Inject test scripts - -### Web Automation -- Fill out forms -- Click buttons and links -- Navigate between pages -- Handle authentication -- Extract data - -### Browser Extension Development -- Load unpacked extensions -- Test extension functionality -- Debug extension code -- Monitor extension behavior - -## Troubleshooting - -### Chrome Won't Launch -1. Verify Chrome is installed and the path is correct -2. Check if Chrome is already running with remote debugging -3. Ensure no conflicting Chrome processes are running - -### Connection Issues -1. Verify the debugging port is available -2. Check Chrome's remote debugging settings -3. Ensure firewall isn't blocking connections - -### Userscript Problems -1. Verify userscript syntax is correct -2. Check userscript permissions -3. Monitor console for userscript errors - -### Extension Loading Fails -1. Verify extension path is correct -2. Check extension manifest.json -3. Ensure extension is compatible with Chrome version - -## Best Practices - -1. **Resource Management** - - Always close Chrome when finished - - Clear console logs regularly - - Manage Chrome profiles appropriately - -2. **Error Handling** - - Monitor console logs for errors - - Implement proper error handling in scripts - - Use try-catch blocks for evaluation - -3. **Security** - - Use secure protocols (https) when possible - - Handle sensitive data appropriately - - Follow Chrome security guidelines - -4. **Performance** - - Minimize unnecessary Chrome instances - - Clean up resources after use - - Optimize script evaluation - -## Additional Resources - -- [Chrome DevTools Protocol Documentation](https://chromedevtools.github.io/devtools-protocol/) -- [Puppeteer API Reference](https://pptr.dev/api) -- [Model Context Protocol Documentation](https://modelcontextprotocol.ai) -- [Chrome Extensions Documentation](https://developer.chrome.com/docs/extensions/) \ No newline at end of file +## Known Issues + +- Chrome must be installed and accessible +- Extensions require non-headless mode +- Some websites may detect automation + +## Support + +For issues and questions: +- Open an issue on GitHub +- See [TROUBLESHOOTING.md](./TROUBLESHOOTING.md) \ No newline at end of file diff --git a/src/handlers/puppeteer-handlers.ts b/src/handlers/puppeteer-handlers.ts new file mode 100644 index 0000000..5f2c7c3 --- /dev/null +++ b/src/handlers/puppeteer-handlers.ts @@ -0,0 +1,223 @@ +import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; + +// Type guards for arguments validation +export function isClickArgs(args: any): args is ClickArgs { + return typeof args === 'object' && typeof args.selector === 'string'; +} + +export function isTypeArgs(args: any): args is TypeArgs { + return typeof args === 'object' && typeof args.selector === 'string' && typeof args.text === 'string'; +} + +export function isSelectArgs(args: any): args is SelectArgs { + return typeof args === 'object' && typeof args.selector === 'string' && typeof args.value === 'string'; +} + +export function isHoverArgs(args: any): args is HoverArgs { + return typeof args === 'object' && typeof args.selector === 'string'; +} + +export function isWaitForSelectorArgs(args: any): args is WaitForSelectorArgs { + return typeof args === 'object' && typeof args.selector === 'string'; +} + +export function isScreenshotArgs(args: any): args is ScreenshotArgs { + return typeof args === 'object' && typeof args.path === 'string'; +} + +export function isNavigateArgs(args: any): args is NavigateArgs { + return typeof args === 'object' && typeof args.url === 'string'; +} + +export function isGetTextArgs(args: any): args is GetTextArgs { + return typeof args === 'object' && typeof args.selector === 'string'; +} + +export function isGetAttributeArgs(args: any): args is GetAttributeArgs { + return typeof args === 'object' && typeof args.selector === 'string' && typeof args.attribute === 'string'; +} + +export function isSetViewportArgs(args: any): args is SetViewportArgs { + return typeof args === 'object' && + typeof args.width === 'number' && + typeof args.height === 'number'; +} +import { Page } from 'puppeteer'; +import { + ClickArgs, + TypeArgs, + SelectArgs, + HoverArgs, + WaitForSelectorArgs, + ScreenshotArgs, + NavigateArgs, + GetTextArgs, + GetAttributeArgs, + SetViewportArgs +} from '../types/puppeteer-tools.js'; + +/** + * Handles click operations on elements + */ +export async function handleClick(page: Page, args: ClickArgs) { + try { + await page.waitForSelector(args.selector); + if (args.delay) { + await new Promise(resolve => setTimeout(resolve, args.delay)); + } + await page.click(args.selector, { button: args.button as any || 'left' }); + return { content: [{ type: 'text', text: `Clicked element: ${args.selector}` }] }; + } catch (error) { + throw new McpError(ErrorCode.InternalError, `Click failed: ${error}`); + } +} + +/** + * Handles typing text into input fields + */ +export async function handleType(page: Page, args: TypeArgs) { + try { + await page.waitForSelector(args.selector); + await page.type(args.selector, args.text, { delay: args.delay }); + return { content: [{ type: 'text', text: `Typed text into: ${args.selector}` }] }; + } catch (error) { + throw new McpError(ErrorCode.InternalError, `Type failed: ${error}`); + } +} + +/** + * Handles selecting options in dropdowns + */ +export async function handleSelect(page: Page, args: SelectArgs) { + try { + await page.waitForSelector(args.selector); + await page.select(args.selector, args.value); + return { content: [{ type: 'text', text: `Selected value in: ${args.selector}` }] }; + } catch (error) { + throw new McpError(ErrorCode.InternalError, `Select failed: ${error}`); + } +} + +/** + * Handles hovering over elements + */ +export async function handleHover(page: Page, args: HoverArgs) { + try { + await page.waitForSelector(args.selector); + await page.hover(args.selector); + return { content: [{ type: 'text', text: `Hovered over: ${args.selector}` }] }; + } catch (error) { + throw new McpError(ErrorCode.InternalError, `Hover failed: ${error}`); + } +} + +/** + * Handles waiting for elements to appear + */ +export async function handleWaitForSelector(page: Page, args: WaitForSelectorArgs) { + try { + await page.waitForSelector(args.selector, { + visible: args.visible, + timeout: args.timeout, + }); + return { content: [{ type: 'text', text: `Found element: ${args.selector}` }] }; + } catch (error) { + throw new McpError(ErrorCode.InternalError, `Wait for selector failed: ${error}`); + } +} + +/** + * Handles taking screenshots + */ +export async function handleScreenshot(page: Page, args: ScreenshotArgs) { + try { + if (args.selector) { + const element = await page.$(args.selector); + if (!element) { + throw new Error(`Element not found: ${args.selector}`); + } + await element.screenshot({ + path: args.path, + type: args.path.endsWith('.jpg') ? 'jpeg' : 'png', + quality: args.quality, + }); + } else { + await page.screenshot({ + path: args.path, + fullPage: args.fullPage, + type: args.path.endsWith('.jpg') ? 'jpeg' : 'png', + quality: args.quality, + }); + } + return { content: [{ type: 'text', text: `Screenshot saved to: ${args.path}` }] }; + } catch (error) { + throw new McpError(ErrorCode.InternalError, `Screenshot failed: ${error}`); + } +} + +/** + * Handles navigation to URLs + */ +export async function handleNavigate(page: Page, args: NavigateArgs) { + try { + await page.goto(args.url, { + waitUntil: args.waitUntil || 'networkidle0', + timeout: args.timeout, + }); + return { content: [{ type: 'text', text: `Navigated to: ${args.url}` }] }; + } catch (error) { + throw new McpError(ErrorCode.InternalError, `Navigation failed: ${error}`); + } +} + +/** + * Handles getting text content from elements + */ +export async function handleGetText(page: Page, args: GetTextArgs) { + try { + await page.waitForSelector(args.selector); + const text = await page.$eval(args.selector, (el) => el.textContent); + return { content: [{ type: 'text', text: text || '' }] }; + } catch (error) { + throw new McpError(ErrorCode.InternalError, `Get text failed: ${error}`); + } +} + +/** + * Handles getting attribute values from elements + */ +export async function handleGetAttribute(page: Page, args: GetAttributeArgs) { + try { + await page.waitForSelector(args.selector); + const value = await page.$eval( + args.selector, + (el, attr) => el.getAttribute(attr), + args.attribute + ); + return { content: [{ type: 'text', text: value || '' }] }; + } catch (error) { + throw new McpError(ErrorCode.InternalError, `Get attribute failed: ${error}`); + } +} + +/** + * Handles setting viewport size and properties + */ +export async function handleSetViewport(page: Page, args: SetViewportArgs) { + try { + await page.setViewport({ + width: args.width, + height: args.height, + deviceScaleFactor: args.deviceScaleFactor || 1, + isMobile: args.isMobile || false, + }); + return { + content: [{ + type: 'text', + text: `Viewport set to ${args.width}x${args.height}` + }] + }; + } catch (error) { + throw new McpError(ErrorCode.InternalError, `Set viewport failed: ${error}`); + } +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 3981419..21dc47d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,41 @@ import { import CDP from 'chrome-remote-interface'; import type { Client } from 'chrome-remote-interface'; import * as puppeteer from 'puppeteer'; +import { toolDefinitions } from './tool-definitions.js'; +import { + ClickArgs, + TypeArgs, + SelectArgs, + HoverArgs, + WaitForSelectorArgs, + ScreenshotArgs, + NavigateArgs, + GetTextArgs, + GetAttributeArgs, + SetViewportArgs +} from './types/puppeteer-tools.js'; +import { + handleClick, + handleType, + handleSelect, + handleHover, + handleWaitForSelector, + handleScreenshot, + handleNavigate, + handleGetText, + handleGetAttribute, + handleSetViewport, + isClickArgs, + isTypeArgs, + isSelectArgs, + isHoverArgs, + isWaitForSelectorArgs, + isScreenshotArgs, + isNavigateArgs, + isGetTextArgs, + isGetAttributeArgs, + isSetViewportArgs +} from './handlers/puppeteer-handlers.js'; interface ConsoleAPICalledEvent { type: string; @@ -94,6 +129,32 @@ class ChromeDebugServer { private browser: puppeteer.Browser | null = null; private cdpClient: Client | null = null; private consoleLogs: string[] = []; + private activePage: puppeteer.Page | null = null; + + /** + * Gets the active page, throwing an error if Chrome isn't running or no page is active + */ + private async getActivePage(): Promise { + if (!this.browser) { + throw new McpError( + ErrorCode.InternalError, + 'Chrome is not running. Call launch_chrome first.' + ); + } + + if (!this.activePage) { + const pages = await this.browser.pages(); + this.activePage = pages[0]; + if (!this.activePage) { + throw new McpError( + ErrorCode.InternalError, + 'No active page found' + ); + } + } + + return this.activePage; + } constructor() { // Initialize MCP server with basic configuration @@ -122,72 +183,7 @@ class ChromeDebugServer { private setupToolHandlers() { // Handler for listing available tools this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: [ - { - name: 'launch_chrome', - description: 'Launch Chrome in debug mode', - inputSchema: { - type: 'object', - properties: { - url: { - type: 'string', - description: 'URL to navigate to (optional)', - }, - executablePath: { - type: 'string', - description: 'Path to Chrome executable (optional, uses bundled Chrome if not provided)', - }, - userDataDir: { - type: 'string', - description: 'Path to a specific user data directory (optional, uses default Chrome profile if not provided)', - }, - loadExtension: { - type: 'string', - description: 'Path to unpacked extension directory to load (optional)', - }, - disableExtensionsExcept: { - type: 'string', - description: 'Path to extension that should remain enabled while others are disabled (optional)', - }, - disableAutomationControlled: { - type: 'boolean', - description: 'Disable Chrome\'s "Automation Controlled" mode (optional, default: false)', - }, - userscriptPath: { - type: 'string', - description: 'Path to userscript file to inject (optional)', - }, - }, - }, - }, - { - name: 'get_console_logs', - description: 'Get console logs from Chrome', - inputSchema: { - type: 'object', - properties: { - clear: { - type: 'boolean', - description: 'Whether to clear logs after retrieving', - }, - }, - }, - }, - { - name: 'evaluate', - description: 'Evaluate JavaScript in Chrome', - inputSchema: { - type: 'object', - properties: { - expression: { - type: 'string', - description: 'JavaScript code to evaluate', - }, - }, - required: ['expression'], - }, - }, - ], + tools: toolDefinitions, })); // Handler for executing tools @@ -201,6 +197,36 @@ class ChromeDebugServer { return this.handleGetConsoleLogs(args as GetConsoleLogsArgs); case 'evaluate': return this.handleEvaluate(args as EvaluateArgs); + case 'click': + if (!isClickArgs(args)) throw new McpError(ErrorCode.InvalidParams, 'Invalid click arguments'); + return handleClick(await this.getActivePage(), args); + case 'type': + if (!isTypeArgs(args)) throw new McpError(ErrorCode.InvalidParams, 'Invalid type arguments'); + return handleType(await this.getActivePage(), args); + case 'select': + if (!isSelectArgs(args)) throw new McpError(ErrorCode.InvalidParams, 'Invalid select arguments'); + return handleSelect(await this.getActivePage(), args); + case 'hover': + if (!isHoverArgs(args)) throw new McpError(ErrorCode.InvalidParams, 'Invalid hover arguments'); + return handleHover(await this.getActivePage(), args); + case 'wait_for_selector': + if (!isWaitForSelectorArgs(args)) throw new McpError(ErrorCode.InvalidParams, 'Invalid wait_for_selector arguments'); + return handleWaitForSelector(await this.getActivePage(), args); + case 'screenshot': + if (!isScreenshotArgs(args)) throw new McpError(ErrorCode.InvalidParams, 'Invalid screenshot arguments'); + return handleScreenshot(await this.getActivePage(), args); + case 'navigate': + if (!isNavigateArgs(args)) throw new McpError(ErrorCode.InvalidParams, 'Invalid navigate arguments'); + return handleNavigate(await this.getActivePage(), args); + case 'get_text': + if (!isGetTextArgs(args)) throw new McpError(ErrorCode.InvalidParams, 'Invalid get_text arguments'); + return handleGetText(await this.getActivePage(), args); + case 'get_attribute': + if (!isGetAttributeArgs(args)) throw new McpError(ErrorCode.InvalidParams, 'Invalid get_attribute arguments'); + return handleGetAttribute(await this.getActivePage(), args); + case 'set_viewport': + if (!isSetViewportArgs(args)) throw new McpError(ErrorCode.InvalidParams, 'Invalid set_viewport arguments'); + return handleSetViewport(await this.getActivePage(), args); default: throw new McpError( ErrorCode.MethodNotFound, diff --git a/src/tool-definitions.ts b/src/tool-definitions.ts new file mode 100644 index 0000000..2deac17 --- /dev/null +++ b/src/tool-definitions.ts @@ -0,0 +1,274 @@ +// Tool definitions for Chrome Debug MCP + +export const toolDefinitions = [ + { + name: 'launch_chrome', + description: 'Launch Chrome in debug mode', + inputSchema: { + type: 'object', + properties: { + url: { + type: 'string', + description: 'URL to navigate to (optional)', + }, + executablePath: { + type: 'string', + description: 'Path to Chrome executable (optional, uses bundled Chrome if not provided)', + }, + userDataDir: { + type: 'string', + description: 'Path to a specific user data directory (optional, uses default Chrome profile if not provided)', + }, + loadExtension: { + type: 'string', + description: 'Path to unpacked extension directory to load (optional)', + }, + disableExtensionsExcept: { + type: 'string', + description: 'Path to extension that should remain enabled while others are disabled (optional)', + }, + disableAutomationControlled: { + type: 'boolean', + description: 'Disable Chrome\'s "Automation Controlled" mode (optional, default: false)', + }, + userscriptPath: { + type: 'string', + description: 'Path to userscript file to inject (optional)', + }, + }, + }, + }, + { + name: 'get_console_logs', + description: 'Get console logs from Chrome', + inputSchema: { + type: 'object', + properties: { + clear: { + type: 'boolean', + description: 'Whether to clear logs after retrieving', + }, + }, + }, + }, + { + name: 'evaluate', + description: 'Evaluate JavaScript in Chrome', + inputSchema: { + type: 'object', + properties: { + expression: { + type: 'string', + description: 'JavaScript code to evaluate', + }, + }, + required: ['expression'], + }, + }, + { + name: 'click', + description: 'Click an element on the page', + inputSchema: { + type: 'object', + properties: { + selector: { + type: 'string', + description: 'CSS selector for element to click', + }, + delay: { + type: 'number', + description: 'Optional delay before clicking (in milliseconds)', + }, + button: { + type: 'string', + enum: ['left', 'right', 'middle'], + description: 'Mouse button to use', + }, + }, + required: ['selector'], + }, + }, + { + name: 'type', + description: 'Type text into an input field', + inputSchema: { + type: 'object', + properties: { + selector: { + type: 'string', + description: 'CSS selector for input field', + }, + text: { + type: 'string', + description: 'Text to type', + }, + delay: { + type: 'number', + description: 'Optional delay between keystrokes (in milliseconds)', + }, + }, + required: ['selector', 'text'], + }, + }, + { + name: 'select', + description: 'Select an option in a dropdown', + inputSchema: { + type: 'object', + properties: { + selector: { + type: 'string', + description: 'CSS selector for select element', + }, + value: { + type: 'string', + description: 'Option value or label to select', + }, + }, + required: ['selector', 'value'], + }, + }, + { + name: 'hover', + description: 'Hover over an element', + inputSchema: { + type: 'object', + properties: { + selector: { + type: 'string', + description: 'CSS selector for element to hover', + }, + }, + required: ['selector'], + }, + }, + { + name: 'wait_for_selector', + description: 'Wait for an element to appear on the page', + inputSchema: { + type: 'object', + properties: { + selector: { + type: 'string', + description: 'CSS selector to wait for', + }, + timeout: { + type: 'number', + description: 'Optional timeout in milliseconds', + }, + visible: { + type: 'boolean', + description: 'Whether element should be visible', + }, + }, + required: ['selector'], + }, + }, + { + name: 'screenshot', + description: 'Take a screenshot of the page or element', + inputSchema: { + type: 'object', + properties: { + selector: { + type: 'string', + description: 'Optional CSS selector to screenshot specific element', + }, + path: { + type: 'string', + description: 'Output path for screenshot', + }, + fullPage: { + type: 'boolean', + description: 'Whether to capture the full scrollable page', + }, + quality: { + type: 'number', + description: 'Image quality (0-100) for JPEG', + }, + }, + required: ['path'], + }, + }, + { + name: 'navigate', + description: 'Navigate to a URL', + inputSchema: { + type: 'object', + properties: { + url: { + type: 'string', + description: 'URL to navigate to', + }, + waitUntil: { + type: 'string', + enum: ['load', 'domcontentloaded', 'networkidle0', 'networkidle2'], + description: 'When to consider navigation completed', + }, + timeout: { + type: 'number', + description: 'Navigation timeout in milliseconds', + }, + }, + required: ['url'], + }, + }, + { + name: 'get_text', + description: 'Get text content of an element', + inputSchema: { + type: 'object', + properties: { + selector: { + type: 'string', + description: 'CSS selector for element', + }, + }, + required: ['selector'], + }, + }, + { + name: 'get_attribute', + description: 'Get attribute value of an element', + inputSchema: { + type: 'object', + properties: { + selector: { + type: 'string', + description: 'CSS selector for element', + }, + attribute: { + type: 'string', + description: 'Attribute name to get', + }, + }, + required: ['selector', 'attribute'], + }, + }, + { + name: 'set_viewport', + description: 'Set the viewport size and properties', + inputSchema: { + type: 'object', + properties: { + width: { + type: 'number', + description: 'Viewport width in pixels', + }, + height: { + type: 'number', + description: 'Viewport height in pixels', + }, + deviceScaleFactor: { + type: 'number', + description: 'Device scale factor', + }, + isMobile: { + type: 'boolean', + description: 'Whether to emulate mobile device', + }, + }, + required: ['width', 'height'], + }, + }, +]; \ No newline at end of file diff --git a/src/types/puppeteer-tools.ts b/src/types/puppeteer-tools.ts new file mode 100644 index 0000000..0473722 --- /dev/null +++ b/src/types/puppeteer-tools.ts @@ -0,0 +1,85 @@ +/** + * Type definitions for Puppeteer-based MCP tools + */ + +export interface ClickArgs { + /** CSS selector for element to click */ + selector: string; + /** Optional delay before clicking (in milliseconds) */ + delay?: number; + /** Optional button to use (left, right, middle) */ + button?: 'left' | 'right' | 'middle'; +} + +export interface TypeArgs { + /** CSS selector for input field */ + selector: string; + /** Text to type */ + text: string; + /** Optional delay between keystrokes (in milliseconds) */ + delay?: number; +} + +export interface SelectArgs { + /** CSS selector for select element */ + selector: string; + /** Option value or label to select */ + value: string; +} + +export interface HoverArgs { + /** CSS selector for element to hover */ + selector: string; +} + +export interface WaitForSelectorArgs { + /** CSS selector to wait for */ + selector: string; + /** Optional timeout in milliseconds */ + timeout?: number; + /** Whether element should be visible */ + visible?: boolean; +} + +export interface ScreenshotArgs { + /** Optional CSS selector to screenshot specific element */ + selector?: string; + /** Output path for screenshot */ + path: string; + /** Optional screenshot options */ + fullPage?: boolean; + /** Image quality (0-100) for JPEG */ + quality?: number; +} + +export interface NavigateArgs { + /** URL to navigate to */ + url: string; + /** Optional wait condition */ + waitUntil?: 'load' | 'domcontentloaded' | 'networkidle0' | 'networkidle2'; + /** Optional timeout in milliseconds */ + timeout?: number; +} + +export interface GetTextArgs { + /** CSS selector for element */ + selector: string; +} + +export interface GetAttributeArgs { + /** CSS selector for element */ + selector: string; + /** Attribute name to get */ + attribute: string; +} + +export interface SetViewportArgs { + /** Viewport width in pixels */ + width: number; + /** Viewport height in pixels */ + height: number; + /** Device scale factor */ + deviceScaleFactor?: number; + /** Whether to emulate mobile device */ + isMobile?: boolean; +} \ No newline at end of file diff --git a/test/run-tests.js b/test/run-tests.js new file mode 100644 index 0000000..cdc9334 --- /dev/null +++ b/test/run-tests.js @@ -0,0 +1,131 @@ +// Test script for Chrome Debug MCP commands +const path = require('path'); +const assert = require('assert').strict; + +async function runTests() { + try { + // 1. Launch Chrome and navigate to test page + console.log('Launching Chrome...'); + const result = await useMcpTool('chrome-debug', 'launch_chrome', { + url: `file://${path.resolve(__dirname, 'test.html')}`, + disableAutomationControlled: true + }); + console.log('Chrome launched:', result); + + // 2. Test viewport setting + console.log('\nTesting viewport...'); + await useMcpTool('chrome-debug', 'set_viewport', { + width: 1024, + height: 768 + }); + console.log('Viewport set'); + + // 3. Test clicking + console.log('\nTesting click...'); + await useMcpTool('chrome-debug', 'click', { + selector: '#test-button' + }); + const clickResult = await useMcpTool('chrome-debug', 'get_text', { + selector: '#click-result' + }); + assert.equal(clickResult.content[0].text, 'Button clicked!'); + console.log('Click test passed'); + + // 4. Test typing + console.log('\nTesting type...'); + await useMcpTool('chrome-debug', 'type', { + selector: '#test-input', + text: 'Hello World', + delay: 100 + }); + const typeResult = await useMcpTool('chrome-debug', 'get_text', { + selector: '#type-result' + }); + assert.equal(typeResult.content[0].text, 'Typed: Hello World'); + console.log('Type test passed'); + + // 5. Test select + console.log('\nTesting select...'); + await useMcpTool('chrome-debug', 'select', { + selector: '#test-select', + value: '2' + }); + const selectResult = await useMcpTool('chrome-debug', 'get_text', { + selector: '#select-result' + }); + assert.equal(selectResult.content[0].text, 'Selected: Option 2'); + console.log('Select test passed'); + + // 6. Test hover + console.log('\nTesting hover...'); + await useMcpTool('chrome-debug', 'hover', { + selector: '#hover-test' + }); + const hoverResult = await useMcpTool('chrome-debug', 'get_text', { + selector: '#hover-result' + }); + assert.equal(hoverResult.content[0].text, 'Hovered!'); + console.log('Hover test passed'); + + // 7. Test wait for selector + console.log('\nTesting wait for selector...'); + await useMcpTool('chrome-debug', 'click', { + selector: '#show-delayed' + }); + await useMcpTool('chrome-debug', 'wait_for_selector', { + selector: '#delayed-element', + visible: true, + timeout: 3000 + }); + console.log('Wait for selector test passed'); + + // 8. Test get attribute + console.log('\nTesting get attribute...'); + const attrResult = await useMcpTool('chrome-debug', 'get_attribute', { + selector: '#attribute-test', + attribute: 'data-test' + }); + assert.equal(attrResult.content[0].text, 'test-value'); + console.log('Get attribute test passed'); + + // 9. Test screenshot + console.log('\nTesting screenshot...'); + await useMcpTool('chrome-debug', 'screenshot', { + path: path.resolve(__dirname, 'test-screenshot.png'), + fullPage: true + }); + console.log('Screenshot saved'); + + // 10. Test navigation + console.log('\nTesting navigation...'); + await useMcpTool('chrome-debug', 'navigate', { + url: 'about:blank', + waitUntil: 'networkidle0' + }); + console.log('Navigation test passed'); + + // 11. Test evaluate + console.log('\nTesting evaluate...'); + const evalResult = await useMcpTool('chrome-debug', 'evaluate', { + expression: 'document.title' + }); + assert.equal(evalResult.content[0].text, 'about:blank'); + console.log('Evaluate test passed'); + + console.log('\nAll tests passed! 🎉'); + + } catch (error) { + console.error('Test failed:', error); + process.exit(1); + } +} + +// Helper function to simulate MCP tool usage +async function useMcpTool(serverName, toolName, args) { + // This would be replaced by actual MCP tool invocation in real usage + console.log(`Using tool ${toolName} with args:`, args); + // Simulated response for testing + return { content: [{ type: 'text', text: 'Test response' }] }; +} + +runTests().catch(console.error); \ No newline at end of file diff --git a/test/test-screenshot.png b/test/test-screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..2bc4412060b83c2c3b4510036b0ee8e3e2b00f1c GIT binary patch literal 21490 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 literal 0 HcmV?d00001 diff --git a/test/test.html b/test/test.html index 40b1c10..b569972 100644 --- a/test/test.html +++ b/test/test.html @@ -1,19 +1,92 @@ - Chrome Debug MCP Test + Chrome Debug MCP Test Page + -

Chrome Debug MCP Test Page

+

Test Page for Chrome Debug MCP

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ Hover over me + +
+ +
+ +
+ This element appears after a delay +
+
+ +
+ Element with test attribute +
+ \ No newline at end of file