├── .DS_Store ├── .gitignore ├── LICENSE ├── README.md ├── browser-tools-mcp ├── README.md ├── mcp-server.ts ├── package-lock.json ├── package.json └── tsconfig.json ├── browser-tools-server ├── README.md ├── browser-connector.ts ├── lighthouse │ ├── accessibility.ts │ ├── best-practices.ts │ ├── index.ts │ ├── performance.ts │ ├── seo.ts │ └── types.ts ├── package-lock.json ├── package.json ├── puppeteer-service.ts └── tsconfig.json ├── chrome-extension ├── background.js ├── devtools.html ├── devtools.js ├── manifest.json ├── panel.html └── panel.js └── docs ├── mcp-docs.md └── mcp.md /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgentDeskAI/browser-tools-mcp/0befce357f2421e928f2a19ee09ab502080a35a5/.DS_Store -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .port 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 AgentDesk LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BrowserTools MCP 2 | 3 | > Make your AI tools 10x more aware and capable of interacting with your browser 4 | 5 | This application is a powerful browser monitoring and interaction tool that enables AI-powered applications via Anthropic's Model Context Protocol (MCP) to capture and analyze browser data through a Chrome extension. 6 | 7 | Read our [docs](https://browsertools.agentdesk.ai/) for the full installation, quickstart and contribution guides. 8 | 9 | ## Roadmap 10 | 11 | Check out our project roadmap here: [Github Roadmap / Project Board](https://github.com/orgs/AgentDeskAI/projects/1/views/1) 12 | 13 | ## Updates 14 | 15 | v1.2.0 is out! Here's a quick breakdown of the update: 16 | - You can now enable "Allow Auto-Paste into Cursor" within the DevTools panel. Screenshots will be automatically pasted into Cursor (just make sure to focus/click into the Agent input field in Cursor, otherwise it won't work!) 17 | - Integrated a suite of SEO, performance, accessibility, and best practice analysis tools via Lighthouse 18 | - Implemented a NextJS specific prompt used to improve SEO for a NextJS application 19 | - Added Debugger Mode as a tool which executes all debugging tools in a particular sequence, along with a prompt to improve reasoning 20 | - Added Audit Mode as a tool to execute all auditing tools in a particular sequence 21 | - Resolved Windows connectivity issues 22 | - Improved networking between BrowserTools server, extension and MCP server with host/port auto-discovery, auto-reconnect, and graceful shutdown mechanisms 23 | - Added ability to more easily exit out of the Browser Tools server with Ctrl+C 24 | 25 | ## Quickstart Guide 26 | 27 | There are three components to run this MCP tool: 28 | 29 | 1. Install our chrome extension from here: [v1.2.0 BrowserToolsMCP Chrome Extension](https://github.com/AgentDeskAI/browser-tools-mcp/releases/download/v1.2.0/BrowserTools-1.2.0-extension.zip) 30 | 2. Install the MCP server from this command within your IDE: `npx @agentdeskai/browser-tools-mcp@latest` 31 | 3. Open a new terminal and run this command: `npx @agentdeskai/browser-tools-server@latest` 32 | 33 | * Different IDEs have different configs but this command is generally a good starting point; please reference your IDEs docs for the proper config setup 34 | 35 | IMPORTANT TIP - there are two servers you need to install. There's... 36 | - browser-tools-server (local nodejs server that's a middleware for gathering logs) 37 | and 38 | - browser-tools-mcp (MCP server that you install into your IDE that communicates w/ the extension + browser-tools-server) 39 | 40 | `npx @agentdeskai/browser-tools-mcp@latest` is what you put into your IDE 41 | `npx @agentdeskai/browser-tools-server@latest` is what you run in a new terminal window 42 | 43 | After those three steps, open up your chrome dev tools and then the BrowserToolsMCP panel. 44 | 45 | If you're still having issues try these steps: 46 | - Quit / close down your browser. Not just the window but all of Chrome itself. 47 | - Restart the local node server (browser-tools-server) 48 | - Make sure you only have ONE instance of chrome dev tools panel open 49 | 50 | After that, it should work but if it doesn't let me know and I can share some more steps to gather logs/info about the issue! 51 | 52 | If you have any questions or issues, feel free to open an issue ticket! And if you have any ideas to make this better, feel free to reach out or open an issue ticket with an enhancement tag or reach out to me at [@tedx_ai on x](https://x.com/tedx_ai) 53 | 54 | ## Full Update Notes: 55 | 56 | Coding agents like Cursor can run these audits against the current page seamlessly. By leveraging Puppeteer and the Lighthouse npm library, BrowserTools MCP can now: 57 | 58 | - Evaluate pages for WCAG compliance 59 | - Identify performance bottlenecks 60 | - Flag on-page SEO issues 61 | - Check adherence to web development best practices 62 | - Review NextJS specific issues with SEO 63 | 64 | ...all without leaving your IDE 🎉 65 | 66 | --- 67 | 68 | ## 🔑 Key Additions 69 | 70 | | Audit Type | Description | 71 | | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | 72 | | **Accessibility** | WCAG-compliant checks for color contrast, missing alt text, keyboard navigation traps, ARIA attributes, and more. | 73 | | **Performance** | Lighthouse-driven analysis of render-blocking resources, excessive DOM size, unoptimized images, and other factors affecting page speed. | 74 | | **SEO** | Evaluates on-page SEO factors (like metadata, headings, and link structure) and suggests improvements for better search visibility. | 75 | | **Best Practices** | Checks for general best practices in web development. | 76 | | **NextJS Audit** | Injects a prompt used to perform a NextJS audit. | 77 | | **Audit Mode** | Runs all auditing tools in a sequence. | 78 | | **Debugger Mode** | Runs all debugging tools in a sequence. | 79 | 80 | --- 81 | 82 | ## 🛠️ Using Audit Tools 83 | 84 | ### ✅ **Before You Start** 85 | 86 | Ensure you have: 87 | 88 | - An **active tab** in your browser 89 | - The **BrowserTools extension enabled** 90 | 91 | ### ▶️ **Running Audits** 92 | 93 | **Headless Browser Automation**: 94 | Puppeteer automates a headless Chrome instance to load the page and collect audit data, ensuring accurate results even for SPAs or content loaded via JavaScript. 95 | 96 | The headless browser instance remains active for **60 seconds** after the last audit call to efficiently handle consecutive audit requests. 97 | 98 | **Structured Results**: 99 | Each audit returns results in a structured JSON format, including overall scores and detailed issue lists. This makes it easy for MCP-compatible clients to interpret the findings and present actionable insights. 100 | 101 | The MCP server provides tools to run audits on the current page. Here are example queries you can use to trigger them: 102 | 103 | #### Accessibility Audit (`runAccessibilityAudit`) 104 | 105 | Ensures the page meets accessibility standards like WCAG. 106 | 107 | > **Example Queries:** 108 | > 109 | > - "Are there any accessibility issues on this page?" 110 | > - "Run an accessibility audit." 111 | > - "Check if this page meets WCAG standards." 112 | 113 | #### Performance Audit (`runPerformanceAudit`) 114 | 115 | Identifies performance bottlenecks and loading issues. 116 | 117 | > **Example Queries:** 118 | > 119 | > - "Why is this page loading so slowly?" 120 | > - "Check the performance of this page." 121 | > - "Run a performance audit." 122 | 123 | #### SEO Audit (`runSEOAudit`) 124 | 125 | Evaluates how well the page is optimized for search engines. 126 | 127 | > **Example Queries:** 128 | > 129 | > - "How can I improve SEO for this page?" 130 | > - "Run an SEO audit." 131 | > - "Check SEO on this page." 132 | 133 | #### Best Practices Audit (`runBestPracticesAudit`) 134 | 135 | Checks for general best practices in web development. 136 | 137 | > **Example Queries:** 138 | > 139 | > - "Run a best practices audit." 140 | > - "Check best practices on this page." 141 | > - "Are there any best practices issues on this page?" 142 | 143 | #### Audit Mode (`runAuditMode`) 144 | 145 | Runs all audits in a particular sequence. Will run a NextJS audit if the framework is detected. 146 | 147 | > **Example Queries:** 148 | > 149 | > - "Run audit mode." 150 | > - "Enter audit mode." 151 | 152 | #### NextJS Audits (`runNextJSAudit`) 153 | 154 | Checks for best practices and SEO improvements for NextJS applications 155 | 156 | > **Example Queries:** 157 | > 158 | > - "Run a NextJS audit." 159 | > - "Run a NextJS audit, I'm using app router." 160 | > - "Run a NextJS audit, I'm using page router." 161 | 162 | #### Debugger Mode (`runDebuggerMode`) 163 | 164 | Runs all debugging tools in a particular sequence 165 | 166 | > **Example Queries:** 167 | > 168 | > - "Enter debugger mode." 169 | 170 | ## Architecture 171 | 172 | There are three core components all used to capture and analyze browser data: 173 | 174 | 1. **Chrome Extension**: A browser extension that captures screenshots, console logs, network activity and DOM elements. 175 | 2. **Node Server**: An intermediary server that facilitates communication between the Chrome extension and any instance of an MCP server. 176 | 3. **MCP Server**: A Model Context Protocol server that provides standardized tools for AI clients to interact with the browser. 177 | 178 | ``` 179 | ┌─────────────┐ ┌──────────────┐ ┌───────────────┐ ┌─────────────┐ 180 | │ MCP Client │ ──► │ MCP Server │ ──► │ Node Server │ ──► │ Chrome │ 181 | │ (e.g. │ ◄── │ (Protocol │ ◄── │ (Middleware) │ ◄── │ Extension │ 182 | │ Cursor) │ │ Handler) │ │ │ │ │ 183 | └─────────────┘ └──────────────┘ └───────────────┘ └─────────────┘ 184 | ``` 185 | 186 | Model Context Protocol (MCP) is a capability supported by Anthropic AI models that 187 | allow you to create custom tools for any compatible client. MCP clients like Claude 188 | Desktop, Cursor, Cline or Zed can run an MCP server which "teaches" these clients 189 | about a new tool that they can use. 190 | 191 | These tools can call out to external APIs but in our case, **all logs are stored locally** on your machine and NEVER sent out to any third-party service or API. BrowserTools MCP runs a local instance of a NodeJS API server which communicates with the BrowserTools Chrome Extension. 192 | 193 | All consumers of the BrowserTools MCP Server interface with the same NodeJS API and Chrome extension. 194 | 195 | #### Chrome Extension 196 | 197 | - Monitors XHR requests/responses and console logs 198 | - Tracks selected DOM elements 199 | - Sends all logs and current element to the BrowserTools Connector 200 | - Connects to Websocket server to capture/send screenshots 201 | - Allows user to configure token/truncation limits + screenshot folder path 202 | 203 | #### Node Server 204 | 205 | - Acts as middleware between the Chrome extension and MCP server 206 | - Receives logs and currently selected element from Chrome extension 207 | - Processes requests from MCP server to capture logs, screenshot or current element 208 | - Sends Websocket command to the Chrome extension for capturing a screenshot 209 | - Intelligently truncates strings and # of duplicate objects in logs to avoid token limits 210 | - Removes cookies and sensitive headers to avoid sending to LLMs in MCP clients 211 | 212 | #### MCP Server 213 | 214 | - Implements the Model Context Protocol 215 | - Provides standardized tools for AI clients 216 | - Compatible with various MCP clients (Cursor, Cline, Zed, Claude Desktop, etc.) 217 | 218 | ## Installation 219 | 220 | Installation steps can be found in our documentation: 221 | 222 | - [BrowserTools MCP Docs](https://browsertools.agentdesk.ai/) 223 | 224 | ## Usage 225 | 226 | Once installed and configured, the system allows any compatible MCP client to: 227 | 228 | - Monitor browser console output 229 | - Capture network traffic 230 | - Take screenshots 231 | - Analyze selected elements 232 | - Wipe logs stored in our MCP server 233 | - Run accessibility, performance, SEO, and best practices audits 234 | 235 | ## Compatibility 236 | 237 | - Works with any MCP-compatible client 238 | - Primarily designed for Cursor IDE integration 239 | - Supports other AI editors and MCP clients 240 | -------------------------------------------------------------------------------- /browser-tools-mcp/README.md: -------------------------------------------------------------------------------- 1 | # Browser Tools MCP Server 2 | 3 | A Model Context Protocol (MCP) server that provides AI-powered browser tools integration. This server works in conjunction with the Browser Tools Server to provide AI capabilities for browser debugging and analysis. 4 | 5 | ## Features 6 | 7 | - MCP protocol implementation 8 | - Browser console log access 9 | - Network request analysis 10 | - Screenshot capture capabilities 11 | - Element selection and inspection 12 | - Real-time browser state monitoring 13 | - Accessibility, performance, SEO, and best practices audits 14 | 15 | ## Prerequisites 16 | 17 | - Node.js 14 or higher 18 | - Browser Tools Server running 19 | - Chrome or Chromium browser installed (required for audit functionality) 20 | 21 | ## Installation 22 | 23 | ```bash 24 | npx @agentdeskai/browser-tools-mcp 25 | ``` 26 | 27 | Or install globally: 28 | 29 | ```bash 30 | npm install -g @agentdeskai/browser-tools-mcp 31 | ``` 32 | 33 | ## Usage 34 | 35 | 1. First, make sure the Browser Tools Server is running: 36 | 37 | ```bash 38 | npx @agentdeskai/browser-tools-server 39 | ``` 40 | 41 | 2. Then start the MCP server: 42 | 43 | ```bash 44 | npx @agentdeskai/browser-tools-mcp 45 | ``` 46 | 47 | 3. The MCP server will connect to the Browser Tools Server and provide the following capabilities: 48 | 49 | - Console log retrieval 50 | - Network request monitoring 51 | - Screenshot capture 52 | - Element selection 53 | - Browser state analysis 54 | - Accessibility and performance audits 55 | 56 | ## MCP Functions 57 | 58 | The server provides the following MCP functions: 59 | 60 | - `mcp_getConsoleLogs` - Retrieve browser console logs 61 | - `mcp_getConsoleErrors` - Get browser console errors 62 | - `mcp_getNetworkErrors` - Get network error logs 63 | - `mcp_getNetworkSuccess` - Get successful network requests 64 | - `mcp_getNetworkLogs` - Get all network logs 65 | - `mcp_getSelectedElement` - Get the currently selected DOM element 66 | - `mcp_runAccessibilityAudit` - Run a WCAG-compliant accessibility audit 67 | - `mcp_runPerformanceAudit` - Run a performance audit 68 | - `mcp_runSEOAudit` - Run an SEO audit 69 | - `mcp_runBestPracticesAudit` - Run a best practices audit 70 | 71 | ## Integration 72 | 73 | This server is designed to work with AI tools and platforms that support the Model Context Protocol (MCP). It provides a standardized interface for AI models to interact with browser state and debugging information. 74 | 75 | ## License 76 | 77 | MIT 78 | -------------------------------------------------------------------------------- /browser-tools-mcp/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@agentdeskai/browser-tools-mcp", 3 | "version": "1.2.0", 4 | "description": "MCP (Model Context Protocol) server for browser tools integration", 5 | "main": "dist/mcp-server.js", 6 | "bin": { 7 | "browser-tools-mcp": "dist/mcp-server.js" 8 | }, 9 | "scripts": { 10 | "inspect": "tsc && npx @modelcontextprotocol/inspector node -- dist/mcp-server.js", 11 | "inspect-live": "npx @modelcontextprotocol/inspector npx -- @agentdeskai/browser-tools-mcp", 12 | "build": "tsc", 13 | "start": "tsc && node dist/mcp-server.js", 14 | "prepublishOnly": "npm run build", 15 | "update": "npm run build && npm version patch && npm publish" 16 | }, 17 | "keywords": [ 18 | "mcp", 19 | "model-context-protocol", 20 | "browser", 21 | "tools", 22 | "debugging", 23 | "ai", 24 | "chrome", 25 | "extension" 26 | ], 27 | "author": "AgentDesk AI", 28 | "license": "MIT", 29 | "dependencies": { 30 | "@modelcontextprotocol/sdk": "^1.4.1", 31 | "body-parser": "^1.20.3", 32 | "cors": "^2.8.5", 33 | "express": "^4.21.2", 34 | "llm-cost": "^1.0.5", 35 | "node-fetch": "^2.7.0", 36 | "ws": "^8.18.0" 37 | }, 38 | "devDependencies": { 39 | "@types/ws": "^8.5.14", 40 | "@types/body-parser": "^1.19.5", 41 | "@types/cors": "^2.8.17", 42 | "@types/express": "^5.0.0", 43 | "@types/node": "^22.13.1", 44 | "@types/node-fetch": "^2.6.11", 45 | "typescript": "^5.7.3" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /browser-tools-mcp/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "esModuleInterop": true, 7 | "outDir": "./dist", 8 | "rootDir": ".", 9 | "strict": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true 12 | }, 13 | "include": ["*.ts"], 14 | "exclude": ["node_modules", "dist"] 15 | } -------------------------------------------------------------------------------- /browser-tools-server/README.md: -------------------------------------------------------------------------------- 1 | # Browser Tools Server 2 | 3 | A powerful browser tools server for capturing and managing browser events, logs, and screenshots. This server works in conjunction with the Browser Tools Chrome Extension to provide comprehensive browser debugging capabilities. 4 | 5 | ## Features 6 | 7 | - Console log capture 8 | - Network request monitoring 9 | - Screenshot capture 10 | - Element selection tracking 11 | - WebSocket real-time communication 12 | - Configurable log limits and settings 13 | - Lighthouse-powered accessibility, performance, SEO, and best practices audits 14 | 15 | ## Installation 16 | 17 | ```bash 18 | npx @agentdeskai/browser-tools-server 19 | ``` 20 | 21 | Or install globally: 22 | 23 | ```bash 24 | npm install -g @agentdeskai/browser-tools-server 25 | ``` 26 | 27 | ## Usage 28 | 29 | 1. Start the server: 30 | 31 | ```bash 32 | npx @agentdeskai/browser-tools-server 33 | ``` 34 | 35 | 2. The server will start on port 3025 by default 36 | 37 | 3. Install and enable the Browser Tools Chrome Extension 38 | 39 | 4. The server exposes the following endpoints: 40 | 41 | - `/console-logs` - Get console logs 42 | - `/console-errors` - Get console errors 43 | - `/network-errors` - Get network error logs 44 | - `/network-success` - Get successful network requests 45 | - `/all-xhr` - Get all network requests 46 | - `/screenshot` - Capture screenshots 47 | - `/selected-element` - Get currently selected DOM element 48 | - `/accessibility-audit` - Run accessibility audit on current page 49 | - `/performance-audit` - Run performance audit on current page 50 | - `/seo-audit` - Run SEO audit on current page 51 | 52 | ## API Documentation 53 | 54 | ### GET Endpoints 55 | 56 | - `GET /console-logs` - Returns recent console logs 57 | - `GET /console-errors` - Returns recent console errors 58 | - `GET /network-errors` - Returns recent network errors 59 | - `GET /network-success` - Returns recent successful network requests 60 | - `GET /all-xhr` - Returns all recent network requests 61 | - `GET /selected-element` - Returns the currently selected DOM element 62 | 63 | ### POST Endpoints 64 | 65 | - `POST /extension-log` - Receive logs from the extension 66 | - `POST /screenshot` - Capture and save screenshots 67 | - `POST /selected-element` - Update the selected element 68 | - `POST /wipelogs` - Clear all stored logs 69 | - `POST /accessibility-audit` - Run a WCAG-compliant accessibility audit on the current page 70 | - `POST /performance-audit` - Run a performance audit on the current page 71 | - `POST /seo-audit` - Run a SEO audit on the current page 72 | 73 | # Audit Functionality 74 | 75 | The server provides Lighthouse-powered audit capabilities through four AI-optimized endpoints. These audits have been specifically tailored for AI consumption, with structured data, clear categorization, and smart prioritization. 76 | 77 | ## Smart Limit Implementation 78 | 79 | All audit tools implement a "smart limit" approach to provide the most relevant information based on impact severity: 80 | 81 | - **Critical issues**: No limit (all issues are shown) 82 | - **Serious issues**: Up to 15 items per issue 83 | - **Moderate issues**: Up to 10 items per issue 84 | - **Minor issues**: Up to 3 items per issue 85 | 86 | This ensures that the most important issues are always included in the response, while less important ones are limited to maintain a manageable response size for AI processing. 87 | 88 | ## Common Audit Response Structure 89 | 90 | All audit responses follow a similar structure: 91 | 92 | ```json 93 | { 94 | "metadata": { 95 | "url": "https://example.com", 96 | "timestamp": "2025-03-06T16:28:30.930Z", 97 | "device": "desktop", 98 | "lighthouseVersion": "11.7.1" 99 | }, 100 | "report": { 101 | "score": 88, 102 | "audit_counts": { 103 | "failed": 2, 104 | "passed": 17, 105 | "manual": 10, 106 | "informative": 0, 107 | "not_applicable": 42 108 | } 109 | // Audit-specific content 110 | // ... 111 | } 112 | } 113 | ``` 114 | 115 | ## Accessibility Audit (`/accessibility-audit`) 116 | 117 | The accessibility audit evaluates web pages against WCAG standards, identifying issues that affect users with disabilities. 118 | 119 | ### Response Format 120 | 121 | ```json 122 | { 123 | "metadata": { 124 | "url": "https://example.com", 125 | "timestamp": "2025-03-06T16:28:30.930Z", 126 | "device": "desktop", 127 | "lighthouseVersion": "11.7.1" 128 | }, 129 | "report": { 130 | "score": 88, 131 | "audit_counts": { 132 | "failed": 2, 133 | "passed": 17, 134 | "manual": 10, 135 | "informative": 0, 136 | "not_applicable": 42 137 | }, 138 | "issues": [ 139 | { 140 | "id": "meta-viewport", 141 | "title": "`[user-scalable=\"no\"]` is used in the `` element or the `[maximum-scale]` attribute is less than 5.", 142 | "impact": "critical", 143 | "category": "a11y-best-practices", 144 | "elements": [ 145 | { 146 | "selector": "head > meta", 147 | "snippet": "", 148 | "label": "head > meta", 149 | "issue_description": "Fix any of the following: user-scalable on tag disables zooming on mobile devices" 150 | } 151 | ], 152 | "score": 0 153 | } 154 | ], 155 | "categories": { 156 | "a11y-navigation": { "score": 0, "issues_count": 0 }, 157 | "a11y-aria": { "score": 0, "issues_count": 1 }, 158 | "a11y-best-practices": { "score": 0, "issues_count": 1 } 159 | }, 160 | "critical_elements": [ 161 | { 162 | "selector": "head > meta", 163 | "snippet": "", 164 | "label": "head > meta", 165 | "issue_description": "Fix any of the following: user-scalable on tag disables zooming on mobile devices" 166 | } 167 | ], 168 | "prioritized_recommendations": [ 169 | "Fix ARIA attributes and roles", 170 | "Fix 1 issues in a11y-best-practices" 171 | ] 172 | } 173 | } 174 | ``` 175 | 176 | ### Key Features 177 | 178 | - **Issues Categorized by Impact**: Critical, serious, moderate, and minor 179 | - **Element-Specific Information**: Selectors, snippets, and labels for affected elements 180 | - **Issue Categories**: ARIA, navigation, color contrast, forms, keyboard access, etc. 181 | - **Critical Elements List**: Quick access to the most serious issues 182 | - **Prioritized Recommendations**: Actionable advice in order of importance 183 | 184 | ## Performance Audit (`/performance-audit`) 185 | 186 | The performance audit analyzes page load speed, Core Web Vitals, and optimization opportunities. 187 | 188 | ### Response Format 189 | 190 | ```json 191 | { 192 | "metadata": { 193 | "url": "https://example.com", 194 | "timestamp": "2025-03-06T16:27:44.900Z", 195 | "device": "desktop", 196 | "lighthouseVersion": "11.7.1" 197 | }, 198 | "report": { 199 | "score": 60, 200 | "audit_counts": { 201 | "failed": 11, 202 | "passed": 21, 203 | "manual": 0, 204 | "informative": 20, 205 | "not_applicable": 8 206 | }, 207 | "metrics": [ 208 | { 209 | "id": "lcp", 210 | "score": 0, 211 | "value_ms": 14149, 212 | "passes_core_web_vital": false, 213 | "element_selector": "div.heading > span", 214 | "element_type": "text", 215 | "element_content": "Welcome to Example" 216 | }, 217 | { 218 | "id": "fcp", 219 | "score": 0.53, 220 | "value_ms": 1542, 221 | "passes_core_web_vital": false 222 | }, 223 | { 224 | "id": "si", 225 | "score": 0, 226 | "value_ms": 6883 227 | }, 228 | { 229 | "id": "tti", 230 | "score": 0, 231 | "value_ms": 14746 232 | }, 233 | { 234 | "id": "cls", 235 | "score": 1, 236 | "value_ms": 0.001, 237 | "passes_core_web_vital": true 238 | }, 239 | { 240 | "id": "tbt", 241 | "score": 1, 242 | "value_ms": 43, 243 | "passes_core_web_vital": true 244 | } 245 | ], 246 | "opportunities": [ 247 | { 248 | "id": "render_blocking_resources", 249 | "savings_ms": 1270, 250 | "severity": "serious", 251 | "resources": [ 252 | { 253 | "url": "styles.css", 254 | "savings_ms": 781 255 | } 256 | ] 257 | } 258 | ], 259 | "page_stats": { 260 | "total_size_kb": 2190, 261 | "total_requests": 108, 262 | "resource_counts": { 263 | "js": 86, 264 | "css": 1, 265 | "img": 3, 266 | "font": 3, 267 | "other": 15 268 | }, 269 | "third_party_size_kb": 2110, 270 | "main_thread_blocking_time_ms": 693 271 | }, 272 | "prioritized_recommendations": ["Improve Largest Contentful Paint (LCP)"] 273 | } 274 | } 275 | ``` 276 | 277 | ### Key Features 278 | 279 | - **Core Web Vitals Analysis**: LCP, FCP, CLS, TBT with pass/fail status 280 | - **Element Information for LCP**: Identifies what's causing the largest contentful paint 281 | - **Optimization Opportunities**: Specific actions to improve performance with estimated time savings 282 | - **Resource Breakdown**: By type, size, and origin (first vs. third party) 283 | - **Main Thread Analysis**: Blocking time metrics to identify JavaScript performance issues 284 | - **Resource-Specific Recommendations**: For each optimization opportunity 285 | 286 | ## SEO Audit (`/seo-audit`) 287 | 288 | The SEO audit checks search engine optimization best practices and identifies issues that could affect search ranking. 289 | 290 | ### Response Format 291 | 292 | ```json 293 | { 294 | "metadata": { 295 | "url": "https://example.com", 296 | "timestamp": "2025-03-06T16:29:12.455Z", 297 | "device": "desktop", 298 | "lighthouseVersion": "11.7.1" 299 | }, 300 | "report": { 301 | "score": 91, 302 | "audit_counts": { 303 | "failed": 1, 304 | "passed": 10, 305 | "manual": 1, 306 | "informative": 0, 307 | "not_applicable": 3 308 | }, 309 | "issues": [ 310 | { 311 | "id": "is-crawlable", 312 | "title": "Page is blocked from indexing", 313 | "impact": "critical", 314 | "category": "crawlability", 315 | "score": 0 316 | } 317 | ], 318 | "categories": { 319 | "content": { "score": 0, "issues_count": 0 }, 320 | "mobile": { "score": 0, "issues_count": 0 }, 321 | "crawlability": { "score": 0, "issues_count": 1 }, 322 | "other": { "score": 0, "issues_count": 0 } 323 | }, 324 | "prioritized_recommendations": [ 325 | "Fix crawlability issues (1 issues): robots.txt, sitemaps, and redirects" 326 | ] 327 | } 328 | } 329 | ``` 330 | 331 | ### Key Features 332 | 333 | - **Issues Categorized by Impact**: Critical, serious, moderate, and minor 334 | - **SEO Categories**: Content, mobile friendliness, crawlability 335 | - **Issue Details**: Information about what's causing each SEO problem 336 | - **Prioritized Recommendations**: Actionable advice in order of importance 337 | 338 | ## Best Practices Audit (`/best-practices-audit`) 339 | 340 | The best practices audit evaluates adherence to web development best practices related to security, trust, user experience, and browser compatibility. 341 | 342 | ### Response Format 343 | 344 | ```json 345 | { 346 | "metadata": { 347 | "url": "https://example.com", 348 | "timestamp": "2025-03-06T17:01:38.029Z", 349 | "device": "desktop", 350 | "lighthouseVersion": "11.7.1" 351 | }, 352 | "report": { 353 | "score": 74, 354 | "audit_counts": { 355 | "failed": 4, 356 | "passed": 10, 357 | "manual": 0, 358 | "informative": 2, 359 | "not_applicable": 1 360 | }, 361 | "issues": [ 362 | { 363 | "id": "deprecations", 364 | "title": "Uses deprecated APIs", 365 | "impact": "critical", 366 | "category": "security", 367 | "score": 0, 368 | "details": [ 369 | { 370 | "value": "UnloadHandler" 371 | } 372 | ] 373 | }, 374 | { 375 | "id": "errors-in-console", 376 | "title": "Browser errors were logged to the console", 377 | "impact": "serious", 378 | "category": "user-experience", 379 | "score": 0, 380 | "details": [ 381 | { 382 | "source": "console.error", 383 | "description": "ReferenceError: variable is not defined" 384 | } 385 | ] 386 | } 387 | ], 388 | "categories": { 389 | "security": { "score": 75, "issues_count": 1 }, 390 | "trust": { "score": 100, "issues_count": 0 }, 391 | "user-experience": { "score": 50, "issues_count": 1 }, 392 | "browser-compat": { "score": 100, "issues_count": 0 }, 393 | "other": { "score": 75, "issues_count": 2 } 394 | }, 395 | "prioritized_recommendations": [ 396 | "Address 1 security issues: vulnerabilities, CSP, deprecations", 397 | "Improve 1 user experience issues: console errors, user interactions" 398 | ] 399 | } 400 | } 401 | ``` 402 | 403 | ### Key Features 404 | 405 | - **Issues Categorized by Impact**: Critical, serious, moderate, and minor 406 | - **Best Practice Categories**: Security, trust, user experience, browser compatibility 407 | - **Detailed Issue Information**: Specific problems affecting best practices compliance 408 | - **Security Focus**: Special attention to security vulnerabilities and deprecated APIs 409 | - **Prioritized Recommendations**: Actionable advice in order of importance 410 | 411 | ## License 412 | 413 | MIT 414 | 415 | # Puppeteer Service 416 | 417 | A comprehensive browser automation service built on Puppeteer to provide reliable cross-platform browser control capabilities. 418 | 419 | ## Features 420 | 421 | - **Cross-Platform Browser Support**: 422 | 423 | - Windows, macOS, and Linux support 424 | - Chrome, Edge, Brave, and Firefox detection 425 | - Fallback strategy for finding browser executables 426 | 427 | - **Smart Browser Management**: 428 | 429 | - Singleton browser instance with automatic cleanup 430 | - Connection retry mechanisms 431 | - Temporary user data directories with cleanup 432 | 433 | - **Rich Configuration Options**: 434 | - Custom browser paths 435 | - Network condition emulation 436 | - Device emulation (mobile, tablet, desktop) 437 | - Resource blocking 438 | - Cookies and headers customization 439 | - Locale and timezone emulation 440 | -------------------------------------------------------------------------------- /browser-tools-server/lighthouse/accessibility.ts: -------------------------------------------------------------------------------- 1 | import { Result as LighthouseResult } from "lighthouse"; 2 | import { AuditCategory, LighthouseReport } from "./types.js"; 3 | import { runLighthouseAudit } from "./index.js"; 4 | 5 | // === Accessibility Report Types === 6 | 7 | /** 8 | * Accessibility-specific report content structure 9 | */ 10 | export interface AccessibilityReportContent { 11 | score: number; // Overall score (0-100) 12 | audit_counts: { 13 | // Counts of different audit types 14 | failed: number; 15 | passed: number; 16 | manual: number; 17 | informative: number; 18 | not_applicable: number; 19 | }; 20 | issues: AIAccessibilityIssue[]; 21 | categories: { 22 | [category: string]: { 23 | score: number; 24 | issues_count: number; 25 | }; 26 | }; 27 | critical_elements: AIAccessibilityElement[]; 28 | prioritized_recommendations?: string[]; // Ordered list of recommendations 29 | } 30 | 31 | /** 32 | * Full accessibility report implementing the base LighthouseReport interface 33 | */ 34 | export type AIOptimizedAccessibilityReport = 35 | LighthouseReport; 36 | 37 | /** 38 | * AI-optimized accessibility issue 39 | */ 40 | interface AIAccessibilityIssue { 41 | id: string; // e.g., "color-contrast" 42 | title: string; // e.g., "Color contrast is sufficient" 43 | impact: "critical" | "serious" | "moderate" | "minor"; 44 | category: string; // e.g., "contrast", "aria", "forms", "keyboard" 45 | elements?: AIAccessibilityElement[]; // Elements with issues 46 | score: number | null; // 0-1 or null 47 | } 48 | 49 | /** 50 | * Accessibility element with issues 51 | */ 52 | interface AIAccessibilityElement { 53 | selector: string; // CSS selector 54 | snippet?: string; // HTML snippet 55 | label?: string; // Element label 56 | issue_description?: string; // Description of the issue 57 | value?: string | number; // Current value (e.g., contrast ratio) 58 | } 59 | 60 | // Original interfaces for backward compatibility 61 | interface AccessibilityAudit { 62 | id: string; // e.g., "color-contrast" 63 | title: string; // e.g., "Color contrast is sufficient" 64 | description: string; // e.g., "Ensures text is readable..." 65 | score: number | null; // 0-1 (normalized), null for manual/informative 66 | scoreDisplayMode: string; // e.g., "binary", "numeric", "manual" 67 | details?: AuditDetails; // Optional, structured details 68 | weight?: number; // Optional, audit weight for impact calculation 69 | } 70 | 71 | type AuditDetails = { 72 | items?: Array<{ 73 | node?: { 74 | selector: string; // e.g., ".my-class" 75 | snippet?: string; // HTML snippet 76 | nodeLabel?: string; // e.g., "Modify logging size limits / truncation" 77 | explanation?: string; // Explanation of why the node fails the audit 78 | }; 79 | value?: string | number; // Specific value (e.g., contrast ratio) 80 | explanation?: string; // Explanation at the item level 81 | }>; 82 | debugData?: string; // Optional, debug information 83 | [key: string]: any; // Flexible for other detail types (tables, etc.) 84 | }; 85 | 86 | // Original limits were optimized for human consumption 87 | // This ensures we always include critical issues while limiting less important ones 88 | const DETAIL_LIMITS = { 89 | critical: Number.MAX_SAFE_INTEGER, // No limit for critical issues 90 | serious: 15, // Up to 15 items for serious issues 91 | moderate: 10, // Up to 10 items for moderate issues 92 | minor: 3, // Up to 3 items for minor issues 93 | }; 94 | 95 | /** 96 | * Runs an accessibility audit on the specified URL 97 | * @param url The URL to audit 98 | * @returns Promise resolving to AI-optimized accessibility audit results 99 | */ 100 | export async function runAccessibilityAudit( 101 | url: string 102 | ): Promise { 103 | try { 104 | const lhr = await runLighthouseAudit(url, [AuditCategory.ACCESSIBILITY]); 105 | return extractAIOptimizedData(lhr, url); 106 | } catch (error) { 107 | throw new Error( 108 | `Accessibility audit failed: ${ 109 | error instanceof Error ? error.message : String(error) 110 | }` 111 | ); 112 | } 113 | } 114 | 115 | /** 116 | * Extract AI-optimized accessibility data from Lighthouse results 117 | */ 118 | const extractAIOptimizedData = ( 119 | lhr: LighthouseResult, 120 | url: string 121 | ): AIOptimizedAccessibilityReport => { 122 | const categoryData = lhr.categories[AuditCategory.ACCESSIBILITY]; 123 | const audits = lhr.audits || {}; 124 | 125 | // Add metadata 126 | const metadata = { 127 | url, 128 | timestamp: lhr.fetchTime || new Date().toISOString(), 129 | device: "desktop", // This could be made configurable 130 | lighthouseVersion: lhr.lighthouseVersion, 131 | }; 132 | 133 | // Initialize variables 134 | const issues: AIAccessibilityIssue[] = []; 135 | const criticalElements: AIAccessibilityElement[] = []; 136 | const categories: { 137 | [category: string]: { score: number; issues_count: number }; 138 | } = {}; 139 | 140 | // Count audits by type 141 | let failedCount = 0; 142 | let passedCount = 0; 143 | let manualCount = 0; 144 | let informativeCount = 0; 145 | let notApplicableCount = 0; 146 | 147 | // Process audit refs 148 | const auditRefs = categoryData?.auditRefs || []; 149 | 150 | // First pass: count audits by type and initialize categories 151 | auditRefs.forEach((ref) => { 152 | const audit = audits[ref.id]; 153 | if (!audit) return; 154 | 155 | // Count by scoreDisplayMode 156 | if (audit.scoreDisplayMode === "manual") { 157 | manualCount++; 158 | } else if (audit.scoreDisplayMode === "informative") { 159 | informativeCount++; 160 | } else if (audit.scoreDisplayMode === "notApplicable") { 161 | notApplicableCount++; 162 | } else if (audit.score !== null) { 163 | // Binary pass/fail 164 | if (audit.score >= 0.9) { 165 | passedCount++; 166 | } else { 167 | failedCount++; 168 | } 169 | } 170 | 171 | // Process categories 172 | if (ref.group) { 173 | // Initialize category if not exists 174 | if (!categories[ref.group]) { 175 | categories[ref.group] = { score: 0, issues_count: 0 }; 176 | } 177 | 178 | // Update category score and issues count 179 | if (audit.score !== null && audit.score < 0.9) { 180 | categories[ref.group].issues_count++; 181 | } 182 | } 183 | }); 184 | 185 | // Second pass: process failed audits into AI-friendly format 186 | auditRefs 187 | .filter((ref) => { 188 | const audit = audits[ref.id]; 189 | return audit && audit.score !== null && audit.score < 0.9; 190 | }) 191 | .sort((a, b) => (b.weight || 0) - (a.weight || 0)) 192 | // No limit on number of failed audits - we'll show them all 193 | .forEach((ref) => { 194 | const audit = audits[ref.id]; 195 | 196 | // Determine impact level based on score and weight 197 | let impact: "critical" | "serious" | "moderate" | "minor" = "moderate"; 198 | if (audit.score === 0) { 199 | impact = "critical"; 200 | } else if (audit.score !== null && audit.score <= 0.5) { 201 | impact = "serious"; 202 | } else if (audit.score !== null && audit.score > 0.7) { 203 | impact = "minor"; 204 | } 205 | 206 | // Create elements array 207 | const elements: AIAccessibilityElement[] = []; 208 | 209 | if (audit.details) { 210 | const details = audit.details as any; 211 | if (details.items && Array.isArray(details.items)) { 212 | const items = details.items; 213 | // Apply limits based on impact level 214 | const itemLimit = DETAIL_LIMITS[impact]; 215 | items.slice(0, itemLimit).forEach((item: any) => { 216 | if (item.node) { 217 | const element: AIAccessibilityElement = { 218 | selector: item.node.selector, 219 | snippet: item.node.snippet, 220 | label: item.node.nodeLabel, 221 | issue_description: item.node.explanation || item.explanation, 222 | }; 223 | 224 | if (item.value !== undefined) { 225 | element.value = item.value; 226 | } 227 | 228 | elements.push(element); 229 | 230 | // Add to critical elements if impact is critical or serious 231 | if (impact === "critical" || impact === "serious") { 232 | criticalElements.push(element); 233 | } 234 | } 235 | }); 236 | } 237 | } 238 | 239 | // Create the issue 240 | const issue: AIAccessibilityIssue = { 241 | id: ref.id, 242 | title: audit.title, 243 | impact, 244 | category: ref.group || "other", 245 | elements: elements.length > 0 ? elements : undefined, 246 | score: audit.score, 247 | }; 248 | 249 | issues.push(issue); 250 | }); 251 | 252 | // Calculate overall score 253 | const score = Math.round((categoryData?.score || 0) * 100); 254 | 255 | // Generate prioritized recommendations 256 | const prioritized_recommendations: string[] = []; 257 | 258 | // Add category-specific recommendations 259 | Object.entries(categories) 260 | .filter(([_, data]) => data.issues_count > 0) 261 | .sort(([_, a], [__, b]) => b.issues_count - a.issues_count) 262 | .forEach(([category, data]) => { 263 | let recommendation = ""; 264 | 265 | switch (category) { 266 | case "a11y-color-contrast": 267 | recommendation = "Improve color contrast for better readability"; 268 | break; 269 | case "a11y-names-labels": 270 | recommendation = "Add proper labels to all interactive elements"; 271 | break; 272 | case "a11y-aria": 273 | recommendation = "Fix ARIA attributes and roles"; 274 | break; 275 | case "a11y-navigation": 276 | recommendation = "Improve keyboard navigation and focus management"; 277 | break; 278 | case "a11y-language": 279 | recommendation = "Add proper language attributes to HTML"; 280 | break; 281 | case "a11y-tables-lists": 282 | recommendation = "Fix table and list structures for screen readers"; 283 | break; 284 | default: 285 | recommendation = `Fix ${data.issues_count} issues in ${category}`; 286 | } 287 | 288 | prioritized_recommendations.push(recommendation); 289 | }); 290 | 291 | // Add specific high-impact recommendations 292 | if (issues.some((issue) => issue.id === "color-contrast")) { 293 | prioritized_recommendations.push( 294 | "Fix low contrast text for better readability" 295 | ); 296 | } 297 | 298 | if (issues.some((issue) => issue.id === "document-title")) { 299 | prioritized_recommendations.push("Add a descriptive page title"); 300 | } 301 | 302 | if (issues.some((issue) => issue.id === "image-alt")) { 303 | prioritized_recommendations.push("Add alt text to all images"); 304 | } 305 | 306 | // Create the report content 307 | const reportContent: AccessibilityReportContent = { 308 | score, 309 | audit_counts: { 310 | failed: failedCount, 311 | passed: passedCount, 312 | manual: manualCount, 313 | informative: informativeCount, 314 | not_applicable: notApplicableCount, 315 | }, 316 | issues, 317 | categories, 318 | critical_elements: criticalElements, 319 | prioritized_recommendations: 320 | prioritized_recommendations.length > 0 321 | ? prioritized_recommendations 322 | : undefined, 323 | }; 324 | 325 | // Return the full report following the LighthouseReport interface 326 | return { 327 | metadata, 328 | report: reportContent, 329 | }; 330 | }; 331 | -------------------------------------------------------------------------------- /browser-tools-server/lighthouse/best-practices.ts: -------------------------------------------------------------------------------- 1 | import { Result as LighthouseResult } from "lighthouse"; 2 | import { AuditCategory, LighthouseReport } from "./types.js"; 3 | import { runLighthouseAudit } from "./index.js"; 4 | 5 | // === Best Practices Report Types === 6 | 7 | /** 8 | * Best Practices-specific report content structure 9 | */ 10 | export interface BestPracticesReportContent { 11 | score: number; // Overall score (0-100) 12 | audit_counts: { 13 | // Counts of different audit types 14 | failed: number; 15 | passed: number; 16 | manual: number; 17 | informative: number; 18 | not_applicable: number; 19 | }; 20 | issues: AIBestPracticesIssue[]; 21 | categories: { 22 | [category: string]: { 23 | score: number; 24 | issues_count: number; 25 | }; 26 | }; 27 | prioritized_recommendations?: string[]; // Ordered list of recommendations 28 | } 29 | 30 | /** 31 | * Full Best Practices report implementing the base LighthouseReport interface 32 | */ 33 | export type AIOptimizedBestPracticesReport = 34 | LighthouseReport; 35 | 36 | /** 37 | * AI-optimized Best Practices issue 38 | */ 39 | interface AIBestPracticesIssue { 40 | id: string; // e.g., "js-libraries" 41 | title: string; // e.g., "Detected JavaScript libraries" 42 | impact: "critical" | "serious" | "moderate" | "minor"; 43 | category: string; // e.g., "security", "trust", "user-experience", "browser-compat" 44 | details?: { 45 | name?: string; // Name of the item (e.g., library name, vulnerability) 46 | version?: string; // Version information if applicable 47 | value?: string; // Current value or status 48 | issue?: string; // Description of the issue 49 | }[]; 50 | score: number | null; // 0-1 or null 51 | } 52 | 53 | // Original interfaces for backward compatibility 54 | interface BestPracticesAudit { 55 | id: string; 56 | title: string; 57 | description: string; 58 | score: number | null; 59 | scoreDisplayMode: string; 60 | details?: BestPracticesAuditDetails; 61 | } 62 | 63 | interface BestPracticesAuditDetails { 64 | items?: Array>; 65 | type?: string; // e.g., "table" 66 | } 67 | 68 | // This ensures we always include critical issues while limiting less important ones 69 | const DETAIL_LIMITS: Record = { 70 | critical: Number.MAX_SAFE_INTEGER, // No limit for critical issues 71 | serious: 15, // Up to 15 items for serious issues 72 | moderate: 10, // Up to 10 items for moderate issues 73 | minor: 3, // Up to 3 items for minor issues 74 | }; 75 | 76 | /** 77 | * Runs a Best Practices audit on the specified URL 78 | * @param url The URL to audit 79 | * @returns Promise resolving to AI-optimized Best Practices audit results 80 | */ 81 | export async function runBestPracticesAudit( 82 | url: string 83 | ): Promise { 84 | try { 85 | const lhr = await runLighthouseAudit(url, [AuditCategory.BEST_PRACTICES]); 86 | return extractAIOptimizedData(lhr, url); 87 | } catch (error) { 88 | throw new Error( 89 | `Best Practices audit failed: ${ 90 | error instanceof Error ? error.message : String(error) 91 | }` 92 | ); 93 | } 94 | } 95 | 96 | /** 97 | * Extract AI-optimized Best Practices data from Lighthouse results 98 | */ 99 | const extractAIOptimizedData = ( 100 | lhr: LighthouseResult, 101 | url: string 102 | ): AIOptimizedBestPracticesReport => { 103 | const categoryData = lhr.categories[AuditCategory.BEST_PRACTICES]; 104 | const audits = lhr.audits || {}; 105 | 106 | // Add metadata 107 | const metadata = { 108 | url, 109 | timestamp: lhr.fetchTime || new Date().toISOString(), 110 | device: lhr.configSettings?.formFactor || "desktop", 111 | lighthouseVersion: lhr.lighthouseVersion || "unknown", 112 | }; 113 | 114 | // Process audit results 115 | const issues: AIBestPracticesIssue[] = []; 116 | const categories: { [key: string]: { score: number; issues_count: number } } = 117 | { 118 | security: { score: 0, issues_count: 0 }, 119 | trust: { score: 0, issues_count: 0 }, 120 | "user-experience": { score: 0, issues_count: 0 }, 121 | "browser-compat": { score: 0, issues_count: 0 }, 122 | other: { score: 0, issues_count: 0 }, 123 | }; 124 | 125 | // Counters for audit types 126 | let failedCount = 0; 127 | let passedCount = 0; 128 | let manualCount = 0; 129 | let informativeCount = 0; 130 | let notApplicableCount = 0; 131 | 132 | // Process failed audits (score < 1) 133 | const failedAudits = Object.entries(audits) 134 | .filter(([, audit]) => { 135 | const score = audit.score; 136 | return ( 137 | score !== null && 138 | score < 1 && 139 | audit.scoreDisplayMode !== "manual" && 140 | audit.scoreDisplayMode !== "notApplicable" 141 | ); 142 | }) 143 | .map(([auditId, audit]) => ({ auditId, ...audit })); 144 | 145 | // Update counters 146 | Object.values(audits).forEach((audit) => { 147 | const { score, scoreDisplayMode } = audit; 148 | 149 | if (scoreDisplayMode === "manual") { 150 | manualCount++; 151 | } else if (scoreDisplayMode === "informative") { 152 | informativeCount++; 153 | } else if (scoreDisplayMode === "notApplicable") { 154 | notApplicableCount++; 155 | } else if (score === 1) { 156 | passedCount++; 157 | } else if (score !== null && score < 1) { 158 | failedCount++; 159 | } 160 | }); 161 | 162 | // Process failed audits into AI-friendly format 163 | failedAudits.forEach((ref: any) => { 164 | // Determine impact level based on audit score and weight 165 | let impact: "critical" | "serious" | "moderate" | "minor" = "moderate"; 166 | const score = ref.score || 0; 167 | 168 | // Use a more reliable approach to determine impact 169 | if (score === 0) { 170 | impact = "critical"; 171 | } else if (score < 0.5) { 172 | impact = "serious"; 173 | } else if (score < 0.9) { 174 | impact = "moderate"; 175 | } else { 176 | impact = "minor"; 177 | } 178 | 179 | // Categorize the issue 180 | let category = "other"; 181 | 182 | // Security-related issues 183 | if ( 184 | ref.auditId.includes("csp") || 185 | ref.auditId.includes("security") || 186 | ref.auditId.includes("vulnerab") || 187 | ref.auditId.includes("password") || 188 | ref.auditId.includes("cert") || 189 | ref.auditId.includes("deprecat") 190 | ) { 191 | category = "security"; 192 | } 193 | // Trust and legitimacy issues 194 | else if ( 195 | ref.auditId.includes("doctype") || 196 | ref.auditId.includes("charset") || 197 | ref.auditId.includes("legit") || 198 | ref.auditId.includes("trust") 199 | ) { 200 | category = "trust"; 201 | } 202 | // User experience issues 203 | else if ( 204 | ref.auditId.includes("user") || 205 | ref.auditId.includes("experience") || 206 | ref.auditId.includes("console") || 207 | ref.auditId.includes("errors") || 208 | ref.auditId.includes("paste") 209 | ) { 210 | category = "user-experience"; 211 | } 212 | // Browser compatibility issues 213 | else if ( 214 | ref.auditId.includes("compat") || 215 | ref.auditId.includes("browser") || 216 | ref.auditId.includes("vendor") || 217 | ref.auditId.includes("js-lib") 218 | ) { 219 | category = "browser-compat"; 220 | } 221 | 222 | // Count issues by category 223 | categories[category].issues_count++; 224 | 225 | // Create issue object 226 | const issue: AIBestPracticesIssue = { 227 | id: ref.auditId, 228 | title: ref.title, 229 | impact, 230 | category, 231 | score: ref.score, 232 | details: [], 233 | }; 234 | 235 | // Extract details if available 236 | const refDetails = ref.details as BestPracticesAuditDetails | undefined; 237 | if (refDetails?.items && Array.isArray(refDetails.items)) { 238 | const itemLimit = DETAIL_LIMITS[impact]; 239 | const detailItems = refDetails.items.slice(0, itemLimit); 240 | 241 | detailItems.forEach((item: Record) => { 242 | issue.details = issue.details || []; 243 | 244 | // Different audits have different detail structures 245 | const detail: Record = {}; 246 | 247 | if (typeof item.name === "string") detail.name = item.name; 248 | if (typeof item.version === "string") detail.version = item.version; 249 | if (typeof item.issue === "string") detail.issue = item.issue; 250 | if (item.value !== undefined) detail.value = String(item.value); 251 | 252 | // For JS libraries, extract name and version 253 | if ( 254 | ref.auditId === "js-libraries" && 255 | typeof item.name === "string" && 256 | typeof item.version === "string" 257 | ) { 258 | detail.name = item.name; 259 | detail.version = item.version; 260 | } 261 | 262 | // Add other generic properties that might exist 263 | for (const [key, value] of Object.entries(item)) { 264 | if (!detail[key] && typeof value === "string") { 265 | detail[key] = value; 266 | } 267 | } 268 | 269 | issue.details.push(detail as any); 270 | }); 271 | } 272 | 273 | issues.push(issue); 274 | }); 275 | 276 | // Calculate category scores (0-100) 277 | Object.keys(categories).forEach((category) => { 278 | // Simplified scoring: if there are issues in this category, score is reduced proportionally 279 | const issueCount = categories[category].issues_count; 280 | if (issueCount > 0) { 281 | // More issues = lower score, max penalty of 25 points per issue 282 | const penalty = Math.min(100, issueCount * 25); 283 | categories[category].score = Math.max(0, 100 - penalty); 284 | } else { 285 | categories[category].score = 100; 286 | } 287 | }); 288 | 289 | // Generate prioritized recommendations 290 | const prioritized_recommendations: string[] = []; 291 | 292 | // Prioritize recommendations by category with most issues 293 | Object.entries(categories) 294 | .filter(([_, data]) => data.issues_count > 0) 295 | .sort(([_, a], [__, b]) => b.issues_count - a.issues_count) 296 | .forEach(([category, data]) => { 297 | let recommendation = ""; 298 | 299 | switch (category) { 300 | case "security": 301 | recommendation = `Address ${data.issues_count} security issues: vulnerabilities, CSP, deprecations`; 302 | break; 303 | case "trust": 304 | recommendation = `Fix ${data.issues_count} trust & legitimacy issues: doctype, charset`; 305 | break; 306 | case "user-experience": 307 | recommendation = `Improve ${data.issues_count} user experience issues: console errors, user interactions`; 308 | break; 309 | case "browser-compat": 310 | recommendation = `Resolve ${data.issues_count} browser compatibility issues: outdated libraries, vendor prefixes`; 311 | break; 312 | default: 313 | recommendation = `Fix ${data.issues_count} other best practice issues`; 314 | } 315 | 316 | prioritized_recommendations.push(recommendation); 317 | }); 318 | 319 | // Return the optimized report 320 | return { 321 | metadata, 322 | report: { 323 | score: categoryData?.score ? Math.round(categoryData.score * 100) : 0, 324 | audit_counts: { 325 | failed: failedCount, 326 | passed: passedCount, 327 | manual: manualCount, 328 | informative: informativeCount, 329 | not_applicable: notApplicableCount, 330 | }, 331 | issues, 332 | categories, 333 | prioritized_recommendations, 334 | }, 335 | }; 336 | }; 337 | -------------------------------------------------------------------------------- /browser-tools-server/lighthouse/index.ts: -------------------------------------------------------------------------------- 1 | import lighthouse from "lighthouse"; 2 | import type { Result as LighthouseResult, Flags } from "lighthouse"; 3 | import { 4 | connectToHeadlessBrowser, 5 | scheduleBrowserCleanup, 6 | } from "../puppeteer-service.js"; 7 | import { LighthouseConfig, AuditCategory } from "./types.js"; 8 | 9 | /** 10 | * Creates a Lighthouse configuration object 11 | * @param categories Array of categories to audit 12 | * @returns Lighthouse configuration and flags 13 | */ 14 | export function createLighthouseConfig( 15 | categories: string[] = [AuditCategory.ACCESSIBILITY] 16 | ): LighthouseConfig { 17 | return { 18 | flags: { 19 | output: ["json"], 20 | onlyCategories: categories, 21 | formFactor: "desktop", 22 | port: undefined as number | undefined, 23 | screenEmulation: { 24 | mobile: false, 25 | width: 1350, 26 | height: 940, 27 | deviceScaleFactor: 1, 28 | disabled: false, 29 | }, 30 | }, 31 | config: { 32 | extends: "lighthouse:default", 33 | settings: { 34 | onlyCategories: categories, 35 | emulatedFormFactor: "desktop", 36 | throttling: { cpuSlowdownMultiplier: 1 }, 37 | }, 38 | }, 39 | }; 40 | } 41 | 42 | /** 43 | * Runs a Lighthouse audit on the specified URL via CDP 44 | * @param url The URL to audit 45 | * @param categories Array of categories to audit, defaults to ["accessibility"] 46 | * @returns Promise resolving to the Lighthouse result 47 | * @throws Error if the URL is invalid or if the audit fails 48 | */ 49 | export async function runLighthouseAudit( 50 | url: string, 51 | categories: string[] 52 | ): Promise { 53 | console.log(`Starting Lighthouse ${categories.join(", ")} audit for: ${url}`); 54 | 55 | if (!url || url === "about:blank") { 56 | console.error("Invalid URL for Lighthouse audit"); 57 | throw new Error( 58 | "Cannot run audit on an empty page or about:blank. Please navigate to a valid URL first." 59 | ); 60 | } 61 | 62 | try { 63 | // Always use a dedicated headless browser for audits 64 | console.log("Using dedicated headless browser for audit"); 65 | 66 | // Determine if this is a performance audit - we need to load all resources for performance audits 67 | const isPerformanceAudit = categories.includes(AuditCategory.PERFORMANCE); 68 | 69 | // For performance audits, we want to load all resources 70 | // For accessibility or other audits, we can block non-essential resources 71 | try { 72 | const { port } = await connectToHeadlessBrowser(url, { 73 | blockResources: !isPerformanceAudit, 74 | }); 75 | 76 | console.log(`Connected to browser on port: ${port}`); 77 | 78 | // Create Lighthouse config 79 | const { flags, config } = createLighthouseConfig(categories); 80 | flags.port = port; 81 | 82 | console.log( 83 | `Running Lighthouse with categories: ${categories.join(", ")}` 84 | ); 85 | const runnerResult = await lighthouse(url, flags as Flags, config); 86 | console.log("Lighthouse scan completed"); 87 | 88 | if (!runnerResult?.lhr) { 89 | console.error("Lighthouse audit failed to produce results"); 90 | throw new Error("Lighthouse audit failed to produce results"); 91 | } 92 | 93 | // Schedule browser cleanup after a delay to allow for subsequent audits 94 | scheduleBrowserCleanup(); 95 | 96 | // Return the result 97 | const result = runnerResult.lhr; 98 | 99 | return result; 100 | } catch (browserError) { 101 | // Check if the error is related to Chrome/Edge not being available 102 | const errorMessage = 103 | browserError instanceof Error 104 | ? browserError.message 105 | : String(browserError); 106 | if ( 107 | errorMessage.includes("Chrome could not be found") || 108 | errorMessage.includes("Failed to launch browser") || 109 | errorMessage.includes("spawn ENOENT") 110 | ) { 111 | throw new Error( 112 | "Chrome or Edge browser could not be found. Please ensure that Chrome or Edge is installed on your system to run audits." 113 | ); 114 | } 115 | // Re-throw other errors 116 | throw browserError; 117 | } 118 | } catch (error) { 119 | console.error("Lighthouse audit failed:", error); 120 | // Schedule browser cleanup even if the audit fails 121 | scheduleBrowserCleanup(); 122 | throw new Error( 123 | `Lighthouse audit failed: ${ 124 | error instanceof Error ? error.message : String(error) 125 | }` 126 | ); 127 | } 128 | } 129 | 130 | // Export from specific audit modules 131 | export * from "./accessibility.js"; 132 | export * from "./performance.js"; 133 | export * from "./seo.js"; 134 | export * from "./types.js"; 135 | -------------------------------------------------------------------------------- /browser-tools-server/lighthouse/performance.ts: -------------------------------------------------------------------------------- 1 | import { Result as LighthouseResult } from "lighthouse"; 2 | import { AuditCategory, LighthouseReport } from "./types.js"; 3 | import { runLighthouseAudit } from "./index.js"; 4 | 5 | // === Performance Report Types === 6 | 7 | /** 8 | * Performance-specific report content structure 9 | */ 10 | export interface PerformanceReportContent { 11 | score: number; // Overall score (0-100) 12 | audit_counts: { 13 | // Counts of different audit types 14 | failed: number; 15 | passed: number; 16 | manual: number; 17 | informative: number; 18 | not_applicable: number; 19 | }; 20 | metrics: AIOptimizedMetric[]; 21 | opportunities: AIOptimizedOpportunity[]; 22 | page_stats?: AIPageStats; // Optional page statistics 23 | prioritized_recommendations?: string[]; // Ordered list of recommendations 24 | } 25 | 26 | /** 27 | * Full performance report implementing the base LighthouseReport interface 28 | */ 29 | export type AIOptimizedPerformanceReport = 30 | LighthouseReport; 31 | 32 | // AI-optimized performance metric format 33 | interface AIOptimizedMetric { 34 | id: string; // Short ID like "lcp", "fcp" 35 | score: number | null; // 0-1 score 36 | value_ms: number; // Value in milliseconds 37 | element_type?: string; // For LCP: "image", "text", etc. 38 | element_selector?: string; // DOM selector for the element 39 | element_url?: string; // For images/videos 40 | element_content?: string; // For text content (truncated) 41 | passes_core_web_vital?: boolean; // Whether this metric passes as a Core Web Vital 42 | } 43 | 44 | // AI-optimized opportunity format 45 | interface AIOptimizedOpportunity { 46 | id: string; // Like "render_blocking", "http2" 47 | savings_ms: number; // Time savings in ms 48 | severity?: "critical" | "serious" | "moderate" | "minor"; // Severity classification 49 | resources: Array<{ 50 | url: string; // Resource URL 51 | savings_ms?: number; // Individual resource savings 52 | size_kb?: number; // Size in KB 53 | type?: string; // Resource type (js, css, img, etc.) 54 | is_third_party?: boolean; // Whether this is a third-party resource 55 | }>; 56 | } 57 | 58 | // Page stats for AI analysis 59 | interface AIPageStats { 60 | total_size_kb: number; // Total page weight in KB 61 | total_requests: number; // Total number of requests 62 | resource_counts: { 63 | // Count by resource type 64 | js: number; 65 | css: number; 66 | img: number; 67 | font: number; 68 | other: number; 69 | }; 70 | third_party_size_kb: number; // Size of third-party resources 71 | main_thread_blocking_time_ms: number; // Time spent blocking the main thread 72 | } 73 | 74 | // This ensures we always include critical issues while limiting less important ones 75 | const DETAIL_LIMITS = { 76 | critical: Number.MAX_SAFE_INTEGER, // No limit for critical issues 77 | serious: 15, // Up to 15 items for serious issues 78 | moderate: 10, // Up to 10 items for moderate issues 79 | minor: 3, // Up to 3 items for minor issues 80 | }; 81 | 82 | /** 83 | * Performance audit adapted for AI consumption 84 | * This format is optimized for AI agents with: 85 | * - Concise, relevant information without redundant descriptions 86 | * - Key metrics and opportunities clearly structured 87 | * - Only actionable data that an AI can use for recommendations 88 | */ 89 | export async function runPerformanceAudit( 90 | url: string 91 | ): Promise { 92 | try { 93 | const lhr = await runLighthouseAudit(url, [AuditCategory.PERFORMANCE]); 94 | return extractAIOptimizedData(lhr, url); 95 | } catch (error) { 96 | throw new Error( 97 | `Performance audit failed: ${ 98 | error instanceof Error ? error.message : String(error) 99 | }` 100 | ); 101 | } 102 | } 103 | 104 | /** 105 | * Extract AI-optimized performance data from Lighthouse results 106 | */ 107 | const extractAIOptimizedData = ( 108 | lhr: LighthouseResult, 109 | url: string 110 | ): AIOptimizedPerformanceReport => { 111 | const audits = lhr.audits || {}; 112 | const categoryData = lhr.categories[AuditCategory.PERFORMANCE]; 113 | const score = Math.round((categoryData?.score || 0) * 100); 114 | 115 | // Add metadata 116 | const metadata = { 117 | url, 118 | timestamp: lhr.fetchTime || new Date().toISOString(), 119 | device: "desktop", // This could be made configurable 120 | lighthouseVersion: lhr.lighthouseVersion, 121 | }; 122 | 123 | // Count audits by type 124 | const auditRefs = categoryData?.auditRefs || []; 125 | let failedCount = 0; 126 | let passedCount = 0; 127 | let manualCount = 0; 128 | let informativeCount = 0; 129 | let notApplicableCount = 0; 130 | 131 | auditRefs.forEach((ref) => { 132 | const audit = audits[ref.id]; 133 | if (!audit) return; 134 | 135 | if (audit.scoreDisplayMode === "manual") { 136 | manualCount++; 137 | } else if (audit.scoreDisplayMode === "informative") { 138 | informativeCount++; 139 | } else if (audit.scoreDisplayMode === "notApplicable") { 140 | notApplicableCount++; 141 | } else if (audit.score !== null) { 142 | if (audit.score >= 0.9) { 143 | passedCount++; 144 | } else { 145 | failedCount++; 146 | } 147 | } 148 | }); 149 | 150 | const audit_counts = { 151 | failed: failedCount, 152 | passed: passedCount, 153 | manual: manualCount, 154 | informative: informativeCount, 155 | not_applicable: notApplicableCount, 156 | }; 157 | 158 | const metrics: AIOptimizedMetric[] = []; 159 | const opportunities: AIOptimizedOpportunity[] = []; 160 | 161 | // Extract core metrics 162 | if (audits["largest-contentful-paint"]) { 163 | const lcp = audits["largest-contentful-paint"]; 164 | const lcpElement = audits["largest-contentful-paint-element"]; 165 | 166 | const metric: AIOptimizedMetric = { 167 | id: "lcp", 168 | score: lcp.score, 169 | value_ms: Math.round(lcp.numericValue || 0), 170 | passes_core_web_vital: lcp.score !== null && lcp.score >= 0.9, 171 | }; 172 | 173 | // Enhanced LCP element detection 174 | 175 | // 1. Try from largest-contentful-paint-element audit 176 | if (lcpElement && lcpElement.details) { 177 | const lcpDetails = lcpElement.details as any; 178 | 179 | // First attempt - try to get directly from items 180 | if ( 181 | lcpDetails.items && 182 | Array.isArray(lcpDetails.items) && 183 | lcpDetails.items.length > 0 184 | ) { 185 | const item = lcpDetails.items[0]; 186 | 187 | // For text elements in tables format 188 | if (item.type === "table" && item.items && item.items.length > 0) { 189 | const firstTableItem = item.items[0]; 190 | 191 | if (firstTableItem.node) { 192 | if (firstTableItem.node.selector) { 193 | metric.element_selector = firstTableItem.node.selector; 194 | } 195 | 196 | // Determine element type based on path or selector 197 | const path = firstTableItem.node.path; 198 | const selector = firstTableItem.node.selector || ""; 199 | 200 | if (path) { 201 | if ( 202 | selector.includes(" > img") || 203 | selector.includes(" img") || 204 | selector.endsWith("img") || 205 | path.includes(",IMG") 206 | ) { 207 | metric.element_type = "image"; 208 | 209 | // Try to extract image name from selector 210 | const imgMatch = selector.match(/img[.][^> ]+/); 211 | if (imgMatch && !metric.element_url) { 212 | metric.element_url = imgMatch[0]; 213 | } 214 | } else if ( 215 | path.includes(",SPAN") || 216 | path.includes(",P") || 217 | path.includes(",H") 218 | ) { 219 | metric.element_type = "text"; 220 | } 221 | } 222 | 223 | // Try to extract text content if available 224 | if (firstTableItem.node.nodeLabel) { 225 | metric.element_content = firstTableItem.node.nodeLabel.substring( 226 | 0, 227 | 100 228 | ); 229 | } 230 | } 231 | } 232 | // Original handling for direct items 233 | else if (item.node?.nodeLabel) { 234 | // Determine element type from node label 235 | if (item.node.nodeLabel.startsWith(" 0 266 | ) { 267 | const item = lcpImageDetails.items[0]; 268 | 269 | if (item.url) { 270 | metric.element_type = "image"; 271 | metric.element_url = item.url; 272 | } 273 | } 274 | } 275 | 276 | // 3. Try directly from the LCP audit details 277 | if (!metric.element_url && lcp.details) { 278 | const lcpDirectDetails = lcp.details as any; 279 | 280 | if (lcpDirectDetails.items && Array.isArray(lcpDirectDetails.items)) { 281 | for (const item of lcpDirectDetails.items) { 282 | if (item.url || (item.node && item.node.path)) { 283 | if (item.url) { 284 | metric.element_url = item.url; 285 | metric.element_type = item.url.match( 286 | /\.(jpg|jpeg|png|gif|webp|svg)$/i 287 | ) 288 | ? "image" 289 | : "resource"; 290 | } 291 | if (item.node && item.node.selector) { 292 | metric.element_selector = item.node.selector; 293 | } 294 | break; 295 | } 296 | } 297 | } 298 | } 299 | 300 | // 4. Check for specific audit that might contain image info 301 | const largestImageAudit = audits["largest-image-paint"]; 302 | if (largestImageAudit && largestImageAudit.details) { 303 | const imageDetails = largestImageAudit.details as any; 304 | 305 | if ( 306 | imageDetails.items && 307 | Array.isArray(imageDetails.items) && 308 | imageDetails.items.length > 0 309 | ) { 310 | const item = imageDetails.items[0]; 311 | 312 | if (item.url) { 313 | // If we have a large image that's close in time to LCP, it's likely the LCP element 314 | metric.element_type = "image"; 315 | metric.element_url = item.url; 316 | } 317 | } 318 | } 319 | 320 | // 5. Check for network requests audit to find image resources 321 | if (!metric.element_url) { 322 | const networkRequests = audits["network-requests"]; 323 | 324 | if (networkRequests && networkRequests.details) { 325 | const networkDetails = networkRequests.details as any; 326 | 327 | if (networkDetails.items && Array.isArray(networkDetails.items)) { 328 | // Get all image resources loaded close to the LCP time 329 | const lcpTime = lcp.numericValue || 0; 330 | const imageResources = networkDetails.items 331 | .filter( 332 | (item: any) => 333 | item.url && 334 | item.mimeType && 335 | item.mimeType.startsWith("image/") && 336 | item.endTime && 337 | Math.abs(item.endTime - lcpTime) < 500 // Within 500ms of LCP 338 | ) 339 | .sort( 340 | (a: any, b: any) => 341 | Math.abs(a.endTime - lcpTime) - Math.abs(b.endTime - lcpTime) 342 | ); 343 | 344 | if (imageResources.length > 0) { 345 | const closestImage = imageResources[0]; 346 | 347 | if (!metric.element_type) { 348 | metric.element_type = "image"; 349 | metric.element_url = closestImage.url; 350 | } 351 | } 352 | } 353 | } 354 | } 355 | 356 | metrics.push(metric); 357 | } 358 | 359 | if (audits["first-contentful-paint"]) { 360 | const fcp = audits["first-contentful-paint"]; 361 | metrics.push({ 362 | id: "fcp", 363 | score: fcp.score, 364 | value_ms: Math.round(fcp.numericValue || 0), 365 | passes_core_web_vital: fcp.score !== null && fcp.score >= 0.9, 366 | }); 367 | } 368 | 369 | if (audits["speed-index"]) { 370 | const si = audits["speed-index"]; 371 | metrics.push({ 372 | id: "si", 373 | score: si.score, 374 | value_ms: Math.round(si.numericValue || 0), 375 | }); 376 | } 377 | 378 | if (audits["interactive"]) { 379 | const tti = audits["interactive"]; 380 | metrics.push({ 381 | id: "tti", 382 | score: tti.score, 383 | value_ms: Math.round(tti.numericValue || 0), 384 | }); 385 | } 386 | 387 | // Add CLS (Cumulative Layout Shift) 388 | if (audits["cumulative-layout-shift"]) { 389 | const cls = audits["cumulative-layout-shift"]; 390 | metrics.push({ 391 | id: "cls", 392 | score: cls.score, 393 | // CLS is not in ms, but a unitless value 394 | value_ms: Math.round((cls.numericValue || 0) * 1000) / 1000, // Convert to 3 decimal places 395 | passes_core_web_vital: cls.score !== null && cls.score >= 0.9, 396 | }); 397 | } 398 | 399 | // Add TBT (Total Blocking Time) 400 | if (audits["total-blocking-time"]) { 401 | const tbt = audits["total-blocking-time"]; 402 | metrics.push({ 403 | id: "tbt", 404 | score: tbt.score, 405 | value_ms: Math.round(tbt.numericValue || 0), 406 | passes_core_web_vital: tbt.score !== null && tbt.score >= 0.9, 407 | }); 408 | } 409 | 410 | // Extract opportunities 411 | if (audits["render-blocking-resources"]) { 412 | const rbrAudit = audits["render-blocking-resources"]; 413 | 414 | // Determine impact level based on potential savings 415 | let impact: "critical" | "serious" | "moderate" | "minor" = "moderate"; 416 | const savings = Math.round(rbrAudit.numericValue || 0); 417 | 418 | if (savings > 2000) { 419 | impact = "critical"; 420 | } else if (savings > 1000) { 421 | impact = "serious"; 422 | } else if (savings < 300) { 423 | impact = "minor"; 424 | } 425 | 426 | const opportunity: AIOptimizedOpportunity = { 427 | id: "render_blocking_resources", 428 | savings_ms: savings, 429 | severity: impact, 430 | resources: [], 431 | }; 432 | 433 | const rbrDetails = rbrAudit.details as any; 434 | if (rbrDetails && rbrDetails.items && Array.isArray(rbrDetails.items)) { 435 | // Determine how many items to include based on impact 436 | const itemLimit = DETAIL_LIMITS[impact]; 437 | 438 | rbrDetails.items 439 | .slice(0, itemLimit) 440 | .forEach((item: { url?: string; wastedMs?: number }) => { 441 | if (item.url) { 442 | // Extract file name from full URL 443 | const fileName = item.url.split("/").pop() || item.url; 444 | opportunity.resources.push({ 445 | url: fileName, 446 | savings_ms: Math.round(item.wastedMs || 0), 447 | }); 448 | } 449 | }); 450 | } 451 | 452 | if (opportunity.resources.length > 0) { 453 | opportunities.push(opportunity); 454 | } 455 | } 456 | 457 | if (audits["uses-http2"]) { 458 | const http2Audit = audits["uses-http2"]; 459 | 460 | // Determine impact level based on potential savings 461 | let impact: "critical" | "serious" | "moderate" | "minor" = "moderate"; 462 | const savings = Math.round(http2Audit.numericValue || 0); 463 | 464 | if (savings > 2000) { 465 | impact = "critical"; 466 | } else if (savings > 1000) { 467 | impact = "serious"; 468 | } else if (savings < 300) { 469 | impact = "minor"; 470 | } 471 | 472 | const opportunity: AIOptimizedOpportunity = { 473 | id: "http2", 474 | savings_ms: savings, 475 | severity: impact, 476 | resources: [], 477 | }; 478 | 479 | const http2Details = http2Audit.details as any; 480 | if ( 481 | http2Details && 482 | http2Details.items && 483 | Array.isArray(http2Details.items) 484 | ) { 485 | // Determine how many items to include based on impact 486 | const itemLimit = DETAIL_LIMITS[impact]; 487 | 488 | http2Details.items 489 | .slice(0, itemLimit) 490 | .forEach((item: { url?: string }) => { 491 | if (item.url) { 492 | // Extract file name from full URL 493 | const fileName = item.url.split("/").pop() || item.url; 494 | opportunity.resources.push({ url: fileName }); 495 | } 496 | }); 497 | } 498 | 499 | if (opportunity.resources.length > 0) { 500 | opportunities.push(opportunity); 501 | } 502 | } 503 | 504 | // After extracting all metrics and opportunities, collect page stats 505 | // Extract page stats 506 | let page_stats: AIPageStats | undefined; 507 | 508 | // Total page stats 509 | const totalByteWeight = audits["total-byte-weight"]; 510 | const networkRequests = audits["network-requests"]; 511 | const thirdPartyAudit = audits["third-party-summary"]; 512 | const mainThreadWork = audits["mainthread-work-breakdown"]; 513 | 514 | if (networkRequests && networkRequests.details) { 515 | const resourceDetails = networkRequests.details as any; 516 | 517 | if (resourceDetails.items && Array.isArray(resourceDetails.items)) { 518 | const resources = resourceDetails.items; 519 | const totalRequests = resources.length; 520 | 521 | // Calculate total size and counts by type 522 | let totalSizeKb = 0; 523 | let jsCount = 0, 524 | cssCount = 0, 525 | imgCount = 0, 526 | fontCount = 0, 527 | otherCount = 0; 528 | 529 | resources.forEach((resource: any) => { 530 | const sizeKb = resource.transferSize 531 | ? Math.round(resource.transferSize / 1024) 532 | : 0; 533 | totalSizeKb += sizeKb; 534 | 535 | // Count by mime type 536 | const mimeType = resource.mimeType || ""; 537 | if (mimeType.includes("javascript") || resource.url.endsWith(".js")) { 538 | jsCount++; 539 | } else if (mimeType.includes("css") || resource.url.endsWith(".css")) { 540 | cssCount++; 541 | } else if ( 542 | mimeType.includes("image") || 543 | /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(resource.url) 544 | ) { 545 | imgCount++; 546 | } else if ( 547 | mimeType.includes("font") || 548 | /\.(woff|woff2|ttf|otf|eot)$/i.test(resource.url) 549 | ) { 550 | fontCount++; 551 | } else { 552 | otherCount++; 553 | } 554 | }); 555 | 556 | // Calculate third-party size 557 | let thirdPartySizeKb = 0; 558 | if (thirdPartyAudit && thirdPartyAudit.details) { 559 | const thirdPartyDetails = thirdPartyAudit.details as any; 560 | if (thirdPartyDetails.items && Array.isArray(thirdPartyDetails.items)) { 561 | thirdPartyDetails.items.forEach((item: any) => { 562 | if (item.transferSize) { 563 | thirdPartySizeKb += Math.round(item.transferSize / 1024); 564 | } 565 | }); 566 | } 567 | } 568 | 569 | // Get main thread blocking time 570 | let mainThreadBlockingTimeMs = 0; 571 | if (mainThreadWork && mainThreadWork.numericValue) { 572 | mainThreadBlockingTimeMs = Math.round(mainThreadWork.numericValue); 573 | } 574 | 575 | // Create page stats object 576 | page_stats = { 577 | total_size_kb: totalSizeKb, 578 | total_requests: totalRequests, 579 | resource_counts: { 580 | js: jsCount, 581 | css: cssCount, 582 | img: imgCount, 583 | font: fontCount, 584 | other: otherCount, 585 | }, 586 | third_party_size_kb: thirdPartySizeKb, 587 | main_thread_blocking_time_ms: mainThreadBlockingTimeMs, 588 | }; 589 | } 590 | } 591 | 592 | // Generate prioritized recommendations 593 | const prioritized_recommendations: string[] = []; 594 | 595 | // Add key recommendations based on failed audits with high impact 596 | if ( 597 | audits["render-blocking-resources"] && 598 | audits["render-blocking-resources"].score !== null && 599 | audits["render-blocking-resources"].score === 0 600 | ) { 601 | prioritized_recommendations.push("Eliminate render-blocking resources"); 602 | } 603 | 604 | if ( 605 | audits["uses-responsive-images"] && 606 | audits["uses-responsive-images"].score !== null && 607 | audits["uses-responsive-images"].score === 0 608 | ) { 609 | prioritized_recommendations.push("Properly size images"); 610 | } 611 | 612 | if ( 613 | audits["uses-optimized-images"] && 614 | audits["uses-optimized-images"].score !== null && 615 | audits["uses-optimized-images"].score === 0 616 | ) { 617 | prioritized_recommendations.push("Efficiently encode images"); 618 | } 619 | 620 | if ( 621 | audits["uses-text-compression"] && 622 | audits["uses-text-compression"].score !== null && 623 | audits["uses-text-compression"].score === 0 624 | ) { 625 | prioritized_recommendations.push("Enable text compression"); 626 | } 627 | 628 | if ( 629 | audits["uses-http2"] && 630 | audits["uses-http2"].score !== null && 631 | audits["uses-http2"].score === 0 632 | ) { 633 | prioritized_recommendations.push("Use HTTP/2"); 634 | } 635 | 636 | // Add more specific recommendations based on Core Web Vitals 637 | if ( 638 | audits["largest-contentful-paint"] && 639 | audits["largest-contentful-paint"].score !== null && 640 | audits["largest-contentful-paint"].score < 0.5 641 | ) { 642 | prioritized_recommendations.push("Improve Largest Contentful Paint (LCP)"); 643 | } 644 | 645 | if ( 646 | audits["cumulative-layout-shift"] && 647 | audits["cumulative-layout-shift"].score !== null && 648 | audits["cumulative-layout-shift"].score < 0.5 649 | ) { 650 | prioritized_recommendations.push("Reduce layout shifts (CLS)"); 651 | } 652 | 653 | if ( 654 | audits["total-blocking-time"] && 655 | audits["total-blocking-time"].score !== null && 656 | audits["total-blocking-time"].score < 0.5 657 | ) { 658 | prioritized_recommendations.push("Reduce JavaScript execution time"); 659 | } 660 | 661 | // Create the performance report content 662 | const reportContent: PerformanceReportContent = { 663 | score, 664 | audit_counts, 665 | metrics, 666 | opportunities, 667 | page_stats, 668 | prioritized_recommendations: 669 | prioritized_recommendations.length > 0 670 | ? prioritized_recommendations 671 | : undefined, 672 | }; 673 | 674 | // Return the full report following the LighthouseReport interface 675 | return { 676 | metadata, 677 | report: reportContent, 678 | }; 679 | }; 680 | -------------------------------------------------------------------------------- /browser-tools-server/lighthouse/seo.ts: -------------------------------------------------------------------------------- 1 | import { Result as LighthouseResult } from "lighthouse"; 2 | import { AuditCategory, LighthouseReport } from "./types.js"; 3 | import { runLighthouseAudit } from "./index.js"; 4 | 5 | // === SEO Report Types === 6 | 7 | /** 8 | * SEO-specific report content structure 9 | */ 10 | export interface SEOReportContent { 11 | score: number; // Overall score (0-100) 12 | audit_counts: { 13 | // Counts of different audit types 14 | failed: number; 15 | passed: number; 16 | manual: number; 17 | informative: number; 18 | not_applicable: number; 19 | }; 20 | issues: AISEOIssue[]; 21 | categories: { 22 | [category: string]: { 23 | score: number; 24 | issues_count: number; 25 | }; 26 | }; 27 | prioritized_recommendations?: string[]; // Ordered list of recommendations 28 | } 29 | 30 | /** 31 | * Full SEO report implementing the base LighthouseReport interface 32 | */ 33 | export type AIOptimizedSEOReport = LighthouseReport; 34 | 35 | /** 36 | * AI-optimized SEO issue 37 | */ 38 | interface AISEOIssue { 39 | id: string; // e.g., "meta-description" 40 | title: string; // e.g., "Document has a meta description" 41 | impact: "critical" | "serious" | "moderate" | "minor"; 42 | category: string; // e.g., "content", "mobile", "crawlability" 43 | details?: { 44 | selector?: string; // CSS selector if applicable 45 | value?: string; // Current value 46 | issue?: string; // Description of the issue 47 | }[]; 48 | score: number | null; // 0-1 or null 49 | } 50 | 51 | // Original interfaces for backward compatibility 52 | interface SEOAudit { 53 | id: string; // e.g., "meta-description" 54 | title: string; // e.g., "Document has a meta description" 55 | description: string; // e.g., "Meta descriptions improve SEO..." 56 | score: number | null; // 0-1 or null 57 | scoreDisplayMode: string; // e.g., "binary" 58 | details?: SEOAuditDetails; // Optional, structured details 59 | weight?: number; // For prioritization 60 | } 61 | 62 | interface SEOAuditDetails { 63 | items?: Array<{ 64 | selector?: string; // e.g., "meta[name='description']" 65 | issue?: string; // e.g., "Meta description is missing" 66 | value?: string; // e.g., Current meta description text 67 | }>; 68 | type?: string; // e.g., "table" 69 | } 70 | 71 | // This ensures we always include critical issues while limiting less important ones 72 | const DETAIL_LIMITS = { 73 | critical: Number.MAX_SAFE_INTEGER, // No limit for critical issues 74 | serious: 15, // Up to 15 items for serious issues 75 | moderate: 10, // Up to 10 items for moderate issues 76 | minor: 3, // Up to 3 items for minor issues 77 | }; 78 | 79 | /** 80 | * Runs an SEO audit on the specified URL 81 | * @param url The URL to audit 82 | * @returns Promise resolving to AI-optimized SEO audit results 83 | */ 84 | export async function runSEOAudit(url: string): Promise { 85 | try { 86 | const lhr = await runLighthouseAudit(url, [AuditCategory.SEO]); 87 | return extractAIOptimizedData(lhr, url); 88 | } catch (error) { 89 | throw new Error( 90 | `SEO audit failed: ${ 91 | error instanceof Error ? error.message : String(error) 92 | }` 93 | ); 94 | } 95 | } 96 | 97 | /** 98 | * Extract AI-optimized SEO data from Lighthouse results 99 | */ 100 | const extractAIOptimizedData = ( 101 | lhr: LighthouseResult, 102 | url: string 103 | ): AIOptimizedSEOReport => { 104 | const categoryData = lhr.categories[AuditCategory.SEO]; 105 | const audits = lhr.audits || {}; 106 | 107 | // Add metadata 108 | const metadata = { 109 | url, 110 | timestamp: lhr.fetchTime || new Date().toISOString(), 111 | device: "desktop", // This could be made configurable 112 | lighthouseVersion: lhr.lighthouseVersion, 113 | }; 114 | 115 | // Initialize variables 116 | const issues: AISEOIssue[] = []; 117 | const categories: { 118 | [category: string]: { score: number; issues_count: number }; 119 | } = { 120 | content: { score: 0, issues_count: 0 }, 121 | mobile: { score: 0, issues_count: 0 }, 122 | crawlability: { score: 0, issues_count: 0 }, 123 | other: { score: 0, issues_count: 0 }, 124 | }; 125 | 126 | // Count audits by type 127 | let failedCount = 0; 128 | let passedCount = 0; 129 | let manualCount = 0; 130 | let informativeCount = 0; 131 | let notApplicableCount = 0; 132 | 133 | // Process audit refs 134 | const auditRefs = categoryData?.auditRefs || []; 135 | 136 | // First pass: count audits by type and initialize categories 137 | auditRefs.forEach((ref) => { 138 | const audit = audits[ref.id]; 139 | if (!audit) return; 140 | 141 | // Count by scoreDisplayMode 142 | if (audit.scoreDisplayMode === "manual") { 143 | manualCount++; 144 | } else if (audit.scoreDisplayMode === "informative") { 145 | informativeCount++; 146 | } else if (audit.scoreDisplayMode === "notApplicable") { 147 | notApplicableCount++; 148 | } else if (audit.score !== null) { 149 | // Binary pass/fail 150 | if (audit.score >= 0.9) { 151 | passedCount++; 152 | } else { 153 | failedCount++; 154 | } 155 | } 156 | 157 | // Categorize the issue 158 | let category = "other"; 159 | if ( 160 | ref.id.includes("crawl") || 161 | ref.id.includes("http") || 162 | ref.id.includes("redirect") || 163 | ref.id.includes("robots") 164 | ) { 165 | category = "crawlability"; 166 | } else if ( 167 | ref.id.includes("viewport") || 168 | ref.id.includes("font-size") || 169 | ref.id.includes("tap-targets") 170 | ) { 171 | category = "mobile"; 172 | } else if ( 173 | ref.id.includes("document") || 174 | ref.id.includes("meta") || 175 | ref.id.includes("description") || 176 | ref.id.includes("canonical") || 177 | ref.id.includes("title") || 178 | ref.id.includes("link") 179 | ) { 180 | category = "content"; 181 | } 182 | 183 | // Update category score and issues count 184 | if (audit.score !== null && audit.score < 0.9) { 185 | categories[category].issues_count++; 186 | } 187 | }); 188 | 189 | // Second pass: process failed audits into AI-friendly format 190 | auditRefs 191 | .filter((ref) => { 192 | const audit = audits[ref.id]; 193 | return audit && audit.score !== null && audit.score < 0.9; 194 | }) 195 | .sort((a, b) => (b.weight || 0) - (a.weight || 0)) 196 | // No limit on failed audits - we'll filter dynamically based on impact 197 | .forEach((ref) => { 198 | const audit = audits[ref.id]; 199 | 200 | // Determine impact level based on score and weight 201 | let impact: "critical" | "serious" | "moderate" | "minor" = "moderate"; 202 | if (audit.score === 0) { 203 | impact = "critical"; 204 | } else if (audit.score !== null && audit.score <= 0.5) { 205 | impact = "serious"; 206 | } else if (audit.score !== null && audit.score > 0.7) { 207 | impact = "minor"; 208 | } 209 | 210 | // Categorize the issue 211 | let category = "other"; 212 | if ( 213 | ref.id.includes("crawl") || 214 | ref.id.includes("http") || 215 | ref.id.includes("redirect") || 216 | ref.id.includes("robots") 217 | ) { 218 | category = "crawlability"; 219 | } else if ( 220 | ref.id.includes("viewport") || 221 | ref.id.includes("font-size") || 222 | ref.id.includes("tap-targets") 223 | ) { 224 | category = "mobile"; 225 | } else if ( 226 | ref.id.includes("document") || 227 | ref.id.includes("meta") || 228 | ref.id.includes("description") || 229 | ref.id.includes("canonical") || 230 | ref.id.includes("title") || 231 | ref.id.includes("link") 232 | ) { 233 | category = "content"; 234 | } 235 | 236 | // Extract details 237 | const details: { selector?: string; value?: string; issue?: string }[] = 238 | []; 239 | 240 | if (audit.details) { 241 | const auditDetails = audit.details as any; 242 | if (auditDetails.items && Array.isArray(auditDetails.items)) { 243 | // Determine item limit based on impact 244 | const itemLimit = DETAIL_LIMITS[impact]; 245 | 246 | auditDetails.items.slice(0, itemLimit).forEach((item: any) => { 247 | const detail: { 248 | selector?: string; 249 | value?: string; 250 | issue?: string; 251 | } = {}; 252 | 253 | if (item.selector) { 254 | detail.selector = item.selector; 255 | } 256 | 257 | if (item.value !== undefined) { 258 | detail.value = item.value; 259 | } 260 | 261 | if (item.issue) { 262 | detail.issue = item.issue; 263 | } 264 | 265 | if (Object.keys(detail).length > 0) { 266 | details.push(detail); 267 | } 268 | }); 269 | } 270 | } 271 | 272 | // Create the issue 273 | const issue: AISEOIssue = { 274 | id: ref.id, 275 | title: audit.title, 276 | impact, 277 | category, 278 | details: details.length > 0 ? details : undefined, 279 | score: audit.score, 280 | }; 281 | 282 | issues.push(issue); 283 | }); 284 | 285 | // Calculate overall score 286 | const score = Math.round((categoryData?.score || 0) * 100); 287 | 288 | // Generate prioritized recommendations 289 | const prioritized_recommendations: string[] = []; 290 | 291 | // Add category-specific recommendations 292 | Object.entries(categories) 293 | .filter(([_, data]) => data.issues_count > 0) 294 | .sort(([_, a], [__, b]) => b.issues_count - a.issues_count) 295 | .forEach(([category, data]) => { 296 | if (data.issues_count === 0) return; 297 | 298 | let recommendation = ""; 299 | 300 | switch (category) { 301 | case "content": 302 | recommendation = `Improve SEO content (${data.issues_count} issues): titles, descriptions, and headers`; 303 | break; 304 | case "mobile": 305 | recommendation = `Optimize for mobile devices (${data.issues_count} issues)`; 306 | break; 307 | case "crawlability": 308 | recommendation = `Fix crawlability issues (${data.issues_count} issues): robots.txt, sitemaps, and redirects`; 309 | break; 310 | default: 311 | recommendation = `Fix ${data.issues_count} SEO issues in category: ${category}`; 312 | } 313 | 314 | prioritized_recommendations.push(recommendation); 315 | }); 316 | 317 | // Add specific high-impact recommendations 318 | if (issues.some((issue) => issue.id === "meta-description")) { 319 | prioritized_recommendations.push( 320 | "Add a meta description to improve click-through rate" 321 | ); 322 | } 323 | 324 | if (issues.some((issue) => issue.id === "document-title")) { 325 | prioritized_recommendations.push( 326 | "Add a descriptive page title with keywords" 327 | ); 328 | } 329 | 330 | if (issues.some((issue) => issue.id === "hreflang")) { 331 | prioritized_recommendations.push( 332 | "Fix hreflang implementation for international SEO" 333 | ); 334 | } 335 | 336 | if (issues.some((issue) => issue.id === "canonical")) { 337 | prioritized_recommendations.push("Implement proper canonical tags"); 338 | } 339 | 340 | // Create the report content 341 | const reportContent: SEOReportContent = { 342 | score, 343 | audit_counts: { 344 | failed: failedCount, 345 | passed: passedCount, 346 | manual: manualCount, 347 | informative: informativeCount, 348 | not_applicable: notApplicableCount, 349 | }, 350 | issues, 351 | categories, 352 | prioritized_recommendations: 353 | prioritized_recommendations.length > 0 354 | ? prioritized_recommendations 355 | : undefined, 356 | }; 357 | 358 | // Return the full report following the LighthouseReport interface 359 | return { 360 | metadata, 361 | report: reportContent, 362 | }; 363 | }; 364 | -------------------------------------------------------------------------------- /browser-tools-server/lighthouse/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Audit categories available in Lighthouse 3 | */ 4 | export enum AuditCategory { 5 | ACCESSIBILITY = "accessibility", 6 | PERFORMANCE = "performance", 7 | SEO = "seo", 8 | BEST_PRACTICES = "best-practices", // Not yet implemented 9 | PWA = "pwa", // Not yet implemented 10 | } 11 | 12 | /** 13 | * Base interface for Lighthouse report metadata 14 | */ 15 | export interface LighthouseReport { 16 | metadata: { 17 | url: string; 18 | timestamp: string; // ISO 8601, e.g., "2025-02-27T14:30:00Z" 19 | device: string; // e.g., "mobile", "desktop" 20 | lighthouseVersion: string; // e.g., "10.4.0" 21 | }; 22 | 23 | // For backward compatibility with existing report formats 24 | overallScore?: number; 25 | failedAuditsCount?: number; 26 | passedAuditsCount?: number; 27 | manualAuditsCount?: number; 28 | informativeAuditsCount?: number; 29 | notApplicableAuditsCount?: number; 30 | failedAudits?: any[]; 31 | 32 | // New format for specialized reports 33 | report?: T; // Generic report data that will be specialized by each audit type 34 | } 35 | 36 | /** 37 | * Configuration options for Lighthouse audits 38 | */ 39 | export interface LighthouseConfig { 40 | flags: { 41 | output: string[]; 42 | onlyCategories: string[]; 43 | formFactor: string; 44 | port: number | undefined; 45 | screenEmulation: { 46 | mobile: boolean; 47 | width: number; 48 | height: number; 49 | deviceScaleFactor: number; 50 | disabled: boolean; 51 | }; 52 | }; 53 | config: { 54 | extends: string; 55 | settings: { 56 | onlyCategories: string[]; 57 | emulatedFormFactor: string; 58 | throttling: { 59 | cpuSlowdownMultiplier: number; 60 | }; 61 | }; 62 | }; 63 | } 64 | -------------------------------------------------------------------------------- /browser-tools-server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@agentdeskai/browser-tools-server", 3 | "version": "1.2.0", 4 | "description": "A browser tools server for capturing and managing browser events, logs, and screenshots", 5 | "type": "module", 6 | "main": "dist/browser-connector.js", 7 | "bin": { 8 | "browser-tools-server": "./dist/browser-connector.js" 9 | }, 10 | "scripts": { 11 | "build": "tsc", 12 | "start": "tsc && node dist/browser-connector.js", 13 | "prepublishOnly": "npm run build" 14 | }, 15 | "keywords": [ 16 | "browser", 17 | "tools", 18 | "debugging", 19 | "logging", 20 | "screenshots", 21 | "chrome", 22 | "extension" 23 | ], 24 | "author": "AgentDesk AI", 25 | "license": "MIT", 26 | "dependencies": { 27 | "@modelcontextprotocol/sdk": "^1.4.1", 28 | "body-parser": "^1.20.3", 29 | "cors": "^2.8.5", 30 | "express": "^4.21.2", 31 | "lighthouse": "^11.6.0", 32 | "llm-cost": "^1.0.5", 33 | "node-fetch": "^2.7.0", 34 | "puppeteer-core": "^22.4.1", 35 | "ws": "^8.18.0" 36 | }, 37 | "optionalDependencies": { 38 | "chrome-launcher": "^1.1.2" 39 | }, 40 | "devDependencies": { 41 | "@types/ws": "^8.5.14", 42 | "@types/body-parser": "^1.19.5", 43 | "@types/cors": "^2.8.17", 44 | "@types/express": "^5.0.0", 45 | "@types/node": "^22.13.1", 46 | "@types/node-fetch": "^2.6.11", 47 | "@types/puppeteer-core": "^7.0.4", 48 | "typescript": "^5.7.3" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /browser-tools-server/puppeteer-service.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import puppeteer from "puppeteer-core"; 3 | import path from "path"; 4 | import os from "os"; 5 | import { execSync } from "child_process"; 6 | import * as ChromeLauncher from "chrome-launcher"; 7 | // ===== Configuration Types and Defaults ===== 8 | 9 | /** 10 | * Configuration interface for the Puppeteer service 11 | */ 12 | export interface PuppeteerServiceConfig { 13 | // Browser preferences 14 | preferredBrowsers?: string[]; // Order of browser preference ("chrome", "edge", "brave", "firefox") 15 | customBrowserPaths?: { [key: string]: string }; // Custom browser executable paths 16 | 17 | // Connection settings 18 | debugPorts?: number[]; // Ports to try when connecting to existing browsers 19 | connectionTimeout?: number; // Timeout for connection attempts in ms 20 | maxRetries?: number; // Maximum number of retries for connections 21 | 22 | // Browser cleanup settings 23 | browserCleanupTimeout?: number; // Timeout before closing inactive browsers (ms) 24 | 25 | // Performance settings 26 | blockResourceTypes?: string[]; // Resource types to block for performance 27 | } 28 | 29 | // Default configuration values 30 | const DEFAULT_CONFIG: PuppeteerServiceConfig = { 31 | preferredBrowsers: ["chrome", "edge", "brave", "firefox"], 32 | debugPorts: [9222, 9223, 9224, 9225], 33 | connectionTimeout: 10000, 34 | maxRetries: 3, 35 | browserCleanupTimeout: 60000, 36 | blockResourceTypes: ["image", "font", "media"], 37 | }; 38 | 39 | // Browser support notes: 40 | // - Chrome/Chromium: Fully supported (primary target) 41 | // - Edge: Fully supported (Chromium-based) 42 | // - Brave: Fully supported (Chromium-based) 43 | // - Firefox: Partially supported (some features may not work) 44 | // - Safari: Not supported by Puppeteer 45 | 46 | // ===== Global State ===== 47 | 48 | // Current active configuration 49 | let currentConfig: PuppeteerServiceConfig = { ...DEFAULT_CONFIG }; 50 | 51 | // Browser instance management 52 | let headlessBrowserInstance: puppeteer.Browser | null = null; 53 | let launchedBrowserWSEndpoint: string | null = null; 54 | 55 | // Cleanup management 56 | let browserCleanupTimeout: NodeJS.Timeout | null = null; 57 | let BROWSER_CLEANUP_TIMEOUT = 60000; // 60 seconds default 58 | 59 | // Cache for browser executable paths 60 | let detectedBrowserPath: string | null = null; 61 | 62 | // ===== Configuration Functions ===== 63 | 64 | /** 65 | * Configure the Puppeteer service with custom settings 66 | * @param config Partial configuration to override defaults 67 | */ 68 | export function configurePuppeteerService( 69 | config: Partial 70 | ): void { 71 | currentConfig = { ...DEFAULT_CONFIG, ...config }; 72 | 73 | // Update the timeout if it was changed 74 | if ( 75 | config.browserCleanupTimeout && 76 | config.browserCleanupTimeout !== BROWSER_CLEANUP_TIMEOUT 77 | ) { 78 | BROWSER_CLEANUP_TIMEOUT = config.browserCleanupTimeout; 79 | } 80 | 81 | console.log("Puppeteer service configured:", currentConfig); 82 | } 83 | 84 | // ===== Browser Management ===== 85 | 86 | /** 87 | * Get or create a headless browser instance 88 | * @returns Promise resolving to a browser instance 89 | */ 90 | async function getHeadlessBrowserInstance(): Promise { 91 | console.log("Browser instance request started"); 92 | 93 | // Cancel any scheduled cleanup 94 | cancelScheduledCleanup(); 95 | 96 | // Try to reuse existing browser 97 | if (headlessBrowserInstance) { 98 | try { 99 | const pages = await headlessBrowserInstance.pages(); 100 | console.log( 101 | `Reusing existing headless browser with ${pages.length} pages` 102 | ); 103 | return headlessBrowserInstance; 104 | } catch (error) { 105 | console.log( 106 | "Existing browser instance is no longer valid, creating a new one" 107 | ); 108 | headlessBrowserInstance = null; 109 | launchedBrowserWSEndpoint = null; 110 | } 111 | } 112 | 113 | // Create a new browser instance 114 | return launchNewBrowser(); 115 | } 116 | 117 | /** 118 | * Launches a new browser instance 119 | * @returns Promise resolving to a browser instance 120 | */ 121 | async function launchNewBrowser(): Promise { 122 | console.log("Creating new headless browser instance"); 123 | 124 | // Setup temporary user data directory 125 | const userDataDir = createTempUserDataDir(); 126 | let browser: puppeteer.Browser | null = null; 127 | 128 | try { 129 | // Configure launch options 130 | const launchOptions = configureLaunchOptions(userDataDir); 131 | 132 | // Set custom browser executable 133 | await setCustomBrowserExecutable(launchOptions); 134 | 135 | // Launch the browser 136 | console.log( 137 | "Launching browser with options:", 138 | JSON.stringify({ 139 | headless: launchOptions.headless, 140 | executablePath: launchOptions.executablePath, 141 | }) 142 | ); 143 | 144 | browser = await puppeteer.launch(launchOptions); 145 | 146 | // Store references to the browser instance 147 | launchedBrowserWSEndpoint = browser.wsEndpoint(); 148 | headlessBrowserInstance = browser; 149 | 150 | // Setup cleanup handlers 151 | setupBrowserCleanupHandlers(browser, userDataDir); 152 | 153 | console.log("Browser ready"); 154 | return browser; 155 | } catch (error) { 156 | console.error("Failed to launch browser:", error); 157 | 158 | // Clean up resources 159 | if (browser) { 160 | try { 161 | await browser.close(); 162 | } catch (closeError) { 163 | console.error("Error closing browser:", closeError); 164 | } 165 | headlessBrowserInstance = null; 166 | launchedBrowserWSEndpoint = null; 167 | } 168 | 169 | // Clean up the temporary directory 170 | try { 171 | fs.rmSync(userDataDir, { recursive: true, force: true }); 172 | } catch (fsError) { 173 | console.error("Error removing temporary directory:", fsError); 174 | } 175 | 176 | throw error; 177 | } 178 | } 179 | 180 | /** 181 | * Creates a temporary user data directory for the browser 182 | * @returns Path to the created directory 183 | */ 184 | function createTempUserDataDir(): string { 185 | const tempDir = os.tmpdir(); 186 | const uniqueId = `${Date.now().toString()}-${Math.random() 187 | .toString(36) 188 | .substring(2)}`; 189 | const userDataDir = path.join(tempDir, `browser-debug-profile-${uniqueId}`); 190 | fs.mkdirSync(userDataDir, { recursive: true }); 191 | console.log(`Using temporary user data directory: ${userDataDir}`); 192 | return userDataDir; 193 | } 194 | 195 | /** 196 | * Configures browser launch options 197 | * @param userDataDir Path to the user data directory 198 | * @returns Launch options object 199 | */ 200 | function configureLaunchOptions(userDataDir: string): any { 201 | const launchOptions: any = { 202 | args: [ 203 | "--remote-debugging-port=0", // Use dynamic port 204 | `--user-data-dir=${userDataDir}`, 205 | "--no-first-run", 206 | "--no-default-browser-check", 207 | "--disable-dev-shm-usage", 208 | "--disable-extensions", 209 | "--disable-component-extensions-with-background-pages", 210 | "--disable-background-networking", 211 | "--disable-backgrounding-occluded-windows", 212 | "--disable-default-apps", 213 | "--disable-sync", 214 | "--disable-translate", 215 | "--metrics-recording-only", 216 | "--no-pings", 217 | "--safebrowsing-disable-auto-update", 218 | ], 219 | }; 220 | 221 | // Add headless mode (using any to bypass type checking issues) 222 | launchOptions.headless = "new"; 223 | 224 | return launchOptions; 225 | } 226 | 227 | /** 228 | * Sets a custom browser executable path if configured 229 | * @param launchOptions Launch options object to modify 230 | */ 231 | async function setCustomBrowserExecutable(launchOptions: any): Promise { 232 | // First, try to use a custom browser path from configuration 233 | if ( 234 | currentConfig.customBrowserPaths && 235 | Object.keys(currentConfig.customBrowserPaths).length > 0 236 | ) { 237 | const preferredBrowsers = currentConfig.preferredBrowsers || [ 238 | "chrome", 239 | "edge", 240 | "brave", 241 | "firefox", 242 | ]; 243 | 244 | for (const browser of preferredBrowsers) { 245 | if ( 246 | currentConfig.customBrowserPaths[browser] && 247 | fs.existsSync(currentConfig.customBrowserPaths[browser]) 248 | ) { 249 | launchOptions.executablePath = 250 | currentConfig.customBrowserPaths[browser]; 251 | 252 | // Set product to firefox if using Firefox browser 253 | if (browser === "firefox") { 254 | launchOptions.product = "firefox"; 255 | } 256 | 257 | console.log( 258 | `Using custom ${browser} path: ${launchOptions.executablePath}` 259 | ); 260 | return; 261 | } 262 | } 263 | } 264 | 265 | // If no custom path is found, use cached path or detect a new one 266 | try { 267 | if (detectedBrowserPath && fs.existsSync(detectedBrowserPath)) { 268 | console.log(`Using cached browser path: ${detectedBrowserPath}`); 269 | launchOptions.executablePath = detectedBrowserPath; 270 | 271 | // Check if the detected browser is Firefox 272 | if (detectedBrowserPath.includes("firefox")) { 273 | launchOptions.product = "firefox"; 274 | console.log("Setting product to firefox for Firefox browser"); 275 | } 276 | } else { 277 | detectedBrowserPath = await findBrowserExecutablePath(); 278 | launchOptions.executablePath = detectedBrowserPath; 279 | 280 | // Check if the detected browser is Firefox 281 | if (detectedBrowserPath.includes("firefox")) { 282 | launchOptions.product = "firefox"; 283 | console.log("Setting product to firefox for Firefox browser"); 284 | } 285 | 286 | console.log( 287 | `Using detected browser path: ${launchOptions.executablePath}` 288 | ); 289 | } 290 | } catch (error) { 291 | console.error("Failed to detect browser executable path:", error); 292 | throw new Error( 293 | "No browser executable path found. Please specify a custom browser path in the configuration." 294 | ); 295 | } 296 | } 297 | 298 | /** 299 | * Find a browser executable path on the current system 300 | * @returns Path to a browser executable 301 | */ 302 | async function findBrowserExecutablePath(): Promise { 303 | // Try to use chrome-launcher (most reliable method) 304 | try { 305 | console.log("Attempting to find Chrome using chrome-launcher..."); 306 | 307 | // Launch Chrome using chrome-launcher 308 | const chrome = await ChromeLauncher.launch({ 309 | chromeFlags: ["--headless"], 310 | handleSIGINT: false, 311 | }); 312 | 313 | // chrome-launcher stores the Chrome executable path differently than Puppeteer 314 | // Let's try different approaches to get it 315 | 316 | // First check if we can access it directly 317 | let chromePath = ""; 318 | 319 | // Chrome version data often contains the path 320 | if (chrome.process && chrome.process.spawnfile) { 321 | chromePath = chrome.process.spawnfile; 322 | console.log("Found Chrome path from process.spawnfile"); 323 | } else { 324 | // Try to get the Chrome path from chrome-launcher 325 | // In newer versions, it's directly accessible 326 | console.log("Trying to determine Chrome path using other methods"); 327 | 328 | // This will actually return the real Chrome path for us 329 | // chrome-launcher has this inside but doesn't expose it directly 330 | const possiblePaths = [ 331 | process.env.CHROME_PATH, 332 | // Common paths by OS 333 | ...(process.platform === "darwin" 334 | ? ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"] 335 | : process.platform === "win32" 336 | ? [ 337 | `${process.env.PROGRAMFILES}\\Google\\Chrome\\Application\\chrome.exe`, 338 | `${process.env["PROGRAMFILES(X86)"]}\\Google\\Chrome\\Application\\chrome.exe`, 339 | ] 340 | : ["/usr/bin/google-chrome"]), 341 | ].filter(Boolean); 342 | 343 | // Use the first valid path 344 | for (const p of possiblePaths) { 345 | if (p && fs.existsSync(p)) { 346 | chromePath = p; 347 | console.log("Found Chrome path from common locations"); 348 | break; 349 | } 350 | } 351 | } 352 | 353 | // Always kill the Chrome instance we just launched 354 | await chrome.kill(); 355 | 356 | if (chromePath) { 357 | console.log(`Chrome found via chrome-launcher: ${chromePath}`); 358 | return chromePath; 359 | } else { 360 | console.log("Chrome launched but couldn't determine executable path"); 361 | } 362 | } catch (error) { 363 | // Check if it's a ChromeNotInstalledError 364 | const errorMessage = error instanceof Error ? error.message : String(error); 365 | if ( 366 | errorMessage.includes("No Chrome installations found") || 367 | (error as any)?.code === "ERR_LAUNCHER_NOT_INSTALLED" 368 | ) { 369 | console.log("Chrome not installed. Falling back to manual detection"); 370 | } else { 371 | console.error("Failed to find Chrome using chrome-launcher:", error); 372 | console.log("Falling back to manual detection"); 373 | } 374 | } 375 | 376 | // If chrome-launcher failed, use manual detection 377 | 378 | const platform = process.platform; 379 | const preferredBrowsers = currentConfig.preferredBrowsers || [ 380 | "chrome", 381 | "edge", 382 | "brave", 383 | "firefox", 384 | ]; 385 | 386 | console.log(`Attempting to detect browser executable path on ${platform}...`); 387 | 388 | // Platform-specific detection strategies 389 | if (platform === "win32") { 390 | // Windows - try registry detection for Chrome 391 | let registryPath = null; 392 | try { 393 | console.log("Checking Windows registry for Chrome..."); 394 | // Try HKLM first 395 | const regOutput = execSync( 396 | 'reg query "HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe" /ve', 397 | { encoding: "utf8" } 398 | ); 399 | 400 | // Extract path from registry output 401 | const match = regOutput.match(/REG_(?:SZ|EXPAND_SZ)\s+([^\s]+)/i); 402 | if (match && match[1]) { 403 | registryPath = match[1].replace(/\\"/g, ""); 404 | // Verify the path exists 405 | if (fs.existsSync(registryPath)) { 406 | console.log(`Found Chrome via HKLM registry: ${registryPath}`); 407 | return registryPath; 408 | } 409 | } 410 | } catch (e) { 411 | // Try HKCU if HKLM fails 412 | try { 413 | console.log("Checking user registry for Chrome..."); 414 | const regOutput = execSync( 415 | 'reg query "HKEY_CURRENT_USER\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe" /ve', 416 | { encoding: "utf8" } 417 | ); 418 | 419 | // Extract path from registry output 420 | const match = regOutput.match(/REG_(?:SZ|EXPAND_SZ)\s+([^\s]+)/i); 421 | if (match && match[1]) { 422 | registryPath = match[1].replace(/\\"/g, ""); 423 | // Verify the path exists 424 | if (fs.existsSync(registryPath)) { 425 | console.log(`Found Chrome via HKCU registry: ${registryPath}`); 426 | return registryPath; 427 | } 428 | } 429 | } catch (innerError) { 430 | console.log( 431 | "Failed to find Chrome via registry, continuing with path checks" 432 | ); 433 | } 434 | } 435 | 436 | // Try to find Chrome through BLBeacon registry key (version info) 437 | try { 438 | console.log("Checking Chrome BLBeacon registry..."); 439 | const regOutput = execSync( 440 | 'reg query "HKEY_CURRENT_USER\\Software\\Google\\Chrome\\BLBeacon" /v version', 441 | { encoding: "utf8" } 442 | ); 443 | 444 | if (regOutput) { 445 | // If BLBeacon exists, Chrome is likely installed in the default location 446 | const programFiles = process.env.PROGRAMFILES || "C:\\Program Files"; 447 | const programFilesX86 = 448 | process.env["PROGRAMFILES(X86)"] || "C:\\Program Files (x86)"; 449 | 450 | const defaultChromePaths = [ 451 | path.join(programFiles, "Google\\Chrome\\Application\\chrome.exe"), 452 | path.join(programFilesX86, "Google\\Chrome\\Application\\chrome.exe"), 453 | ]; 454 | 455 | for (const chromePath of defaultChromePaths) { 456 | if (fs.existsSync(chromePath)) { 457 | console.log( 458 | `Found Chrome via BLBeacon registry hint: ${chromePath}` 459 | ); 460 | return chromePath; 461 | } 462 | } 463 | } 464 | } catch (e) { 465 | console.log("Failed to find Chrome via BLBeacon registry"); 466 | } 467 | 468 | // Continue with regular path checks 469 | const programFiles = process.env.PROGRAMFILES || "C:\\Program Files"; 470 | const programFilesX86 = 471 | process.env["PROGRAMFILES(X86)"] || "C:\\Program Files (x86)"; 472 | 473 | // Common Windows browser paths 474 | const winBrowserPaths = { 475 | chrome: [ 476 | path.join(programFiles, "Google\\Chrome\\Application\\chrome.exe"), 477 | path.join(programFilesX86, "Google\\Chrome\\Application\\chrome.exe"), 478 | ], 479 | edge: [ 480 | path.join(programFiles, "Microsoft\\Edge\\Application\\msedge.exe"), 481 | path.join(programFilesX86, "Microsoft\\Edge\\Application\\msedge.exe"), 482 | ], 483 | brave: [ 484 | path.join( 485 | programFiles, 486 | "BraveSoftware\\Brave-Browser\\Application\\brave.exe" 487 | ), 488 | path.join( 489 | programFilesX86, 490 | "BraveSoftware\\Brave-Browser\\Application\\brave.exe" 491 | ), 492 | ], 493 | firefox: [ 494 | path.join(programFiles, "Mozilla Firefox\\firefox.exe"), 495 | path.join(programFilesX86, "Mozilla Firefox\\firefox.exe"), 496 | ], 497 | }; 498 | 499 | // Check each browser in preferred order 500 | for (const browser of preferredBrowsers) { 501 | const paths = 502 | winBrowserPaths[browser as keyof typeof winBrowserPaths] || []; 503 | for (const browserPath of paths) { 504 | if (fs.existsSync(browserPath)) { 505 | console.log(`Found ${browser} at ${browserPath}`); 506 | return browserPath; 507 | } 508 | } 509 | } 510 | } else if (platform === "darwin") { 511 | // macOS browser paths 512 | const macBrowserPaths = { 513 | chrome: ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"], 514 | edge: ["/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"], 515 | brave: ["/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"], 516 | firefox: ["/Applications/Firefox.app/Contents/MacOS/firefox"], 517 | safari: ["/Applications/Safari.app/Contents/MacOS/Safari"], 518 | }; 519 | 520 | // Check each browser in preferred order 521 | for (const browser of preferredBrowsers) { 522 | const paths = 523 | macBrowserPaths[browser as keyof typeof macBrowserPaths] || []; 524 | for (const browserPath of paths) { 525 | if (fs.existsSync(browserPath)) { 526 | console.log(`Found ${browser} at ${browserPath}`); 527 | // Safari is detected but not supported by Puppeteer 528 | if (browser === "safari") { 529 | console.log( 530 | "Safari detected but not supported by Puppeteer. Continuing search..." 531 | ); 532 | continue; 533 | } 534 | return browserPath; 535 | } 536 | } 537 | } 538 | } else if (platform === "linux") { 539 | // Linux browser commands 540 | const linuxBrowserCommands = { 541 | chrome: ["google-chrome", "chromium", "chromium-browser"], 542 | edge: ["microsoft-edge"], 543 | brave: ["brave-browser"], 544 | firefox: ["firefox"], 545 | }; 546 | 547 | // Check each browser in preferred order 548 | for (const browser of preferredBrowsers) { 549 | const commands = 550 | linuxBrowserCommands[browser as keyof typeof linuxBrowserCommands] || 551 | []; 552 | for (const cmd of commands) { 553 | try { 554 | // Use more universal commands for Linux to find executables 555 | // command -v works in most shells, fallback to which or type 556 | const browserPath = execSync( 557 | `command -v ${cmd} || which ${cmd} || type -p ${cmd} 2>/dev/null`, 558 | { encoding: "utf8" } 559 | ).trim(); 560 | 561 | if (browserPath && fs.existsSync(browserPath)) { 562 | console.log(`Found ${browser} at ${browserPath}`); 563 | return browserPath; 564 | } 565 | } catch (e) { 566 | // Command not found, continue to next 567 | } 568 | } 569 | } 570 | 571 | // Additional check for unusual locations on Linux 572 | const alternativeLocations = [ 573 | "/usr/bin/google-chrome", 574 | "/usr/bin/chromium", 575 | "/usr/bin/chromium-browser", 576 | "/snap/bin/chromium", 577 | "/snap/bin/google-chrome", 578 | "/opt/google/chrome/chrome", 579 | ]; 580 | 581 | for (const location of alternativeLocations) { 582 | if (fs.existsSync(location)) { 583 | console.log(`Found browser at alternative location: ${location}`); 584 | return location; 585 | } 586 | } 587 | } 588 | 589 | throw new Error( 590 | `No browser executable found for platform ${platform}. Please specify a custom browser path.` 591 | ); 592 | } 593 | 594 | /** 595 | * Sets up cleanup handlers for the browser instance 596 | * @param browser Browser instance 597 | * @param userDataDir Path to the user data directory to clean up 598 | */ 599 | function setupBrowserCleanupHandlers( 600 | browser: puppeteer.Browser, 601 | userDataDir: string 602 | ): void { 603 | browser.on("disconnected", () => { 604 | console.log(`Browser disconnected. Scheduling cleanup for: ${userDataDir}`); 605 | 606 | // Clear any existing cleanup timeout when browser is disconnected 607 | cancelScheduledCleanup(); 608 | 609 | // Delayed cleanup to avoid conflicts with potential new browser instances 610 | setTimeout(() => { 611 | // Only remove the directory if no new browser has been launched 612 | if (!headlessBrowserInstance) { 613 | console.log(`Cleaning up temporary directory: ${userDataDir}`); 614 | try { 615 | fs.rmSync(userDataDir, { recursive: true, force: true }); 616 | console.log(`Successfully removed directory: ${userDataDir}`); 617 | } catch (error) { 618 | console.error(`Failed to remove directory ${userDataDir}:`, error); 619 | } 620 | } else { 621 | console.log( 622 | `Skipping cleanup for ${userDataDir} as new browser instance is active` 623 | ); 624 | } 625 | }, 5000); // 5-second delay for cleanup 626 | 627 | // Reset browser instance variables 628 | launchedBrowserWSEndpoint = null; 629 | headlessBrowserInstance = null; 630 | }); 631 | } 632 | 633 | // ===== Cleanup Management ===== 634 | 635 | /** 636 | * Cancels any scheduled browser cleanup 637 | */ 638 | function cancelScheduledCleanup(): void { 639 | if (browserCleanupTimeout) { 640 | console.log("Cancelling scheduled browser cleanup"); 641 | clearTimeout(browserCleanupTimeout); 642 | browserCleanupTimeout = null; 643 | } 644 | } 645 | 646 | /** 647 | * Schedules automatic cleanup of the browser instance after inactivity 648 | */ 649 | export function scheduleBrowserCleanup(): void { 650 | // Clear any existing timeout first 651 | cancelScheduledCleanup(); 652 | 653 | // Only schedule cleanup if we have an active browser instance 654 | if (headlessBrowserInstance) { 655 | console.log( 656 | `Scheduling browser cleanup in ${BROWSER_CLEANUP_TIMEOUT / 1000} seconds` 657 | ); 658 | 659 | browserCleanupTimeout = setTimeout(() => { 660 | console.log("Executing scheduled browser cleanup"); 661 | if (headlessBrowserInstance) { 662 | console.log("Closing headless browser instance"); 663 | headlessBrowserInstance.close(); 664 | headlessBrowserInstance = null; 665 | launchedBrowserWSEndpoint = null; 666 | } 667 | browserCleanupTimeout = null; 668 | }, BROWSER_CLEANUP_TIMEOUT); 669 | } 670 | } 671 | 672 | // ===== Public Browser Connection API ===== 673 | 674 | /** 675 | * Connects to a headless browser for web operations 676 | * @param url The URL to navigate to 677 | * @param options Connection and emulation options 678 | * @returns Promise resolving to browser, port, and page objects 679 | */ 680 | export async function connectToHeadlessBrowser( 681 | url: string, 682 | options: { 683 | blockResources?: boolean; 684 | customResourceBlockList?: string[]; 685 | emulateDevice?: "mobile" | "tablet" | "desktop"; 686 | emulateNetworkCondition?: "slow3G" | "fast3G" | "4G" | "offline"; 687 | viewport?: { width: number; height: number }; 688 | locale?: string; 689 | timezoneId?: string; 690 | userAgent?: string; 691 | waitForSelector?: string; 692 | waitForTimeout?: number; 693 | cookies?: Array<{ 694 | name: string; 695 | value: string; 696 | domain?: string; 697 | path?: string; 698 | }>; 699 | headers?: Record; 700 | } = {} 701 | ): Promise<{ 702 | browser: puppeteer.Browser; 703 | port: number; 704 | page: puppeteer.Page; 705 | }> { 706 | console.log( 707 | `Connecting to headless browser for ${url}${ 708 | options.blockResources ? " (blocking non-essential resources)" : "" 709 | }` 710 | ); 711 | 712 | try { 713 | // Validate URL format 714 | try { 715 | new URL(url); 716 | } catch (e) { 717 | throw new Error(`Invalid URL format: ${url}`); 718 | } 719 | 720 | // Get or create a browser instance 721 | const browser = await getHeadlessBrowserInstance(); 722 | 723 | if (!launchedBrowserWSEndpoint) { 724 | throw new Error("Failed to retrieve WebSocket endpoint for browser"); 725 | } 726 | 727 | // Extract port from WebSocket endpoint 728 | const port = parseInt( 729 | launchedBrowserWSEndpoint.split(":")[2].split("/")[0] 730 | ); 731 | 732 | // Always create a new page for each audit to avoid request interception conflicts 733 | console.log("Creating a new page for this audit"); 734 | const page = await browser.newPage(); 735 | 736 | // Set a longer timeout for navigation 737 | const navigationTimeout = 10000; // 10 seconds 738 | page.setDefaultNavigationTimeout(navigationTimeout); 739 | 740 | // Navigate to the URL 741 | console.log(`Navigating to ${url}`); 742 | await page.goto(url, { 743 | waitUntil: "networkidle2", // Wait until there are no more network connections for at least 500ms 744 | timeout: navigationTimeout, 745 | }); 746 | 747 | // Set custom headers if provided 748 | if (options.headers && Object.keys(options.headers).length > 0) { 749 | await page.setExtraHTTPHeaders(options.headers); 750 | console.log("Set custom HTTP headers"); 751 | } 752 | 753 | // Set cookies if provided 754 | if (options.cookies && options.cookies.length > 0) { 755 | const urlObj = new URL(url); 756 | const cookiesWithDomain = options.cookies.map((cookie) => ({ 757 | ...cookie, 758 | domain: cookie.domain || urlObj.hostname, 759 | path: cookie.path || "/", 760 | })); 761 | await page.setCookie(...cookiesWithDomain); 762 | console.log(`Set ${options.cookies.length} cookies`); 763 | } 764 | 765 | // Set custom viewport if specified 766 | if (options.viewport) { 767 | await page.setViewport(options.viewport); 768 | console.log( 769 | `Set viewport to ${options.viewport.width}x${options.viewport.height}` 770 | ); 771 | } else if (options.emulateDevice) { 772 | // Set common device emulation presets 773 | let viewport; 774 | let userAgent = options.userAgent; 775 | 776 | switch (options.emulateDevice) { 777 | case "mobile": 778 | viewport = { 779 | width: 375, 780 | height: 667, 781 | isMobile: true, 782 | hasTouch: true, 783 | }; 784 | userAgent = 785 | userAgent || 786 | "Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X)"; 787 | break; 788 | case "tablet": 789 | viewport = { 790 | width: 768, 791 | height: 1024, 792 | isMobile: true, 793 | hasTouch: true, 794 | }; 795 | userAgent = 796 | userAgent || "Mozilla/5.0 (iPad; CPU OS 13_2_3 like Mac OS X)"; 797 | break; 798 | case "desktop": 799 | default: 800 | viewport = { 801 | width: 1280, 802 | height: 800, 803 | isMobile: false, 804 | hasTouch: false, 805 | }; 806 | break; 807 | } 808 | 809 | await page.setViewport(viewport); 810 | if (userAgent) await page.setUserAgent(userAgent); 811 | 812 | console.log(`Emulating ${options.emulateDevice} device`); 813 | } 814 | 815 | // Set locale and timezone if provided 816 | if (options.locale) { 817 | await page.evaluateOnNewDocument((locale) => { 818 | Object.defineProperty(navigator, "language", { get: () => locale }); 819 | Object.defineProperty(navigator, "languages", { get: () => [locale] }); 820 | }, options.locale); 821 | console.log(`Set locale to ${options.locale}`); 822 | } 823 | 824 | if (options.timezoneId) { 825 | await page.emulateTimezone(options.timezoneId); 826 | console.log(`Set timezone to ${options.timezoneId}`); 827 | } 828 | 829 | // Emulate network conditions if specified 830 | if (options.emulateNetworkCondition) { 831 | // Define network condition types that match puppeteer's expected format 832 | interface PuppeteerNetworkConditions { 833 | offline: boolean; 834 | latency?: number; 835 | download?: number; 836 | upload?: number; 837 | } 838 | 839 | let networkConditions: PuppeteerNetworkConditions; 840 | 841 | switch (options.emulateNetworkCondition) { 842 | case "slow3G": 843 | networkConditions = { 844 | offline: false, 845 | latency: 400, 846 | download: (500 * 1024) / 8, 847 | upload: (500 * 1024) / 8, 848 | }; 849 | break; 850 | case "fast3G": 851 | networkConditions = { 852 | offline: false, 853 | latency: 150, 854 | download: (1.5 * 1024 * 1024) / 8, 855 | upload: (750 * 1024) / 8, 856 | }; 857 | break; 858 | case "4G": 859 | networkConditions = { 860 | offline: false, 861 | latency: 50, 862 | download: (4 * 1024 * 1024) / 8, 863 | upload: (2 * 1024 * 1024) / 8, 864 | }; 865 | break; 866 | case "offline": 867 | networkConditions = { offline: true }; 868 | break; 869 | default: 870 | networkConditions = { offline: false }; 871 | } 872 | 873 | // @ts-ignore - Property might not be in types but is supported 874 | await page.emulateNetworkConditions(networkConditions); 875 | console.log( 876 | `Emulating ${options.emulateNetworkCondition} network conditions` 877 | ); 878 | } 879 | 880 | // Check if we should block resources based on the options 881 | if (options.blockResources) { 882 | const resourceTypesToBlock = options.customResourceBlockList || 883 | currentConfig.blockResourceTypes || ["image", "font", "media"]; 884 | 885 | await page.setRequestInterception(true); 886 | page.on("request", (request) => { 887 | // Block unnecessary resources to speed up loading 888 | const resourceType = request.resourceType(); 889 | if (resourceTypesToBlock.includes(resourceType)) { 890 | request.abort(); 891 | } else { 892 | request.continue(); 893 | } 894 | }); 895 | 896 | console.log( 897 | `Blocking resource types: ${resourceTypesToBlock.join(", ")}` 898 | ); 899 | } 900 | 901 | // Wait for a specific selector if requested 902 | if (options.waitForSelector) { 903 | try { 904 | console.log(`Waiting for selector: ${options.waitForSelector}`); 905 | await page.waitForSelector(options.waitForSelector, { 906 | timeout: options.waitForTimeout || 30000, 907 | }); 908 | } catch (selectorError: any) { 909 | console.warn( 910 | `Failed to find selector "${options.waitForSelector}": ${selectorError.message}` 911 | ); 912 | // Continue anyway, don't fail the whole operation 913 | } 914 | } 915 | 916 | return { browser, port, page }; 917 | } catch (error) { 918 | console.error("Failed to connect to headless browser:", error); 919 | throw new Error( 920 | `Failed to connect to headless browser: ${ 921 | error instanceof Error ? error.message : String(error) 922 | }` 923 | ); 924 | } 925 | } 926 | -------------------------------------------------------------------------------- /browser-tools-server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "esModuleInterop": true, 7 | "outDir": "./dist", 8 | "rootDir": ".", 9 | "strict": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "resolveJsonModule": true 13 | }, 14 | "include": ["**/*.ts"], 15 | "exclude": ["node_modules", "dist"] 16 | } 17 | -------------------------------------------------------------------------------- /chrome-extension/background.js: -------------------------------------------------------------------------------- 1 | // Listen for messages from the devtools panel 2 | chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { 3 | if (message.type === "GET_CURRENT_URL" && message.tabId) { 4 | getCurrentTabUrl(message.tabId) 5 | .then((url) => { 6 | sendResponse({ success: true, url: url }); 7 | }) 8 | .catch((error) => { 9 | sendResponse({ success: false, error: error.message }); 10 | }); 11 | return true; // Required to use sendResponse asynchronously 12 | } 13 | 14 | // Handle explicit request to update the server with the URL 15 | if (message.type === "UPDATE_SERVER_URL" && message.tabId && message.url) { 16 | console.log( 17 | `Background: Received request to update server with URL for tab ${message.tabId}: ${message.url}` 18 | ); 19 | updateServerWithUrl( 20 | message.tabId, 21 | message.url, 22 | message.source || "explicit_update" 23 | ) 24 | .then(() => { 25 | if (sendResponse) sendResponse({ success: true }); 26 | }) 27 | .catch((error) => { 28 | console.error("Background: Error updating server with URL:", error); 29 | if (sendResponse) 30 | sendResponse({ success: false, error: error.message }); 31 | }); 32 | return true; // Required to use sendResponse asynchronously 33 | } 34 | 35 | if (message.type === "CAPTURE_SCREENSHOT" && message.tabId) { 36 | // First get the server settings 37 | chrome.storage.local.get(["browserConnectorSettings"], (result) => { 38 | const settings = result.browserConnectorSettings || { 39 | serverHost: "localhost", 40 | serverPort: 3025, 41 | }; 42 | 43 | // Validate server identity first 44 | validateServerIdentity(settings.serverHost, settings.serverPort) 45 | .then((isValid) => { 46 | if (!isValid) { 47 | console.error( 48 | "Cannot capture screenshot: Not connected to a valid browser tools server" 49 | ); 50 | sendResponse({ 51 | success: false, 52 | error: 53 | "Not connected to a valid browser tools server. Please check your connection settings.", 54 | }); 55 | return; 56 | } 57 | 58 | // Continue with screenshot capture 59 | captureAndSendScreenshot(message, settings, sendResponse); 60 | }) 61 | .catch((error) => { 62 | console.error("Error validating server:", error); 63 | sendResponse({ 64 | success: false, 65 | error: "Failed to validate server identity: " + error.message, 66 | }); 67 | }); 68 | }); 69 | return true; // Required to use sendResponse asynchronously 70 | } 71 | }); 72 | 73 | // Validate server identity 74 | async function validateServerIdentity(host, port) { 75 | try { 76 | const response = await fetch(`http://${host}:${port}/.identity`, { 77 | signal: AbortSignal.timeout(3000), // 3 second timeout 78 | }); 79 | 80 | if (!response.ok) { 81 | console.error(`Invalid server response: ${response.status}`); 82 | return false; 83 | } 84 | 85 | const identity = await response.json(); 86 | 87 | // Validate the server signature 88 | if (identity.signature !== "mcp-browser-connector-24x7") { 89 | console.error("Invalid server signature - not the browser tools server"); 90 | return false; 91 | } 92 | 93 | return true; 94 | } catch (error) { 95 | console.error("Error validating server identity:", error); 96 | return false; 97 | } 98 | } 99 | 100 | // Helper function to process the tab and run the audit 101 | function processTabForAudit(tab, tabId) { 102 | const url = tab.url; 103 | 104 | if (!url) { 105 | console.error(`No URL available for tab ${tabId}`); 106 | return; 107 | } 108 | 109 | // Update our cache and the server with this URL 110 | tabUrls.set(tabId, url); 111 | updateServerWithUrl(tabId, url); 112 | } 113 | 114 | // Track URLs for each tab 115 | const tabUrls = new Map(); 116 | 117 | // Function to get the current URL for a tab 118 | async function getCurrentTabUrl(tabId) { 119 | try { 120 | console.log("Background: Getting URL for tab", tabId); 121 | 122 | // First check if we have it cached 123 | if (tabUrls.has(tabId)) { 124 | const cachedUrl = tabUrls.get(tabId); 125 | console.log("Background: Found cached URL:", cachedUrl); 126 | return cachedUrl; 127 | } 128 | 129 | // Otherwise get it from the tab 130 | try { 131 | const tab = await chrome.tabs.get(tabId); 132 | if (tab && tab.url) { 133 | // Cache the URL 134 | tabUrls.set(tabId, tab.url); 135 | console.log("Background: Got URL from tab:", tab.url); 136 | return tab.url; 137 | } else { 138 | console.log("Background: Tab exists but no URL found"); 139 | } 140 | } catch (tabError) { 141 | console.error("Background: Error getting tab:", tabError); 142 | } 143 | 144 | // If we can't get the tab directly, try querying for active tabs 145 | try { 146 | const tabs = await chrome.tabs.query({ 147 | active: true, 148 | currentWindow: true, 149 | }); 150 | if (tabs && tabs.length > 0 && tabs[0].url) { 151 | const activeUrl = tabs[0].url; 152 | console.log("Background: Got URL from active tab:", activeUrl); 153 | // Cache this URL as well 154 | tabUrls.set(tabId, activeUrl); 155 | return activeUrl; 156 | } 157 | } catch (queryError) { 158 | console.error("Background: Error querying tabs:", queryError); 159 | } 160 | 161 | console.log("Background: Could not find URL for tab", tabId); 162 | return null; 163 | } catch (error) { 164 | console.error("Background: Error getting tab URL:", error); 165 | return null; 166 | } 167 | } 168 | 169 | // Listen for tab updates to detect page refreshes and URL changes 170 | chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { 171 | // Track URL changes 172 | if (changeInfo.url) { 173 | console.log(`URL changed in tab ${tabId} to ${changeInfo.url}`); 174 | tabUrls.set(tabId, changeInfo.url); 175 | 176 | // Send URL update to server if possible 177 | updateServerWithUrl(tabId, changeInfo.url, "tab_url_change"); 178 | } 179 | 180 | // Check if this is a page refresh (status becoming "complete") 181 | if (changeInfo.status === "complete") { 182 | // Update URL in our cache 183 | if (tab.url) { 184 | tabUrls.set(tabId, tab.url); 185 | // Send URL update to server if possible 186 | updateServerWithUrl(tabId, tab.url, "page_complete"); 187 | } 188 | 189 | retestConnectionOnRefresh(tabId); 190 | } 191 | }); 192 | 193 | // Listen for tab activation (switching between tabs) 194 | chrome.tabs.onActivated.addListener((activeInfo) => { 195 | const tabId = activeInfo.tabId; 196 | console.log(`Tab activated: ${tabId}`); 197 | 198 | // Get the URL of the newly activated tab 199 | chrome.tabs.get(tabId, (tab) => { 200 | if (chrome.runtime.lastError) { 201 | console.error("Error getting tab info:", chrome.runtime.lastError); 202 | return; 203 | } 204 | 205 | if (tab && tab.url) { 206 | console.log(`Active tab changed to ${tab.url}`); 207 | 208 | // Update our cache 209 | tabUrls.set(tabId, tab.url); 210 | 211 | // Send URL update to server 212 | updateServerWithUrl(tabId, tab.url, "tab_activated"); 213 | } 214 | }); 215 | }); 216 | 217 | // Function to update the server with the current URL 218 | async function updateServerWithUrl(tabId, url, source = "background_update") { 219 | if (!url) { 220 | console.error("Cannot update server with empty URL"); 221 | return; 222 | } 223 | 224 | console.log(`Updating server with URL for tab ${tabId}: ${url}`); 225 | 226 | // Get the saved settings 227 | chrome.storage.local.get(["browserConnectorSettings"], async (result) => { 228 | const settings = result.browserConnectorSettings || { 229 | serverHost: "localhost", 230 | serverPort: 3025, 231 | }; 232 | 233 | // Maximum number of retry attempts 234 | const maxRetries = 3; 235 | let retryCount = 0; 236 | let success = false; 237 | 238 | while (retryCount < maxRetries && !success) { 239 | try { 240 | // Send the URL to the server 241 | const serverUrl = `http://${settings.serverHost}:${settings.serverPort}/current-url`; 242 | console.log( 243 | `Attempt ${ 244 | retryCount + 1 245 | }/${maxRetries} to update server with URL: ${url}` 246 | ); 247 | 248 | const response = await fetch(serverUrl, { 249 | method: "POST", 250 | headers: { 251 | "Content-Type": "application/json", 252 | }, 253 | body: JSON.stringify({ 254 | url: url, 255 | tabId: tabId, 256 | timestamp: Date.now(), 257 | source: source, 258 | }), 259 | // Add a timeout to prevent hanging requests 260 | signal: AbortSignal.timeout(5000), 261 | }); 262 | 263 | if (response.ok) { 264 | const responseData = await response.json(); 265 | console.log( 266 | `Successfully updated server with URL: ${url}`, 267 | responseData 268 | ); 269 | success = true; 270 | } else { 271 | console.error( 272 | `Server returned error: ${response.status} ${response.statusText}` 273 | ); 274 | retryCount++; 275 | // Wait before retrying 276 | await new Promise((resolve) => setTimeout(resolve, 500)); 277 | } 278 | } catch (error) { 279 | console.error(`Error updating server with URL: ${error.message}`); 280 | retryCount++; 281 | // Wait before retrying 282 | await new Promise((resolve) => setTimeout(resolve, 500)); 283 | } 284 | } 285 | 286 | if (!success) { 287 | console.error( 288 | `Failed to update server with URL after ${maxRetries} attempts` 289 | ); 290 | } 291 | }); 292 | } 293 | 294 | // Clean up when tabs are closed 295 | chrome.tabs.onRemoved.addListener((tabId) => { 296 | tabUrls.delete(tabId); 297 | }); 298 | 299 | // Function to retest connection when a page is refreshed 300 | async function retestConnectionOnRefresh(tabId) { 301 | console.log(`Page refreshed in tab ${tabId}, retesting connection...`); 302 | 303 | // Get the saved settings 304 | chrome.storage.local.get(["browserConnectorSettings"], async (result) => { 305 | const settings = result.browserConnectorSettings || { 306 | serverHost: "localhost", 307 | serverPort: 3025, 308 | }; 309 | 310 | // Test the connection with the last known host and port 311 | const isConnected = await validateServerIdentity( 312 | settings.serverHost, 313 | settings.serverPort 314 | ); 315 | 316 | // Notify all devtools instances about the connection status 317 | chrome.runtime.sendMessage({ 318 | type: "CONNECTION_STATUS_UPDATE", 319 | isConnected: isConnected, 320 | tabId: tabId, 321 | }); 322 | 323 | // Always notify for page refresh, whether connected or not 324 | // This ensures any ongoing discovery is cancelled and restarted 325 | chrome.runtime.sendMessage({ 326 | type: "INITIATE_AUTO_DISCOVERY", 327 | reason: "page_refresh", 328 | tabId: tabId, 329 | forceRestart: true, // Add a flag to indicate this should force restart any ongoing processes 330 | }); 331 | 332 | if (!isConnected) { 333 | console.log( 334 | "Connection test failed after page refresh, initiating auto-discovery..." 335 | ); 336 | } else { 337 | console.log("Connection test successful after page refresh"); 338 | } 339 | }); 340 | } 341 | 342 | // Function to capture and send screenshot 343 | function captureAndSendScreenshot(message, settings, sendResponse) { 344 | // Get the inspected window's tab 345 | chrome.tabs.get(message.tabId, (tab) => { 346 | if (chrome.runtime.lastError) { 347 | console.error("Error getting tab:", chrome.runtime.lastError); 348 | sendResponse({ 349 | success: false, 350 | error: chrome.runtime.lastError.message, 351 | }); 352 | return; 353 | } 354 | 355 | // Get all windows to find the one containing our tab 356 | chrome.windows.getAll({ populate: true }, (windows) => { 357 | const targetWindow = windows.find((w) => 358 | w.tabs.some((t) => t.id === message.tabId) 359 | ); 360 | 361 | if (!targetWindow) { 362 | console.error("Could not find window containing the inspected tab"); 363 | sendResponse({ 364 | success: false, 365 | error: "Could not find window containing the inspected tab", 366 | }); 367 | return; 368 | } 369 | 370 | // Capture screenshot of the window containing our tab 371 | chrome.tabs.captureVisibleTab( 372 | targetWindow.id, 373 | { format: "png" }, 374 | (dataUrl) => { 375 | // Ignore DevTools panel capture error if it occurs 376 | if ( 377 | chrome.runtime.lastError && 378 | !chrome.runtime.lastError.message.includes("devtools://") 379 | ) { 380 | console.error( 381 | "Error capturing screenshot:", 382 | chrome.runtime.lastError 383 | ); 384 | sendResponse({ 385 | success: false, 386 | error: chrome.runtime.lastError.message, 387 | }); 388 | return; 389 | } 390 | 391 | // Send screenshot data to browser connector using configured settings 392 | const serverUrl = `http://${settings.serverHost}:${settings.serverPort}/screenshot`; 393 | console.log(`Sending screenshot to ${serverUrl}`); 394 | 395 | fetch(serverUrl, { 396 | method: "POST", 397 | headers: { 398 | "Content-Type": "application/json", 399 | }, 400 | body: JSON.stringify({ 401 | data: dataUrl, 402 | path: message.screenshotPath, 403 | }), 404 | }) 405 | .then((response) => response.json()) 406 | .then((result) => { 407 | if (result.error) { 408 | console.error("Error from server:", result.error); 409 | sendResponse({ success: false, error: result.error }); 410 | } else { 411 | console.log("Screenshot saved successfully:", result.path); 412 | // Send success response even if DevTools capture failed 413 | sendResponse({ 414 | success: true, 415 | path: result.path, 416 | title: tab.title || "Current Tab", 417 | }); 418 | } 419 | }) 420 | .catch((error) => { 421 | console.error("Error sending screenshot data:", error); 422 | sendResponse({ 423 | success: false, 424 | error: error.message || "Failed to save screenshot", 425 | }); 426 | }); 427 | } 428 | ); 429 | }); 430 | }); 431 | } 432 | -------------------------------------------------------------------------------- /chrome-extension/devtools.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BrowserTools MCP 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /chrome-extension/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "BrowserTools MCP", 3 | "version": "1.2.0", 4 | "description": "MCP tool for AI code editors to capture data from a browser such as console logs, network requests, screenshots and more", 5 | "manifest_version": 3, 6 | "devtools_page": "devtools.html", 7 | "permissions": [ 8 | "activeTab", 9 | "debugger", 10 | "storage", 11 | "tabs", 12 | "tabCapture", 13 | "windows" 14 | ], 15 | "host_permissions": [ 16 | "" 17 | ], 18 | "background": { 19 | "service_worker": "background.js" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /chrome-extension/panel.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 117 | 118 | 119 | 120 | Quick Actions 121 | 122 | 123 | Capture Screenshot 124 | 125 | 126 | Wipe All Logs 127 | 128 | 129 | 130 | 131 | 132 | Allow Auto-paste to Cursor 133 | 134 | 135 | 136 | 137 | 138 | Screenshot Settings 139 | 140 | Provide a directory to save screenshots to (by default screenshots will be saved to your downloads folder if no path is provided) 141 | 142 | 143 | 144 | 145 | 146 | Server Connection Settings 147 | 148 | Server Host 149 | 150 | 151 | 152 | Server Port 153 | 154 | 155 | 156 | 157 | Auto-Discover Server 158 | 159 | 160 | Test Connection 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | Advanced Settings 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | Log Limit (number of logs) 180 | 181 | 182 | 183 | 184 | Query Limit (characters) 185 | 186 | 187 | 188 | 189 | String Size Limit (characters) 190 | 191 | 192 | 193 | 194 | Max Log Size (characters) 195 | 196 | 197 | 198 | 199 | 200 | 201 | Include Request Headers 202 | 203 | 204 | 205 | 206 | 207 | 208 | Include Response Headers 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | -------------------------------------------------------------------------------- /docs/mcp-docs.md: -------------------------------------------------------------------------------- 1 | ## Resources 2 | 3 | Expose data and content from your servers to LLMs 4 | 5 | Resources are a core primitive in the Model Context Protocol (MCP) that allow servers to expose data and content that can be read by clients and used as context for LLM interactions. 6 | 7 | Resources are designed to be application-controlled, meaning that the client application can decide how and when they should be used. Different MCP clients may handle resources differently. For example: 8 | 9 | Claude Desktop currently requires users to explicitly select resources before they can be used 10 | Other clients might automatically select resources based on heuristics 11 | Some implementations may even allow the AI model itself to determine which resources to use 12 | Server authors should be prepared to handle any of these interaction patterns when implementing resource support. In order to expose data to models automatically, server authors should use a model-controlled primitive such as Tools. 13 | 14 | 15 | Overview 16 | Resources represent any kind of data that an MCP server wants to make available to clients. This can include: 17 | 18 | File contents 19 | Database records 20 | API responses 21 | Live system data 22 | Screenshots and images 23 | Log files 24 | And more 25 | Each resource is identified by a unique URI and can contain either text or binary data. 26 | 27 | 28 | Resource URIs 29 | Resources are identified using URIs that follow this format: 30 | 31 | [protocol]://[host]/[path] 32 | For example: 33 | 34 | file:///home/user/documents/report.pdf 35 | postgres://database/customers/schema 36 | screen://localhost/display1 37 | The protocol and path structure is defined by the MCP server implementation. Servers can define their own custom URI schemes. 38 | 39 | 40 | Resource types 41 | Resources can contain two types of content: 42 | 43 | 44 | Text resources 45 | Text resources contain UTF-8 encoded text data. These are suitable for: 46 | 47 | Source code 48 | Configuration files 49 | Log files 50 | JSON/XML data 51 | Plain text 52 | 53 | Binary resources 54 | Binary resources contain raw binary data encoded in base64. These are suitable for: 55 | 56 | Images 57 | PDFs 58 | Audio files 59 | Video files 60 | Other non-text formats 61 | 62 | Resource discovery 63 | Clients can discover available resources through two main methods: 64 | 65 | 66 | Direct resources 67 | Servers expose a list of concrete resources via the resources/list endpoint. Each resource includes: 68 | 69 | { 70 | uri: string; // Unique identifier for the resource 71 | name: string; // Human-readable name 72 | description?: string; // Optional description 73 | mimeType?: string; // Optional MIME type 74 | } 75 | 76 | Resource templates 77 | For dynamic resources, servers can expose URI templates that clients can use to construct valid resource URIs: 78 | 79 | { 80 | uriTemplate: string; // URI template following RFC 6570 81 | name: string; // Human-readable name for this type 82 | description?: string; // Optional description 83 | mimeType?: string; // Optional MIME type for all matching resources 84 | } 85 | 86 | Reading resources 87 | To read a resource, clients make a resources/read request with the resource URI. 88 | 89 | The server responds with a list of resource contents: 90 | 91 | { 92 | contents: [ 93 | { 94 | uri: string; // The URI of the resource 95 | mimeType?: string; // Optional MIME type 96 | 97 | // One of: 98 | text?: string; // For text resources 99 | blob?: string; // For binary resources (base64 encoded) 100 | } 101 | 102 | ] 103 | } 104 | Servers may return multiple resources in response to one resources/read request. This could be used, for example, to return a list of files inside a directory when the directory is read. 105 | 106 | 107 | Resource updates 108 | MCP supports real-time updates for resources through two mechanisms: 109 | 110 | 111 | List changes 112 | Servers can notify clients when their list of available resources changes via the notifications/resources/list_changed notification. 113 | 114 | 115 | Content changes 116 | Clients can subscribe to updates for specific resources: 117 | 118 | Client sends resources/subscribe with resource URI 119 | Server sends notifications/resources/updated when the resource changes 120 | Client can fetch latest content with resources/read 121 | Client can unsubscribe with resources/unsubscribe 122 | 123 | Example implementation 124 | Here’s a simple example of implementing resource support in an MCP server: 125 | 126 | ## Prompts 127 | 128 | Create reusable prompt templates and workflows 129 | 130 | Prompts enable servers to define reusable prompt templates and workflows that clients can easily surface to users and LLMs. They provide a powerful way to standardize and share common LLM interactions. 131 | 132 | Prompts are designed to be user-controlled, meaning they are exposed from servers to clients with the intention of the user being able to explicitly select them for use. 133 | 134 | 135 | Overview 136 | Prompts in MCP are predefined templates that can: 137 | 138 | Accept dynamic arguments 139 | Include context from resources 140 | Chain multiple interactions 141 | Guide specific workflows 142 | Surface as UI elements (like slash commands) 143 | 144 | Prompt structure 145 | Each prompt is defined with: 146 | 147 | { 148 | name: string; // Unique identifier for the prompt 149 | description?: string; // Human-readable description 150 | arguments?: [ // Optional list of arguments 151 | { 152 | name: string; // Argument identifier 153 | description?: string; // Argument description 154 | required?: boolean; // Whether argument is required 155 | } 156 | ] 157 | } 158 | 159 | Discovering prompts 160 | Clients can discover available prompts through the prompts/list endpoint: 161 | 162 | // Request 163 | { 164 | method: "prompts/list" 165 | } 166 | 167 | // Response 168 | { 169 | prompts: [ 170 | { 171 | name: "analyze-code", 172 | description: "Analyze code for potential improvements", 173 | arguments: [ 174 | { 175 | name: "language", 176 | description: "Programming language", 177 | required: true 178 | } 179 | ] 180 | } 181 | ] 182 | } 183 | 184 | Using prompts 185 | To use a prompt, clients make a prompts/get request: 186 | 187 | // Request 188 | { 189 | method: "prompts/get", 190 | params: { 191 | name: "analyze-code", 192 | arguments: { 193 | language: "python" 194 | } 195 | } 196 | } 197 | 198 | // Response 199 | { 200 | description: "Analyze Python code for potential improvements", 201 | messages: [ 202 | { 203 | role: "user", 204 | content: { 205 | type: "text", 206 | text: "Please analyze the following Python code for potential improvements:\n\n`python\ndef calculate_sum(numbers):\n total = 0\n for num in numbers:\n total = total + num\n return total\n\nresult = calculate_sum([1, 2, 3, 4, 5])\nprint(result)\n`" 207 | } 208 | } 209 | ] 210 | } 211 | 212 | Dynamic prompts 213 | Prompts can be dynamic and include: 214 | 215 | 216 | Embedded resource context 217 | 218 | { 219 | "name": "analyze-project", 220 | "description": "Analyze project logs and code", 221 | "arguments": [ 222 | { 223 | "name": "timeframe", 224 | "description": "Time period to analyze logs", 225 | "required": true 226 | }, 227 | { 228 | "name": "fileUri", 229 | "description": "URI of code file to review", 230 | "required": true 231 | } 232 | ] 233 | } 234 | When handling the prompts/get request: 235 | 236 | { 237 | "messages": [ 238 | { 239 | "role": "user", 240 | "content": { 241 | "type": "text", 242 | "text": "Analyze these system logs and the code file for any issues:" 243 | } 244 | }, 245 | { 246 | "role": "user", 247 | "content": { 248 | "type": "resource", 249 | "resource": { 250 | "uri": "logs://recent?timeframe=1h", 251 | "text": "[2024-03-14 15:32:11] ERROR: Connection timeout in network.py:127\n[2024-03-14 15:32:15] WARN: Retrying connection (attempt 2/3)\n[2024-03-14 15:32:20] ERROR: Max retries exceeded", 252 | "mimeType": "text/plain" 253 | } 254 | } 255 | }, 256 | { 257 | "role": "user", 258 | "content": { 259 | "type": "resource", 260 | "resource": { 261 | "uri": "file:///path/to/code.py", 262 | "text": "def connect_to_service(timeout=30):\n retries = 3\n for attempt in range(retries):\n try:\n return establish_connection(timeout)\n except TimeoutError:\n if attempt == retries - 1:\n raise\n time.sleep(5)\n\ndef establish_connection(timeout):\n # Connection implementation\n pass", 263 | "mimeType": "text/x-python" 264 | } 265 | } 266 | } 267 | ] 268 | } 269 | 270 | Multi-step workflows 271 | 272 | const debugWorkflow = { 273 | name: "debug-error", 274 | async getMessages(error: string) { 275 | return [ 276 | { 277 | role: "user", 278 | content: { 279 | type: "text", 280 | text: `Here's an error I'm seeing: ${error}` 281 | } 282 | }, 283 | { 284 | role: "assistant", 285 | content: { 286 | type: "text", 287 | text: "I'll help analyze this error. What have you tried so far?" 288 | } 289 | }, 290 | { 291 | role: "user", 292 | content: { 293 | type: "text", 294 | text: "I've tried restarting the service, but the error persists." 295 | } 296 | } 297 | ]; 298 | } 299 | }; 300 | 301 | Example implementation 302 | Here’s a complete example of implementing prompts in an MCP server: 303 | 304 | TypeScript 305 | Python 306 | 307 | import { Server } from "@modelcontextprotocol/sdk/server"; 308 | import { 309 | ListPromptsRequestSchema, 310 | GetPromptRequestSchema 311 | } from "@modelcontextprotocol/sdk/types"; 312 | 313 | const PROMPTS = { 314 | "git-commit": { 315 | name: "git-commit", 316 | description: "Generate a Git commit message", 317 | arguments: [ 318 | { 319 | name: "changes", 320 | description: "Git diff or description of changes", 321 | required: true 322 | } 323 | ] 324 | }, 325 | "explain-code": { 326 | name: "explain-code", 327 | description: "Explain how code works", 328 | arguments: [ 329 | { 330 | name: "code", 331 | description: "Code to explain", 332 | required: true 333 | }, 334 | { 335 | name: "language", 336 | description: "Programming language", 337 | required: false 338 | } 339 | ] 340 | } 341 | }; 342 | 343 | const server = new Server({ 344 | name: "example-prompts-server", 345 | version: "1.0.0" 346 | }, { 347 | capabilities: { 348 | prompts: {} 349 | } 350 | }); 351 | 352 | // List available prompts 353 | server.setRequestHandler(ListPromptsRequestSchema, async () => { 354 | return { 355 | prompts: Object.values(PROMPTS) 356 | }; 357 | }); 358 | 359 | // Get specific prompt 360 | server.setRequestHandler(GetPromptRequestSchema, async (request) => { 361 | const prompt = PROMPTS[request.params.name]; 362 | if (!prompt) { 363 | throw new Error(`Prompt not found: ${request.params.name}`); 364 | } 365 | 366 | if (request.params.name === "git-commit") { 367 | return { 368 | messages: [ 369 | { 370 | role: "user", 371 | content: { 372 | type: "text", 373 | text: `Generate a concise but descriptive commit message for these changes:\n\n${request.params.arguments?.changes}` 374 | } 375 | } 376 | ] 377 | }; 378 | } 379 | 380 | if (request.params.name === "explain-code") { 381 | const language = request.params.arguments?.language || "Unknown"; 382 | return { 383 | messages: [ 384 | { 385 | role: "user", 386 | content: { 387 | type: "text", 388 | text: `Explain how this ${language} code works:\n\n${request.params.arguments?.code}` 389 | } 390 | } 391 | ] 392 | }; 393 | } 394 | 395 | throw new Error("Prompt implementation not found"); 396 | }); 397 | 398 | Best practices 399 | When implementing prompts: 400 | 401 | Use clear, descriptive prompt names 402 | Provide detailed descriptions for prompts and arguments 403 | Validate all required arguments 404 | Handle missing arguments gracefully 405 | Consider versioning for prompt templates 406 | Cache dynamic content when appropriate 407 | Implement error handling 408 | Document expected argument formats 409 | Consider prompt composability 410 | Test prompts with various inputs 411 | 412 | UI integration 413 | Prompts can be surfaced in client UIs as: 414 | 415 | Slash commands 416 | Quick actions 417 | Context menu items 418 | Command palette entries 419 | Guided workflows 420 | Interactive forms 421 | 422 | Updates and changes 423 | Servers can notify clients about prompt changes: 424 | 425 | Server capability: prompts.listChanged 426 | Notification: notifications/prompts/list_changed 427 | Client re-fetches prompt list 428 | 429 | Security considerations 430 | When implementing prompts: 431 | 432 | Validate all arguments 433 | Sanitize user input 434 | Consider rate limiting 435 | Implement access controls 436 | Audit prompt usage 437 | Handle sensitive data appropriately 438 | Validate generated content 439 | Implement timeouts 440 | Consider prompt injection risks 441 | Document security requirements 442 | 443 | ## Tools 444 | 445 | Tools 446 | Enable LLMs to perform actions through your server 447 | 448 | Tools are a powerful primitive in the Model Context Protocol (MCP) that enable servers to expose executable functionality to clients. Through tools, LLMs can interact with external systems, perform computations, and take actions in the real world. 449 | 450 | Tools are designed to be model-controlled, meaning that tools are exposed from servers to clients with the intention of the AI model being able to automatically invoke them (with a human in the loop to grant approval). 451 | 452 | 453 | Overview 454 | Tools in MCP allow servers to expose executable functions that can be invoked by clients and used by LLMs to perform actions. Key aspects of tools include: 455 | 456 | Discovery: Clients can list available tools through the tools/list endpoint 457 | Invocation: Tools are called using the tools/call endpoint, where servers perform the requested operation and return results 458 | Flexibility: Tools can range from simple calculations to complex API interactions 459 | Like resources, tools are identified by unique names and can include descriptions to guide their usage. However, unlike resources, tools represent dynamic operations that can modify state or interact with external systems. 460 | 461 | 462 | Tool definition structure 463 | Each tool is defined with the following structure: 464 | 465 | { 466 | name: string; // Unique identifier for the tool 467 | description?: string; // Human-readable description 468 | inputSchema: { // JSON Schema for the tool's parameters 469 | type: "object", 470 | properties: { ... } // Tool-specific parameters 471 | } 472 | } 473 | 474 | Implementing tools 475 | Here’s an example of implementing a basic tool in an MCP server: 476 | 477 | TypeScript 478 | Python 479 | 480 | const server = new Server({ 481 | name: "example-server", 482 | version: "1.0.0" 483 | }, { 484 | capabilities: { 485 | tools: {} 486 | } 487 | }); 488 | 489 | // Define available tools 490 | server.setRequestHandler(ListToolsRequestSchema, async () => { 491 | return { 492 | tools: [{ 493 | name: "calculate_sum", 494 | description: "Add two numbers together", 495 | inputSchema: { 496 | type: "object", 497 | properties: { 498 | a: { type: "number" }, 499 | b: { type: "number" } 500 | }, 501 | required: ["a", "b"] 502 | } 503 | }] 504 | }; 505 | }); 506 | 507 | // Handle tool execution 508 | server.setRequestHandler(CallToolRequestSchema, async (request) => { 509 | if (request.params.name === "calculate_sum") { 510 | const { a, b } = request.params.arguments; 511 | return { 512 | content: [ 513 | { 514 | type: "text", 515 | text: String(a + b) 516 | } 517 | ] 518 | }; 519 | } 520 | throw new Error("Tool not found"); 521 | }); 522 | 523 | Example tool patterns 524 | Here are some examples of types of tools that a server could provide: 525 | 526 | 527 | System operations 528 | Tools that interact with the local system: 529 | 530 | { 531 | name: "execute_command", 532 | description: "Run a shell command", 533 | inputSchema: { 534 | type: "object", 535 | properties: { 536 | command: { type: "string" }, 537 | args: { type: "array", items: { type: "string" } } 538 | } 539 | } 540 | } 541 | 542 | API integrations 543 | Tools that wrap external APIs: 544 | 545 | { 546 | name: "github_create_issue", 547 | description: "Create a GitHub issue", 548 | inputSchema: { 549 | type: "object", 550 | properties: { 551 | title: { type: "string" }, 552 | body: { type: "string" }, 553 | labels: { type: "array", items: { type: "string" } } 554 | } 555 | } 556 | } 557 | 558 | Data processing 559 | Tools that transform or analyze data: 560 | 561 | { 562 | name: "analyze_csv", 563 | description: "Analyze a CSV file", 564 | inputSchema: { 565 | type: "object", 566 | properties: { 567 | filepath: { type: "string" }, 568 | operations: { 569 | type: "array", 570 | items: { 571 | enum: ["sum", "average", "count"] 572 | } 573 | } 574 | } 575 | } 576 | } 577 | 578 | Best practices 579 | When implementing tools: 580 | 581 | Provide clear, descriptive names and descriptions 582 | Use detailed JSON Schema definitions for parameters 583 | Include examples in tool descriptions to demonstrate how the model should use them 584 | Implement proper error handling and validation 585 | Use progress reporting for long operations 586 | Keep tool operations focused and atomic 587 | Document expected return value structures 588 | Implement proper timeouts 589 | Consider rate limiting for resource-intensive operations 590 | Log tool usage for debugging and monitoring 591 | 592 | Security considerations 593 | When exposing tools: 594 | 595 | 596 | Input validation 597 | Validate all parameters against the schema 598 | Sanitize file paths and system commands 599 | Validate URLs and external identifiers 600 | Check parameter sizes and ranges 601 | Prevent command injection 602 | 603 | Access control 604 | Implement authentication where needed 605 | Use appropriate authorization checks 606 | Audit tool usage 607 | Rate limit requests 608 | Monitor for abuse 609 | 610 | Error handling 611 | Don’t expose internal errors to clients 612 | Log security-relevant errors 613 | Handle timeouts appropriately 614 | Clean up resources after errors 615 | Validate return values 616 | 617 | Tool discovery and updates 618 | MCP supports dynamic tool discovery: 619 | 620 | Clients can list available tools at any time 621 | Servers can notify clients when tools change using notifications/tools/list_changed 622 | Tools can be added or removed during runtime 623 | Tool definitions can be updated (though this should be done carefully) 624 | 625 | Error handling 626 | Tool errors should be reported within the result object, not as MCP protocol-level errors. This allows the LLM to see and potentially handle the error. When a tool encounters an error: 627 | 628 | Set isError to true in the result 629 | Include error details in the content array 630 | Here’s an example of proper error handling for tools: 631 | 632 | TypeScript 633 | Python 634 | 635 | try { 636 | // Tool operation 637 | const result = performOperation(); 638 | return { 639 | content: [ 640 | { 641 | type: "text", 642 | text: `Operation successful: ${result}` 643 | } 644 | ] 645 | }; 646 | } catch (error) { 647 | return { 648 | isError: true, 649 | content: [ 650 | { 651 | type: "text", 652 | text: `Error: ${error.message}` 653 | } 654 | ] 655 | }; 656 | } 657 | This approach allows the LLM to see that an error occurred and potentially take corrective action or request human intervention. 658 | 659 | 660 | Testing tools 661 | A comprehensive testing strategy for MCP tools should cover: 662 | 663 | Functional testing: Verify tools execute correctly with valid inputs and handle invalid inputs appropriately 664 | Integration testing: Test tool interaction with external systems using both real and mocked dependencies 665 | Security testing: Validate authentication, authorization, input sanitization, and rate limiting 666 | Performance testing: Check behavior under load, timeout handling, and resource cleanup 667 | Error handling: Ensure tools properly report errors through the MCP protocol and clean up resources 668 | 669 | ## Sampling 670 | 671 | Sampling 672 | Let your servers request completions from LLMs 673 | 674 | Sampling is a powerful MCP feature that allows servers to request LLM completions through the client, enabling sophisticated agentic behaviors while maintaining security and privacy. 675 | 676 | This feature of MCP is not yet supported in the Claude Desktop client. 677 | 678 | 679 | How sampling works 680 | The sampling flow follows these steps: 681 | 682 | Server sends a sampling/createMessage request to the client 683 | Client reviews the request and can modify it 684 | Client samples from an LLM 685 | Client reviews the completion 686 | Client returns the result to the server 687 | This human-in-the-loop design ensures users maintain control over what the LLM sees and generates. 688 | 689 | 690 | Message format 691 | Sampling requests use a standardized message format: 692 | 693 | { 694 | messages: [ 695 | { 696 | role: "user" | "assistant", 697 | content: { 698 | type: "text" | "image", 699 | 700 | // For text: 701 | text?: string, 702 | 703 | // For images: 704 | data?: string, // base64 encoded 705 | mimeType?: string 706 | } 707 | } 708 | 709 | ], 710 | modelPreferences?: { 711 | hints?: [{ 712 | name?: string // Suggested model name/family 713 | }], 714 | costPriority?: number, // 0-1, importance of minimizing cost 715 | speedPriority?: number, // 0-1, importance of low latency 716 | intelligencePriority?: number // 0-1, importance of capabilities 717 | }, 718 | systemPrompt?: string, 719 | includeContext?: "none" | "thisServer" | "allServers", 720 | temperature?: number, 721 | maxTokens: number, 722 | stopSequences?: string[], 723 | metadata?: Record 724 | } 725 | 726 | Request parameters 727 | 728 | Messages 729 | The messages array contains the conversation history to send to the LLM. Each message has: 730 | 731 | role: Either “user” or “assistant” 732 | content: The message content, which can be: 733 | Text content with a text field 734 | Image content with data (base64) and mimeType fields 735 | 736 | Model preferences 737 | The modelPreferences object allows servers to specify their model selection preferences: 738 | 739 | hints: Array of model name suggestions that clients can use to select an appropriate model: 740 | 741 | name: String that can match full or partial model names (e.g. “claude-3”, “sonnet”) 742 | Clients may map hints to equivalent models from different providers 743 | Multiple hints are evaluated in preference order 744 | Priority values (0-1 normalized): 745 | 746 | costPriority: Importance of minimizing costs 747 | speedPriority: Importance of low latency response 748 | intelligencePriority: Importance of advanced model capabilities 749 | Clients make the final model selection based on these preferences and their available models. 750 | 751 | 752 | System prompt 753 | An optional systemPrompt field allows servers to request a specific system prompt. The client may modify or ignore this. 754 | 755 | 756 | Context inclusion 757 | The includeContext parameter specifies what MCP context to include: 758 | 759 | "none": No additional context 760 | "thisServer": Include context from the requesting server 761 | "allServers": Include context from all connected MCP servers 762 | The client controls what context is actually included. 763 | 764 | 765 | Sampling parameters 766 | Fine-tune the LLM sampling with: 767 | 768 | temperature: Controls randomness (0.0 to 1.0) 769 | maxTokens: Maximum tokens to generate 770 | stopSequences: Array of sequences that stop generation 771 | metadata: Additional provider-specific parameters 772 | 773 | Response format 774 | The client returns a completion result: 775 | 776 | { 777 | model: string, // Name of the model used 778 | stopReason?: "endTurn" | "stopSequence" | "maxTokens" | string, 779 | role: "user" | "assistant", 780 | content: { 781 | type: "text" | "image", 782 | text?: string, 783 | data?: string, 784 | mimeType?: string 785 | } 786 | } 787 | 788 | Example request 789 | Here’s an example of requesting sampling from a client: 790 | 791 | { 792 | "method": "sampling/createMessage", 793 | "params": { 794 | "messages": [ 795 | { 796 | "role": "user", 797 | "content": { 798 | "type": "text", 799 | "text": "What files are in the current directory?" 800 | } 801 | } 802 | ], 803 | "systemPrompt": "You are a helpful file system assistant.", 804 | "includeContext": "thisServer", 805 | "maxTokens": 100 806 | } 807 | } 808 | 809 | Best practices 810 | When implementing sampling: 811 | 812 | Always provide clear, well-structured prompts 813 | Handle both text and image content appropriately 814 | Set reasonable token limits 815 | Include relevant context through includeContext 816 | Validate responses before using them 817 | Handle errors gracefully 818 | Consider rate limiting sampling requests 819 | Document expected sampling behavior 820 | Test with various model parameters 821 | Monitor sampling costs 822 | 823 | Human in the loop controls 824 | Sampling is designed with human oversight in mind: 825 | 826 | 827 | For prompts 828 | Clients should show users the proposed prompt 829 | Users should be able to modify or reject prompts 830 | System prompts can be filtered or modified 831 | Context inclusion is controlled by the client 832 | 833 | For completions 834 | Clients should show users the completion 835 | Users should be able to modify or reject completions 836 | Clients can filter or modify completions 837 | Users control which model is used 838 | 839 | Security considerations 840 | When implementing sampling: 841 | 842 | Validate all message content 843 | Sanitize sensitive information 844 | Implement appropriate rate limits 845 | Monitor sampling usage 846 | Encrypt data in transit 847 | Handle user data privacy 848 | Audit sampling requests 849 | Control cost exposure 850 | Implement timeouts 851 | Handle model errors gracefully 852 | 853 | Common patterns 854 | 855 | Agentic workflows 856 | Sampling enables agentic patterns like: 857 | 858 | Reading and analyzing resources 859 | Making decisions based on context 860 | Generating structured data 861 | Handling multi-step tasks 862 | Providing interactive assistance 863 | 864 | Context management 865 | Best practices for context: 866 | 867 | Request minimal necessary context 868 | Structure context clearly 869 | Handle context size limits 870 | Update context as needed 871 | Clean up stale context 872 | 873 | Error handling 874 | Robust error handling should: 875 | 876 | Catch sampling failures 877 | Handle timeout errors 878 | Manage rate limits 879 | Validate responses 880 | Provide fallback behaviors 881 | Log errors appropriately 882 | 883 | Limitations 884 | Be aware of these limitations: 885 | 886 | Sampling depends on client capabilities 887 | Users control sampling behavior 888 | Context size has limits 889 | Rate limits may apply 890 | Costs should be considered 891 | Model availability varies 892 | Response times vary 893 | Not all content types supported 894 | 895 | ## Roots 896 | 897 | Roots 898 | Understanding roots in MCP 899 | 900 | Roots are a concept in MCP that define the boundaries where servers can operate. They provide a way for clients to inform servers about relevant resources and their locations. 901 | 902 | 903 | What are Roots? 904 | A root is a URI that a client suggests a server should focus on. When a client connects to a server, it declares which roots the server should work with. While primarily used for filesystem paths, roots can be any valid URI including HTTP URLs. 905 | 906 | For example, roots could be: 907 | 908 | file:///home/user/projects/myapp 909 | https://api.example.com/v1 910 | 911 | Why Use Roots? 912 | Roots serve several important purposes: 913 | 914 | Guidance: They inform servers about relevant resources and locations 915 | Clarity: Roots make it clear which resources are part of your workspace 916 | Organization: Multiple roots let you work with different resources simultaneously 917 | 918 | How Roots Work 919 | When a client supports roots, it: 920 | 921 | Declares the roots capability during connection 922 | Provides a list of suggested roots to the server 923 | Notifies the server when roots change (if supported) 924 | While roots are informational and not strictly enforcing, servers should: 925 | 926 | Respect the provided roots 927 | Use root URIs to locate and access resources 928 | Prioritize operations within root boundaries 929 | 930 | Common Use Cases 931 | Roots are commonly used to define: 932 | 933 | Project directories 934 | Repository locations 935 | API endpoints 936 | Configuration locations 937 | Resource boundaries 938 | 939 | Best Practices 940 | When working with roots: 941 | 942 | Only suggest necessary resources 943 | Use clear, descriptive names for roots 944 | Monitor root accessibility 945 | Handle root changes gracefully 946 | 947 | Example 948 | Here’s how a typical MCP client might expose roots: 949 | 950 | { 951 | "roots": [ 952 | { 953 | "uri": "file:///home/user/projects/frontend", 954 | "name": "Frontend Repository" 955 | }, 956 | { 957 | "uri": "https://api.example.com/v1", 958 | "name": "API Endpoint" 959 | } 960 | ] 961 | } 962 | This configuration suggests the server focus on both a local repository and an API endpoint while keeping them logically separated. 963 | 964 | ## Transports 965 | 966 | Transports 967 | Learn about MCP’s communication mechanisms 968 | 969 | Transports in the Model Context Protocol (MCP) provide the foundation for communication between clients and servers. A transport handles the underlying mechanics of how messages are sent and received. 970 | 971 | 972 | Message Format 973 | MCP uses JSON-RPC 2.0 as its wire format. The transport layer is responsible for converting MCP protocol messages into JSON-RPC format for transmission and converting received JSON-RPC messages back into MCP protocol messages. 974 | 975 | There are three types of JSON-RPC messages used: 976 | 977 | 978 | Requests 979 | 980 | { 981 | jsonrpc: "2.0", 982 | id: number | string, 983 | method: string, 984 | params?: object 985 | } 986 | 987 | Responses 988 | 989 | { 990 | jsonrpc: "2.0", 991 | id: number | string, 992 | result?: object, 993 | error?: { 994 | code: number, 995 | message: string, 996 | data?: unknown 997 | } 998 | } 999 | 1000 | Notifications 1001 | 1002 | { 1003 | jsonrpc: "2.0", 1004 | method: string, 1005 | params?: object 1006 | } 1007 | 1008 | Built-in Transport Types 1009 | MCP includes two standard transport implementations: 1010 | 1011 | 1012 | Standard Input/Output (stdio) 1013 | The stdio transport enables communication through standard input and output streams. This is particularly useful for local integrations and command-line tools. 1014 | 1015 | Use stdio when: 1016 | 1017 | Building command-line tools 1018 | Implementing local integrations 1019 | Needing simple process communication 1020 | Working with shell scripts 1021 | TypeScript (Server) 1022 | TypeScript (Client) 1023 | Python (Server) 1024 | Python (Client) 1025 | 1026 | const server = new Server({ 1027 | name: "example-server", 1028 | version: "1.0.0" 1029 | }, { 1030 | capabilities: {} 1031 | }); 1032 | 1033 | const transport = new StdioServerTransport(); 1034 | await server.connect(transport); 1035 | 1036 | Server-Sent Events (SSE) 1037 | SSE transport enables server-to-client streaming with HTTP POST requests for client-to-server communication. 1038 | 1039 | Use SSE when: 1040 | 1041 | Only server-to-client streaming is needed 1042 | Working with restricted networks 1043 | Implementing simple updates 1044 | TypeScript (Server) 1045 | TypeScript (Client) 1046 | Python (Server) 1047 | Python (Client) 1048 | 1049 | import express from "express"; 1050 | 1051 | const app = express(); 1052 | 1053 | const server = new Server({ 1054 | name: "example-server", 1055 | version: "1.0.0" 1056 | }, { 1057 | capabilities: {} 1058 | }); 1059 | 1060 | let transport: SSEServerTransport | null = null; 1061 | 1062 | app.get("/sse", (req, res) => { 1063 | transport = new SSEServerTransport("/messages", res); 1064 | server.connect(transport); 1065 | }); 1066 | 1067 | app.post("/messages", (req, res) => { 1068 | if (transport) { 1069 | transport.handlePostMessage(req, res); 1070 | } 1071 | }); 1072 | 1073 | app.listen(3000); 1074 | 1075 | Custom Transports 1076 | MCP makes it easy to implement custom transports for specific needs. Any transport implementation just needs to conform to the Transport interface: 1077 | 1078 | You can implement custom transports for: 1079 | 1080 | Custom network protocols 1081 | Specialized communication channels 1082 | Integration with existing systems 1083 | Performance optimization 1084 | TypeScript 1085 | Python 1086 | 1087 | interface Transport { 1088 | // Start processing messages 1089 | start(): Promise; 1090 | 1091 | // Send a JSON-RPC message 1092 | send(message: JSONRPCMessage): Promise; 1093 | 1094 | // Close the connection 1095 | close(): Promise; 1096 | 1097 | // Callbacks 1098 | onclose?: () => void; 1099 | onerror?: (error: Error) => void; 1100 | onmessage?: (message: JSONRPCMessage) => void; 1101 | } 1102 | 1103 | Error Handling 1104 | Transport implementations should handle various error scenarios: 1105 | 1106 | Connection errors 1107 | Message parsing errors 1108 | Protocol errors 1109 | Network timeouts 1110 | Resource cleanup 1111 | Example error handling: 1112 | 1113 | TypeScript 1114 | Python 1115 | 1116 | class ExampleTransport implements Transport { 1117 | async start() { 1118 | try { 1119 | // Connection logic 1120 | } catch (error) { 1121 | this.onerror?.(new Error(`Failed to connect: ${error}`)); 1122 | throw error; 1123 | } 1124 | } 1125 | 1126 | async send(message: JSONRPCMessage) { 1127 | try { 1128 | // Sending logic 1129 | } catch (error) { 1130 | this.onerror?.(new Error(`Failed to send message: ${error}`)); 1131 | throw error; 1132 | } 1133 | } 1134 | } 1135 | 1136 | Best Practices 1137 | When implementing or using MCP transport: 1138 | 1139 | Handle connection lifecycle properly 1140 | Implement proper error handling 1141 | Clean up resources on connection close 1142 | Use appropriate timeouts 1143 | Validate messages before sending 1144 | Log transport events for debugging 1145 | Implement reconnection logic when appropriate 1146 | Handle backpressure in message queues 1147 | Monitor connection health 1148 | Implement proper security measures 1149 | 1150 | Security Considerations 1151 | When implementing transport: 1152 | 1153 | 1154 | Authentication and Authorization 1155 | Implement proper authentication mechanisms 1156 | Validate client credentials 1157 | Use secure token handling 1158 | Implement authorization checks 1159 | 1160 | Data Security 1161 | Use TLS for network transport 1162 | Encrypt sensitive data 1163 | Validate message integrity 1164 | Implement message size limits 1165 | Sanitize input data 1166 | 1167 | Network Security 1168 | Implement rate limiting 1169 | Use appropriate timeouts 1170 | Handle denial of service scenarios 1171 | Monitor for unusual patterns 1172 | Implement proper firewall rules 1173 | 1174 | Debugging Transport 1175 | Tips for debugging transport issues: 1176 | 1177 | Enable debug logging 1178 | Monitor message flow 1179 | Check connection states 1180 | Validate message formats 1181 | Test error scenarios 1182 | Use network analysis tools 1183 | Implement health checks 1184 | Monitor resource usage 1185 | Test edge cases 1186 | Use proper error tracking 1187 | -------------------------------------------------------------------------------- /docs/mcp.md: -------------------------------------------------------------------------------- 1 | # MCP TypeScript SDK   2 | 3 | ## Table of Contents 4 | 5 | - [Overview](mdc:#overview) 6 | - [Installation](mdc:#installation) 7 | - [Quickstart](mdc:#quickstart) 8 | - [What is MCP?](mdc:#what-is-mcp) 9 | - [Core Concepts](mdc:#core-concepts) 10 | - [Server](mdc:#server) 11 | - [Resources](mdc:#resources) 12 | - [Tools](mdc:#tools) 13 | - [Prompts](mdc:#prompts) 14 | - [Running Your Server](mdc:#running-your-server) 15 | - [stdio](mdc:#stdio) 16 | - [HTTP with SSE](mdc:#http-with-sse) 17 | - [Testing and Debugging](mdc:#testing-and-debugging) 18 | - [Examples](mdc:#examples) 19 | - [Echo Server](mdc:#echo-server) 20 | - [SQLite Explorer](mdc:#sqlite-explorer) 21 | - [Advanced Usage](mdc:#advanced-usage) 22 | - [Low-Level Server](mdc:#low-level-server) 23 | - [Writing MCP Clients](mdc:#writing-mcp-clients) 24 | - [Server Capabilities](mdc:#server-capabilities) 25 | 26 | ## Overview 27 | 28 | The Model Context Protocol allows applications to provide context for LLMs in a standardized way, separating the concerns of providing context from the actual LLM interaction. This TypeScript SDK implements the full MCP specification, making it easy to: 29 | 30 | - Build MCP clients that can connect to any MCP server 31 | - Create MCP servers that expose resources, prompts and tools 32 | - Use standard transports like stdio and SSE 33 | - Handle all MCP protocol messages and lifecycle events 34 | 35 | ## Installation 36 | 37 | ```bash 38 | npm install @modelcontextprotocol/sdk 39 | ``` 40 | 41 | ## Quick Start 42 | 43 | Let's create a simple MCP server that exposes a calculator tool and some data: 44 | 45 | ```typescript 46 | import { 47 | McpServer, 48 | ResourceTemplate, 49 | } from "@modelcontextprotocol/sdk/server/mcp.js"; 50 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 51 | import { z } from "zod"; 52 | 53 | // Create an MCP server 54 | const server = new McpServer({ 55 | name: "Demo", 56 | version: "1.0.0", 57 | }); 58 | 59 | // Add an addition tool 60 | server.tool("add", { a: z.number(), b: z.number() }, async ({ a, b }) => ({ 61 | content: [{ type: "text", text: String(a + b) }], 62 | })); 63 | 64 | // Add a dynamic greeting resource 65 | server.resource( 66 | "greeting", 67 | new ResourceTemplate("greeting://{name}", { list: undefined }), 68 | async (uri, { name }) => ({ 69 | contents: [ 70 | { 71 | uri: uri.href, 72 | text: `Hello, ${name}!`, 73 | }, 74 | ], 75 | }) 76 | ); 77 | 78 | // Start receiving messages on stdin and sending messages on stdout 79 | const transport = new StdioServerTransport(); 80 | await server.connect(transport); 81 | ``` 82 | 83 | ## What is MCP? 84 | 85 | The [Model Context Protocol (MCP)](mdc:https:/modelcontextprotocol.io) lets you build servers that expose data and functionality to LLM applications in a secure, standardized way. Think of it like a web API, but specifically designed for LLM interactions. MCP servers can: 86 | 87 | - Expose data through **Resources** (think of these sort of like GET endpoints; they are used to load information into the LLM's context) 88 | - Provide functionality through **Tools** (sort of like POST endpoints; they are used to execute code or otherwise produce a side effect) 89 | - Define interaction patterns through **Prompts** (reusable templates for LLM interactions) 90 | - And more! 91 | 92 | ## Core Concepts 93 | 94 | ### Server 95 | 96 | The McpServer is your core interface to the MCP protocol. It handles connection management, protocol compliance, and message routing: 97 | 98 | ```typescript 99 | const server = new McpServer({ 100 | name: "My App", 101 | version: "1.0.0", 102 | }); 103 | ``` 104 | 105 | ### Resources 106 | 107 | Resources are how you expose data to LLMs. They're similar to GET endpoints in a REST API - they provide data but shouldn't perform significant computation or have side effects: 108 | 109 | ```typescript 110 | // Static resource 111 | server.resource("config", "config://app", async (uri) => ({ 112 | contents: [ 113 | { 114 | uri: uri.href, 115 | text: "App configuration here", 116 | }, 117 | ], 118 | })); 119 | 120 | // Dynamic resource with parameters 121 | server.resource( 122 | "user-profile", 123 | new ResourceTemplate("users://{userId}/profile", { list: undefined }), 124 | async (uri, { userId }) => ({ 125 | contents: [ 126 | { 127 | uri: uri.href, 128 | text: `Profile data for user ${userId}`, 129 | }, 130 | ], 131 | }) 132 | ); 133 | ``` 134 | 135 | ### Tools 136 | 137 | Tools let LLMs take actions through your server. Unlike resources, tools are expected to perform computation and have side effects: 138 | 139 | ```typescript 140 | // Simple tool with parameters 141 | server.tool( 142 | "calculate-bmi", 143 | { 144 | weightKg: z.number(), 145 | heightM: z.number(), 146 | }, 147 | async ({ weightKg, heightM }) => ({ 148 | content: [ 149 | { 150 | type: "text", 151 | text: String(weightKg / (heightM * heightM)), 152 | }, 153 | ], 154 | }) 155 | ); 156 | 157 | // Async tool with external API call 158 | server.tool("fetch-weather", { city: z.string() }, async ({ city }) => { 159 | const response = await fetch(`https://api.weather.com/${city}`); 160 | const data = await response.text(); 161 | return { 162 | content: [{ type: "text", text: data }], 163 | }; 164 | }); 165 | ``` 166 | 167 | ### Prompts 168 | 169 | Prompts are reusable templates that help LLMs interact with your server effectively: 170 | 171 | ```typescript 172 | server.prompt("review-code", { code: z.string() }, ({ code }) => ({ 173 | messages: [ 174 | { 175 | role: "user", 176 | content: { 177 | type: "text", 178 | text: `Please review this code:\n\n${code}`, 179 | }, 180 | }, 181 | ], 182 | })); 183 | ``` 184 | 185 | ## Running Your Server 186 | 187 | MCP servers in TypeScript need to be connected to a transport to communicate with clients. How you start the server depends on the choice of transport: 188 | 189 | ### stdio 190 | 191 | For command-line tools and direct integrations: 192 | 193 | ```typescript 194 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 195 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 196 | 197 | const server = new McpServer({ 198 | name: "example-server", 199 | version: "1.0.0", 200 | }); 201 | 202 | // ... set up server resources, tools, and prompts ... 203 | 204 | const transport = new StdioServerTransport(); 205 | await server.connect(transport); 206 | ``` 207 | 208 | ### HTTP with SSE 209 | 210 | For remote servers, start a web server with a Server-Sent Events (SSE) endpoint, and a separate endpoint for the client to send its messages to: 211 | 212 | ```typescript 213 | import express from "express"; 214 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 215 | import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; 216 | 217 | const server = new McpServer({ 218 | name: "example-server", 219 | version: "1.0.0", 220 | }); 221 | 222 | // ... set up server resources, tools, and prompts ... 223 | 224 | const app = express(); 225 | 226 | app.get("/sse", async (req, res) => { 227 | const transport = new SSEServerTransport("/messages", res); 228 | await server.connect(transport); 229 | }); 230 | 231 | app.post("/messages", async (req, res) => { 232 | // Note: to support multiple simultaneous connections, these messages will 233 | // need to be routed to a specific matching transport. (This logic isn't 234 | // implemented here, for simplicity.) 235 | await transport.handlePostMessage(req, res); 236 | }); 237 | 238 | app.listen(3001); 239 | ``` 240 | 241 | ### Testing and Debugging 242 | 243 | To test your server, you can use the [MCP Inspector](mdc:https:/github.com/modelcontextprotocol/inspector). See its README for more information. 244 | 245 | ## Examples 246 | 247 | ### Echo Server 248 | 249 | A simple server demonstrating resources, tools, and prompts: 250 | 251 | ```typescript 252 | import { 253 | McpServer, 254 | ResourceTemplate, 255 | } from "@modelcontextprotocol/sdk/server/mcp.js"; 256 | import { z } from "zod"; 257 | 258 | const server = new McpServer({ 259 | name: "Echo", 260 | version: "1.0.0", 261 | }); 262 | 263 | server.resource( 264 | "echo", 265 | new ResourceTemplate("echo://{message}", { list: undefined }), 266 | async (uri, { message }) => ({ 267 | contents: [ 268 | { 269 | uri: uri.href, 270 | text: `Resource echo: ${message}`, 271 | }, 272 | ], 273 | }) 274 | ); 275 | 276 | server.tool("echo", { message: z.string() }, async ({ message }) => ({ 277 | content: [{ type: "text", text: `Tool echo: ${message}` }], 278 | })); 279 | 280 | server.prompt("echo", { message: z.string() }, ({ message }) => ({ 281 | messages: [ 282 | { 283 | role: "user", 284 | content: { 285 | type: "text", 286 | text: `Please process this message: ${message}`, 287 | }, 288 | }, 289 | ], 290 | })); 291 | ``` 292 | 293 | ### SQLite Explorer 294 | 295 | A more complex example showing database integration: 296 | 297 | ```typescript 298 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 299 | import sqlite3 from "sqlite3"; 300 | import { promisify } from "util"; 301 | import { z } from "zod"; 302 | 303 | const server = new McpServer({ 304 | name: "SQLite Explorer", 305 | version: "1.0.0", 306 | }); 307 | 308 | // Helper to create DB connection 309 | const getDb = () => { 310 | const db = new sqlite3.Database("database.db"); 311 | return { 312 | all: promisify(db.all.bind(db)), 313 | close: promisify(db.close.bind(db)), 314 | }; 315 | }; 316 | 317 | server.resource("schema", "schema://main", async (uri) => { 318 | const db = getDb(); 319 | try { 320 | const tables = await db.all( 321 | "SELECT sql FROM sqlite_master WHERE type='table'" 322 | ); 323 | return { 324 | contents: [ 325 | { 326 | uri: uri.href, 327 | text: tables.map((t: { sql: string }) => t.sql).join("\n"), 328 | }, 329 | ], 330 | }; 331 | } finally { 332 | await db.close(); 333 | } 334 | }); 335 | 336 | server.tool("query", { sql: z.string() }, async ({ sql }) => { 337 | const db = getDb(); 338 | try { 339 | const results = await db.all(sql); 340 | return { 341 | content: [ 342 | { 343 | type: "text", 344 | text: JSON.stringify(results, null, 2), 345 | }, 346 | ], 347 | }; 348 | } catch (err: unknown) { 349 | const error = err as Error; 350 | return { 351 | content: [ 352 | { 353 | type: "text", 354 | text: `Error: ${error.message}`, 355 | }, 356 | ], 357 | isError: true, 358 | }; 359 | } finally { 360 | await db.close(); 361 | } 362 | }); 363 | ``` 364 | 365 | ## Advanced Usage 366 | 367 | ### Low-Level Server 368 | 369 | For more control, you can use the low-level Server class directly: 370 | 371 | ```typescript 372 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 373 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 374 | import { 375 | ListPromptsRequestSchema, 376 | GetPromptRequestSchema, 377 | } from "@modelcontextprotocol/sdk/types.js"; 378 | 379 | const server = new Server( 380 | { 381 | name: "example-server", 382 | version: "1.0.0", 383 | }, 384 | { 385 | capabilities: { 386 | prompts: {}, 387 | }, 388 | } 389 | ); 390 | 391 | server.setRequestHandler(ListPromptsRequestSchema, async () => { 392 | return { 393 | prompts: [ 394 | { 395 | name: "example-prompt", 396 | description: "An example prompt template", 397 | arguments: [ 398 | { 399 | name: "arg1", 400 | description: "Example argument", 401 | required: true, 402 | }, 403 | ], 404 | }, 405 | ], 406 | }; 407 | }); 408 | 409 | server.setRequestHandler(GetPromptRequestSchema, async (request) => { 410 | if (request.params.name !== "example-prompt") { 411 | throw new Error("Unknown prompt"); 412 | } 413 | return { 414 | description: "Example prompt", 415 | messages: [ 416 | { 417 | role: "user", 418 | content: { 419 | type: "text", 420 | text: "Example prompt text", 421 | }, 422 | }, 423 | ], 424 | }; 425 | }); 426 | 427 | const transport = new StdioServerTransport(); 428 | await server.connect(transport); 429 | ``` 430 | 431 | ### Writing MCP Clients 432 | 433 | The SDK provides a high-level client interface: 434 | 435 | ```typescript 436 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; 437 | import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; 438 | 439 | const transport = new StdioClientTransport({ 440 | command: "node", 441 | args: ["server.js"], 442 | }); 443 | 444 | const client = new Client( 445 | { 446 | name: "example-client", 447 | version: "1.0.0", 448 | }, 449 | { 450 | capabilities: { 451 | prompts: {}, 452 | resources: {}, 453 | tools: {}, 454 | }, 455 | } 456 | ); 457 | 458 | await client.connect(transport); 459 | 460 | // List prompts 461 | const prompts = await client.listPrompts(); 462 | 463 | // Get a prompt 464 | const prompt = await client.getPrompt("example-prompt", { 465 | arg1: "value", 466 | }); 467 | 468 | // List resources 469 | const resources = await client.listResources(); 470 | 471 | // Read a resource 472 | const resource = await client.readResource("file:///example.txt"); 473 | 474 | // Call a tool 475 | const result = await client.callTool({ 476 | name: "example-tool", 477 | arguments: { 478 | arg1: "value", 479 | }, 480 | }); 481 | ``` 482 | 483 | ## Documentation 484 | 485 | - [Model Context Protocol documentation](mdc:https:/modelcontextprotocol.io) 486 | - [MCP Specification](mdc:https:/spec.modelcontextprotocol.io) 487 | - [Example Servers](mdc:https:/github.com/modelcontextprotocol/servers) 488 | 489 | ## Contributing 490 | 491 | Issues and pull requests are welcome on GitHub at https://github.com/modelcontextprotocol/typescript-sdk. 492 | 493 | ## License 494 | 495 | This project is licensed under the MIT License—see the [LICENSE](mdc:LICENSE) file for details. 496 | --------------------------------------------------------------------------------