This is page 2 of 3. Use http://codebase.md/sugatraj/cursor-browser-tools-mcp?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .DS_Store ├── .gitignore ├── browser-tools-mcp │ ├── mcp-server.ts │ ├── package-lock.json │ ├── package.json │ ├── README.md │ └── tsconfig.json ├── browser-tools-server │ ├── 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 │ ├── README.md │ └── tsconfig.json ├── chrome-extension │ ├── background.js │ ├── devtools.html │ ├── devtools.js │ ├── manifest.json │ ├── panel.html │ └── panel.js ├── debugcommands.mdc ├── docs │ ├── mcp-docs.md │ └── mcp.md ├── LICENSE ├── package.json ├── README.md ├── SETUP_INSTRUCTIONS.md ├── start-servers.bat └── start-servers.sh ``` # Files -------------------------------------------------------------------------------- /docs/mcp-docs.md: -------------------------------------------------------------------------------- ```markdown 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<string, unknown> 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<void>; 1090 | 1091 | // Send a JSON-RPC message 1092 | send(message: JSONRPCMessage): Promise<void>; 1093 | 1094 | // Close the connection 1095 | close(): Promise<void>; 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 | ``` -------------------------------------------------------------------------------- /browser-tools-server/puppeteer-service.ts: -------------------------------------------------------------------------------- ```typescript 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<PuppeteerServiceConfig> 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<puppeteer.Browser> { 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<puppeteer.Browser> { 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<void> { 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<string> { 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<string, string>; 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 | ``` -------------------------------------------------------------------------------- /chrome-extension/panel.js: -------------------------------------------------------------------------------- ```javascript 1 | // Store settings 2 | let settings = { 3 | logLimit: 50, 4 | queryLimit: 30000, 5 | stringSizeLimit: 500, 6 | showRequestHeaders: false, 7 | showResponseHeaders: false, 8 | maxLogSize: 20000, 9 | screenshotPath: "", 10 | // Add server connection settings 11 | serverHost: "localhost", 12 | serverPort: 3025, 13 | allowAutoPaste: false, // Default auto-paste setting 14 | }; 15 | 16 | // Track connection status 17 | let serverConnected = false; 18 | let reconnectAttemptTimeout = null; 19 | // Add a flag to track ongoing discovery operations 20 | let isDiscoveryInProgress = false; 21 | // Add an AbortController to cancel fetch operations 22 | let discoveryController = null; 23 | 24 | // Load saved settings on startup 25 | chrome.storage.local.get(["browserConnectorSettings"], (result) => { 26 | if (result.browserConnectorSettings) { 27 | settings = { ...settings, ...result.browserConnectorSettings }; 28 | updateUIFromSettings(); 29 | } 30 | 31 | // Create connection status banner at the top 32 | createConnectionBanner(); 33 | 34 | // Automatically discover server on panel load with quiet mode enabled 35 | discoverServer(true); 36 | }); 37 | 38 | // Add listener for connection status updates from background script (page refresh events) 39 | chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { 40 | if (message.type === "CONNECTION_STATUS_UPDATE") { 41 | console.log( 42 | `Received connection status update: ${ 43 | message.isConnected ? "Connected" : "Disconnected" 44 | }` 45 | ); 46 | 47 | // Update UI based on connection status 48 | if (message.isConnected) { 49 | // If already connected, just maintain the current state 50 | if (!serverConnected) { 51 | // Connection was re-established, update UI 52 | serverConnected = true; 53 | updateConnectionBanner(true, { 54 | name: "Browser Tools Server", 55 | version: "reconnected", 56 | host: settings.serverHost, 57 | port: settings.serverPort, 58 | }); 59 | } 60 | } else { 61 | // Connection lost, update UI to show disconnected 62 | serverConnected = false; 63 | updateConnectionBanner(false, null); 64 | } 65 | } 66 | 67 | if (message.type === "INITIATE_AUTO_DISCOVERY") { 68 | console.log( 69 | `Initiating auto-discovery after page refresh (reason: ${message.reason})` 70 | ); 71 | 72 | // For page refreshes or if forceRestart is set to true, always cancel any ongoing discovery and restart 73 | if (message.reason === "page_refresh" || message.forceRestart === true) { 74 | // Cancel any ongoing discovery operation 75 | cancelOngoingDiscovery(); 76 | 77 | // Update UI to indicate we're starting a fresh scan 78 | if (connectionStatusDiv) { 79 | connectionStatusDiv.style.display = "block"; 80 | if (statusIcon) statusIcon.className = "status-indicator"; 81 | if (statusText) 82 | statusText.textContent = 83 | "Page refreshed. Restarting server discovery..."; 84 | } 85 | 86 | // Always update the connection banner when a page refresh occurs 87 | updateConnectionBanner(false, null); 88 | 89 | // Start a new discovery process with quiet mode 90 | console.log("Starting fresh discovery after page refresh"); 91 | discoverServer(true); 92 | } 93 | // For other types of auto-discovery requests, only start if not already in progress 94 | else if (!isDiscoveryInProgress) { 95 | // Use quiet mode for auto-discovery to minimize UI changes 96 | discoverServer(true); 97 | } 98 | } 99 | 100 | // Handle successful server validation 101 | if (message.type === "SERVER_VALIDATION_SUCCESS") { 102 | console.log( 103 | `Server validation successful: ${message.serverHost}:${message.serverPort}` 104 | ); 105 | 106 | // Update the connection status banner 107 | serverConnected = true; 108 | updateConnectionBanner(true, message.serverInfo); 109 | 110 | // If we were showing the connection status dialog, we can hide it now 111 | if (connectionStatusDiv && connectionStatusDiv.style.display === "block") { 112 | connectionStatusDiv.style.display = "none"; 113 | } 114 | } 115 | 116 | // Handle failed server validation 117 | if (message.type === "SERVER_VALIDATION_FAILED") { 118 | console.log( 119 | `Server validation failed: ${message.reason} - ${message.serverHost}:${message.serverPort}` 120 | ); 121 | 122 | // Update the connection status 123 | serverConnected = false; 124 | updateConnectionBanner(false, null); 125 | 126 | // Start auto-discovery if this was a page refresh validation 127 | if ( 128 | message.reason === "connection_error" || 129 | message.reason === "http_error" 130 | ) { 131 | // If we're not already trying to discover the server, start the process 132 | if (!isDiscoveryInProgress) { 133 | console.log("Starting auto-discovery after validation failure"); 134 | discoverServer(true); 135 | } 136 | } 137 | } 138 | 139 | // Handle successful WebSocket connection 140 | if (message.type === "WEBSOCKET_CONNECTED") { 141 | console.log( 142 | `WebSocket connected to ${message.serverHost}:${message.serverPort}` 143 | ); 144 | 145 | // Update connection status if it wasn't already connected 146 | if (!serverConnected) { 147 | serverConnected = true; 148 | updateConnectionBanner(true, { 149 | name: "Browser Tools Server", 150 | version: "connected via WebSocket", 151 | host: message.serverHost, 152 | port: message.serverPort, 153 | }); 154 | } 155 | } 156 | }); 157 | 158 | // Create connection status banner 159 | function createConnectionBanner() { 160 | // Check if banner already exists 161 | if (document.getElementById("connection-banner")) { 162 | return; 163 | } 164 | 165 | // Create the banner 166 | const banner = document.createElement("div"); 167 | banner.id = "connection-banner"; 168 | banner.style.cssText = ` 169 | padding: 6px 0px; 170 | margin-bottom: 4px; 171 | width: 40%; 172 | display: flex; 173 | flex-direction: column; 174 | align-items: flex-start; 175 | background-color:rgba(0,0,0,0); 176 | border-radius: 11px; 177 | font-size: 11px; 178 | font-weight: 500; 179 | color: #ffffff; 180 | `; 181 | 182 | // Create reconnect button (now placed at the top) 183 | const reconnectButton = document.createElement("button"); 184 | reconnectButton.id = "banner-reconnect-btn"; 185 | reconnectButton.textContent = "Reconnect"; 186 | reconnectButton.style.cssText = ` 187 | background-color: #333333; 188 | color: #ffffff; 189 | border: 1px solid #444444; 190 | border-radius: 3px; 191 | padding: 2px 8px; 192 | font-size: 10px; 193 | cursor: pointer; 194 | margin-bottom: 6px; 195 | align-self: flex-start; 196 | display: none; 197 | transition: background-color 0.2s; 198 | `; 199 | reconnectButton.addEventListener("mouseover", () => { 200 | reconnectButton.style.backgroundColor = "#444444"; 201 | }); 202 | reconnectButton.addEventListener("mouseout", () => { 203 | reconnectButton.style.backgroundColor = "#333333"; 204 | }); 205 | reconnectButton.addEventListener("click", () => { 206 | // Hide the button while reconnecting 207 | reconnectButton.style.display = "none"; 208 | reconnectButton.textContent = "Reconnecting..."; 209 | 210 | // Update UI to show searching state 211 | updateConnectionBanner(false, null); 212 | 213 | // Try to discover server 214 | discoverServer(false); 215 | }); 216 | 217 | // Create a container for the status indicator and text 218 | const statusContainer = document.createElement("div"); 219 | statusContainer.style.cssText = ` 220 | display: flex; 221 | align-items: center; 222 | width: 100%; 223 | `; 224 | 225 | // Create status indicator 226 | const indicator = document.createElement("div"); 227 | indicator.id = "banner-status-indicator"; 228 | indicator.style.cssText = ` 229 | width: 6px; 230 | height: 6px; 231 | position: relative; 232 | top: 1px; 233 | border-radius: 50%; 234 | background-color: #ccc; 235 | margin-right: 8px; 236 | flex-shrink: 0; 237 | transition: background-color 0.3s ease; 238 | `; 239 | 240 | // Create status text 241 | const statusText = document.createElement("div"); 242 | statusText.id = "banner-status-text"; 243 | statusText.textContent = "Searching for server..."; 244 | statusText.style.cssText = 245 | "flex-grow: 1; font-weight: 400; letter-spacing: 0.1px; font-size: 11px;"; 246 | 247 | // Add elements to statusContainer 248 | statusContainer.appendChild(indicator); 249 | statusContainer.appendChild(statusText); 250 | 251 | // Add elements to banner - reconnect button first, then status container 252 | banner.appendChild(reconnectButton); 253 | banner.appendChild(statusContainer); 254 | 255 | // Add banner to the beginning of the document body 256 | // This ensures it's the very first element 257 | document.body.prepend(banner); 258 | 259 | // Set initial state 260 | updateConnectionBanner(false, null); 261 | } 262 | 263 | // Update the connection banner with current status 264 | function updateConnectionBanner(connected, serverInfo) { 265 | const indicator = document.getElementById("banner-status-indicator"); 266 | const statusText = document.getElementById("banner-status-text"); 267 | const banner = document.getElementById("connection-banner"); 268 | const reconnectButton = document.getElementById("banner-reconnect-btn"); 269 | 270 | if (!indicator || !statusText || !banner || !reconnectButton) return; 271 | 272 | if (connected && serverInfo) { 273 | // Connected state with server info 274 | indicator.style.backgroundColor = "#4CAF50"; // Green indicator 275 | statusText.style.color = "#ffffff"; // White text for contrast on black 276 | statusText.textContent = `Connected to ${serverInfo.name} v${serverInfo.version} at ${settings.serverHost}:${settings.serverPort}`; 277 | 278 | // Hide reconnect button when connected 279 | reconnectButton.style.display = "none"; 280 | } else if (connected) { 281 | // Connected without server info 282 | indicator.style.backgroundColor = "#4CAF50"; // Green indicator 283 | statusText.style.color = "#ffffff"; // White text for contrast on black 284 | statusText.textContent = `Connected to server at ${settings.serverHost}:${settings.serverPort}`; 285 | 286 | // Hide reconnect button when connected 287 | reconnectButton.style.display = "none"; 288 | } else { 289 | // Disconnected state 290 | indicator.style.backgroundColor = "#F44336"; // Red indicator 291 | statusText.style.color = "#ffffff"; // White text for contrast on black 292 | 293 | // Only show "searching" message if discovery is in progress 294 | if (isDiscoveryInProgress) { 295 | statusText.textContent = "Not connected to server. Searching..."; 296 | // Hide reconnect button while actively searching 297 | reconnectButton.style.display = "none"; 298 | } else { 299 | statusText.textContent = "Not connected to server."; 300 | // Show reconnect button above status message when disconnected and not searching 301 | reconnectButton.style.display = "block"; 302 | reconnectButton.textContent = "Reconnect"; 303 | } 304 | } 305 | } 306 | 307 | // Initialize UI elements 308 | const logLimitInput = document.getElementById("log-limit"); 309 | const queryLimitInput = document.getElementById("query-limit"); 310 | const stringSizeLimitInput = document.getElementById("string-size-limit"); 311 | const showRequestHeadersCheckbox = document.getElementById( 312 | "show-request-headers" 313 | ); 314 | const showResponseHeadersCheckbox = document.getElementById( 315 | "show-response-headers" 316 | ); 317 | const maxLogSizeInput = document.getElementById("max-log-size"); 318 | const screenshotPathInput = document.getElementById("screenshot-path"); 319 | const captureScreenshotButton = document.getElementById("capture-screenshot"); 320 | 321 | // Server connection UI elements 322 | const serverHostInput = document.getElementById("server-host"); 323 | const serverPortInput = document.getElementById("server-port"); 324 | const discoverServerButton = document.getElementById("discover-server"); 325 | const testConnectionButton = document.getElementById("test-connection"); 326 | const connectionStatusDiv = document.getElementById("connection-status"); 327 | const statusIcon = document.getElementById("status-icon"); 328 | const statusText = document.getElementById("status-text"); 329 | 330 | // Initialize collapsible advanced settings 331 | const advancedSettingsHeader = document.getElementById( 332 | "advanced-settings-header" 333 | ); 334 | const advancedSettingsContent = document.getElementById( 335 | "advanced-settings-content" 336 | ); 337 | const chevronIcon = advancedSettingsHeader.querySelector(".chevron"); 338 | 339 | advancedSettingsHeader.addEventListener("click", () => { 340 | advancedSettingsContent.classList.toggle("visible"); 341 | chevronIcon.classList.toggle("open"); 342 | }); 343 | 344 | // Get all inputs by ID 345 | const allowAutoPasteCheckbox = document.getElementById("allow-auto-paste"); 346 | 347 | // Update UI from settings 348 | function updateUIFromSettings() { 349 | logLimitInput.value = settings.logLimit; 350 | queryLimitInput.value = settings.queryLimit; 351 | stringSizeLimitInput.value = settings.stringSizeLimit; 352 | showRequestHeadersCheckbox.checked = settings.showRequestHeaders; 353 | showResponseHeadersCheckbox.checked = settings.showResponseHeaders; 354 | maxLogSizeInput.value = settings.maxLogSize; 355 | screenshotPathInput.value = settings.screenshotPath; 356 | serverHostInput.value = settings.serverHost; 357 | serverPortInput.value = settings.serverPort; 358 | allowAutoPasteCheckbox.checked = settings.allowAutoPaste; 359 | } 360 | 361 | // Save settings 362 | function saveSettings() { 363 | chrome.storage.local.set({ browserConnectorSettings: settings }); 364 | // Notify devtools.js about settings change 365 | chrome.runtime.sendMessage({ 366 | type: "SETTINGS_UPDATED", 367 | settings, 368 | }); 369 | } 370 | 371 | // Add event listeners for all inputs 372 | logLimitInput.addEventListener("change", (e) => { 373 | settings.logLimit = parseInt(e.target.value, 10); 374 | saveSettings(); 375 | }); 376 | 377 | queryLimitInput.addEventListener("change", (e) => { 378 | settings.queryLimit = parseInt(e.target.value, 10); 379 | saveSettings(); 380 | }); 381 | 382 | stringSizeLimitInput.addEventListener("change", (e) => { 383 | settings.stringSizeLimit = parseInt(e.target.value, 10); 384 | saveSettings(); 385 | }); 386 | 387 | showRequestHeadersCheckbox.addEventListener("change", (e) => { 388 | settings.showRequestHeaders = e.target.checked; 389 | saveSettings(); 390 | }); 391 | 392 | showResponseHeadersCheckbox.addEventListener("change", (e) => { 393 | settings.showResponseHeaders = e.target.checked; 394 | saveSettings(); 395 | }); 396 | 397 | maxLogSizeInput.addEventListener("change", (e) => { 398 | settings.maxLogSize = parseInt(e.target.value, 10); 399 | saveSettings(); 400 | }); 401 | 402 | screenshotPathInput.addEventListener("change", (e) => { 403 | settings.screenshotPath = e.target.value; 404 | saveSettings(); 405 | }); 406 | 407 | // Add event listeners for server settings 408 | serverHostInput.addEventListener("change", (e) => { 409 | settings.serverHost = e.target.value; 410 | saveSettings(); 411 | // Automatically test connection when host is changed 412 | testConnection(settings.serverHost, settings.serverPort); 413 | }); 414 | 415 | serverPortInput.addEventListener("change", (e) => { 416 | settings.serverPort = parseInt(e.target.value, 10); 417 | saveSettings(); 418 | // Automatically test connection when port is changed 419 | testConnection(settings.serverHost, settings.serverPort); 420 | }); 421 | 422 | // Add event listener for auto-paste checkbox 423 | allowAutoPasteCheckbox.addEventListener("change", (e) => { 424 | settings.allowAutoPaste = e.target.checked; 425 | saveSettings(); 426 | }); 427 | 428 | // Function to cancel any ongoing discovery operations 429 | function cancelOngoingDiscovery() { 430 | if (isDiscoveryInProgress) { 431 | console.log("Cancelling ongoing discovery operation"); 432 | 433 | // Abort any fetch requests in progress 434 | if (discoveryController) { 435 | try { 436 | discoveryController.abort(); 437 | } catch (error) { 438 | console.error("Error aborting discovery controller:", error); 439 | } 440 | discoveryController = null; 441 | } 442 | 443 | // Reset the discovery status 444 | isDiscoveryInProgress = false; 445 | 446 | // Update UI to indicate the operation was cancelled 447 | if ( 448 | statusText && 449 | connectionStatusDiv && 450 | connectionStatusDiv.style.display === "block" 451 | ) { 452 | statusText.textContent = "Server discovery operation cancelled"; 453 | } 454 | 455 | // Clear any pending network timeouts that might be part of the discovery process 456 | clearTimeout(reconnectAttemptTimeout); 457 | reconnectAttemptTimeout = null; 458 | 459 | console.log("Discovery operation cancelled successfully"); 460 | } 461 | } 462 | 463 | // Test server connection 464 | testConnectionButton.addEventListener("click", async () => { 465 | // Cancel any ongoing discovery operations before testing 466 | cancelOngoingDiscovery(); 467 | await testConnection(settings.serverHost, settings.serverPort); 468 | }); 469 | 470 | // Function to test server connection 471 | async function testConnection(host, port) { 472 | // Cancel any ongoing discovery operations 473 | cancelOngoingDiscovery(); 474 | 475 | connectionStatusDiv.style.display = "block"; 476 | statusIcon.className = "status-indicator"; 477 | statusText.textContent = "Testing connection..."; 478 | 479 | try { 480 | // Use the identity endpoint instead of .port for more reliable validation 481 | const response = await fetch(`http://${host}:${port}/.identity`, { 482 | signal: AbortSignal.timeout(5000), // 5 second timeout 483 | }); 484 | 485 | if (response.ok) { 486 | const identity = await response.json(); 487 | 488 | // Verify this is actually our server by checking the signature 489 | if (identity.signature !== "mcp-browser-connector-24x7") { 490 | statusIcon.className = "status-indicator status-disconnected"; 491 | statusText.textContent = `Connection failed: Found a server at ${host}:${port} but it's not the Browser Tools server`; 492 | serverConnected = false; 493 | updateConnectionBanner(false, null); 494 | scheduleReconnectAttempt(); 495 | return false; 496 | } 497 | 498 | statusIcon.className = "status-indicator status-connected"; 499 | statusText.textContent = `Connected successfully to ${identity.name} v${identity.version} at ${host}:${port}`; 500 | serverConnected = true; 501 | updateConnectionBanner(true, identity); 502 | 503 | // Clear any scheduled reconnect attempts 504 | if (reconnectAttemptTimeout) { 505 | clearTimeout(reconnectAttemptTimeout); 506 | reconnectAttemptTimeout = null; 507 | } 508 | 509 | // Update settings if different port was discovered 510 | if (parseInt(identity.port, 10) !== port) { 511 | console.log(`Detected different port: ${identity.port}`); 512 | settings.serverPort = parseInt(identity.port, 10); 513 | serverPortInput.value = settings.serverPort; 514 | saveSettings(); 515 | } 516 | 517 | return true; 518 | } else { 519 | statusIcon.className = "status-indicator status-disconnected"; 520 | statusText.textContent = `Connection failed: Server returned ${response.status}`; 521 | serverConnected = false; 522 | 523 | // Make sure isDiscoveryInProgress is false so the reconnect button will show 524 | isDiscoveryInProgress = false; 525 | 526 | // Now update the connection banner to show the reconnect button 527 | updateConnectionBanner(false, null); 528 | scheduleReconnectAttempt(); 529 | return false; 530 | } 531 | } catch (error) { 532 | statusIcon.className = "status-indicator status-disconnected"; 533 | statusText.textContent = `Connection failed: ${error.message}`; 534 | serverConnected = false; 535 | 536 | // Make sure isDiscoveryInProgress is false so the reconnect button will show 537 | isDiscoveryInProgress = false; 538 | 539 | // Now update the connection banner to show the reconnect button 540 | updateConnectionBanner(false, null); 541 | scheduleReconnectAttempt(); 542 | return false; 543 | } 544 | } 545 | 546 | // Schedule a reconnect attempt if server isn't found 547 | function scheduleReconnectAttempt() { 548 | // Clear any existing reconnect timeout 549 | if (reconnectAttemptTimeout) { 550 | clearTimeout(reconnectAttemptTimeout); 551 | } 552 | 553 | // Schedule a reconnect attempt in 30 seconds 554 | reconnectAttemptTimeout = setTimeout(() => { 555 | console.log("Attempting to reconnect to server..."); 556 | // Only show minimal UI during auto-reconnect 557 | discoverServer(true); 558 | }, 30000); // 30 seconds 559 | } 560 | 561 | // Helper function to try connecting to a server 562 | async function tryServerConnection(host, port) { 563 | try { 564 | // Check if the discovery process was cancelled 565 | if (!isDiscoveryInProgress) { 566 | return false; 567 | } 568 | 569 | // Create a local timeout that won't abort the entire discovery process 570 | const controller = new AbortController(); 571 | const timeoutId = setTimeout(() => { 572 | controller.abort(); 573 | }, 500); // 500ms timeout for each connection attempt 574 | 575 | try { 576 | // Use identity endpoint for validation 577 | const response = await fetch(`http://${host}:${port}/.identity`, { 578 | // Use a local controller for this specific request timeout 579 | // but also respect the global discovery cancellation 580 | signal: discoveryController 581 | ? AbortSignal.any([controller.signal, discoveryController.signal]) 582 | : controller.signal, 583 | }); 584 | 585 | clearTimeout(timeoutId); 586 | 587 | // Check again if discovery was cancelled during the fetch 588 | if (!isDiscoveryInProgress) { 589 | return false; 590 | } 591 | 592 | if (response.ok) { 593 | const identity = await response.json(); 594 | 595 | // Verify this is actually our server by checking the signature 596 | if (identity.signature !== "mcp-browser-connector-24x7") { 597 | console.log( 598 | `Found a server at ${host}:${port} but it's not the Browser Tools server` 599 | ); 600 | return false; 601 | } 602 | 603 | console.log(`Successfully found server at ${host}:${port}`); 604 | 605 | // Update settings with discovered server 606 | settings.serverHost = host; 607 | settings.serverPort = parseInt(identity.port, 10); 608 | serverHostInput.value = settings.serverHost; 609 | serverPortInput.value = settings.serverPort; 610 | saveSettings(); 611 | 612 | statusIcon.className = "status-indicator status-connected"; 613 | statusText.textContent = `Discovered ${identity.name} v${identity.version} at ${host}:${identity.port}`; 614 | 615 | // Update connection banner with server info 616 | updateConnectionBanner(true, identity); 617 | 618 | // Update connection status 619 | serverConnected = true; 620 | 621 | // Clear any scheduled reconnect attempts 622 | if (reconnectAttemptTimeout) { 623 | clearTimeout(reconnectAttemptTimeout); 624 | reconnectAttemptTimeout = null; 625 | } 626 | 627 | // End the discovery process 628 | isDiscoveryInProgress = false; 629 | 630 | // Successfully found server 631 | return true; 632 | } 633 | 634 | return false; 635 | } finally { 636 | clearTimeout(timeoutId); 637 | } 638 | } catch (error) { 639 | // Ignore connection errors during discovery 640 | // But check if it was an abort (cancellation) 641 | if (error.name === "AbortError") { 642 | // Check if this was due to the global discovery cancellation 643 | if (discoveryController && discoveryController.signal.aborted) { 644 | console.log("Connection attempt aborted by global cancellation"); 645 | return "aborted"; 646 | } 647 | // Otherwise it was just a timeout for this specific connection attempt 648 | return false; 649 | } 650 | console.log(`Connection error for ${host}:${port}: ${error.message}`); 651 | return false; 652 | } 653 | } 654 | 655 | // Server discovery function (extracted to be reusable) 656 | async function discoverServer(quietMode = false) { 657 | // Cancel any ongoing discovery operations before starting a new one 658 | cancelOngoingDiscovery(); 659 | 660 | // Create a new AbortController for this discovery process 661 | discoveryController = new AbortController(); 662 | isDiscoveryInProgress = true; 663 | 664 | // In quiet mode, we don't show the connection status until we either succeed or fail completely 665 | if (!quietMode) { 666 | connectionStatusDiv.style.display = "block"; 667 | statusIcon.className = "status-indicator"; 668 | statusText.textContent = "Discovering server..."; 669 | } 670 | 671 | // Always update the connection banner 672 | updateConnectionBanner(false, null); 673 | 674 | try { 675 | console.log("Starting server discovery process"); 676 | 677 | // Add an early cancellation listener that will respond to page navigation/refresh 678 | discoveryController.signal.addEventListener("abort", () => { 679 | console.log("Discovery aborted via AbortController signal"); 680 | isDiscoveryInProgress = false; 681 | }); 682 | 683 | // Common IPs to try (in order of likelihood) 684 | const hosts = ["localhost", "127.0.0.1"]; 685 | 686 | // Add the current configured host if it's not already in the list 687 | if ( 688 | !hosts.includes(settings.serverHost) && 689 | settings.serverHost !== "0.0.0.0" 690 | ) { 691 | hosts.unshift(settings.serverHost); // Put at the beginning for priority 692 | } 693 | 694 | // Add common local network IPs 695 | const commonLocalIps = ["192.168.0.", "192.168.1.", "10.0.0.", "10.0.1."]; 696 | for (const prefix of commonLocalIps) { 697 | for (let i = 1; i <= 5; i++) { 698 | // Reduced from 10 to 5 for efficiency 699 | hosts.push(`${prefix}${i}`); 700 | } 701 | } 702 | 703 | // Build port list in a smart order: 704 | // 1. Start with current configured port 705 | // 2. Add default port (3025) 706 | // 3. Add sequential ports around the default (for fallback detection) 707 | const ports = []; 708 | 709 | // Current configured port gets highest priority 710 | const configuredPort = parseInt(settings.serverPort, 10); 711 | ports.push(configuredPort); 712 | 713 | // Add default port if it's not the same as configured 714 | if (configuredPort !== 3025) { 715 | ports.push(3025); 716 | } 717 | 718 | // Add sequential fallback ports (from default up to default+10) 719 | for (let p = 3026; p <= 3035; p++) { 720 | if (p !== configuredPort) { 721 | // Avoid duplicates 722 | ports.push(p); 723 | } 724 | } 725 | 726 | // Remove duplicates 727 | const uniquePorts = [...new Set(ports)]; 728 | console.log("Will check ports:", uniquePorts); 729 | 730 | // Create a progress indicator 731 | let progress = 0; 732 | let totalChecked = 0; 733 | 734 | // Phase 1: Try the most likely combinations first (current host:port and localhost variants) 735 | console.log("Starting Phase 1: Quick check of high-priority hosts/ports"); 736 | const priorityHosts = hosts.slice(0, 2); // First two hosts are highest priority 737 | for (const host of priorityHosts) { 738 | // Check if discovery was cancelled 739 | if (!isDiscoveryInProgress) { 740 | console.log("Discovery process was cancelled during Phase 1"); 741 | return false; 742 | } 743 | 744 | // Try configured port first 745 | totalChecked++; 746 | if (!quietMode) { 747 | statusText.textContent = `Checking ${host}:${uniquePorts[0]}...`; 748 | } 749 | console.log(`Checking ${host}:${uniquePorts[0]}...`); 750 | const result = await tryServerConnection(host, uniquePorts[0]); 751 | 752 | // Check for cancellation or success 753 | if (result === "aborted" || !isDiscoveryInProgress) { 754 | console.log("Discovery process was cancelled"); 755 | return false; 756 | } else if (result === true) { 757 | console.log("Server found in priority check"); 758 | if (quietMode) { 759 | // In quiet mode, only show the connection banner but hide the status box 760 | connectionStatusDiv.style.display = "none"; 761 | } 762 | return true; // Successfully found server 763 | } 764 | 765 | // Then try default port if different 766 | if (uniquePorts.length > 1) { 767 | // Check if discovery was cancelled 768 | if (!isDiscoveryInProgress) { 769 | console.log("Discovery process was cancelled"); 770 | return false; 771 | } 772 | 773 | totalChecked++; 774 | if (!quietMode) { 775 | statusText.textContent = `Checking ${host}:${uniquePorts[1]}...`; 776 | } 777 | console.log(`Checking ${host}:${uniquePorts[1]}...`); 778 | const result = await tryServerConnection(host, uniquePorts[1]); 779 | 780 | // Check for cancellation or success 781 | if (result === "aborted" || !isDiscoveryInProgress) { 782 | console.log("Discovery process was cancelled"); 783 | return false; 784 | } else if (result === true) { 785 | console.log("Server found in priority check"); 786 | if (quietMode) { 787 | // In quiet mode, only show the connection banner but hide the status box 788 | connectionStatusDiv.style.display = "none"; 789 | } 790 | return true; // Successfully found server 791 | } 792 | } 793 | } 794 | 795 | // If we're in quiet mode and the quick checks failed, show the status now 796 | // as we move into more intensive scanning 797 | if (quietMode) { 798 | connectionStatusDiv.style.display = "block"; 799 | statusIcon.className = "status-indicator"; 800 | statusText.textContent = "Searching for server..."; 801 | } 802 | 803 | // Phase 2: Systematic scan of all combinations 804 | const totalAttempts = hosts.length * uniquePorts.length; 805 | console.log( 806 | `Starting Phase 2: Full scan (${totalAttempts} total combinations)` 807 | ); 808 | statusText.textContent = `Quick check failed. Starting full scan (${totalChecked}/${totalAttempts})...`; 809 | 810 | // First, scan through all ports on localhost/127.0.0.1 to find fallback ports quickly 811 | const localHosts = ["localhost", "127.0.0.1"]; 812 | for (const host of localHosts) { 813 | // Skip the first two ports on localhost if we already checked them in Phase 1 814 | const portsToCheck = uniquePorts.slice( 815 | localHosts.includes(host) && priorityHosts.includes(host) ? 2 : 0 816 | ); 817 | 818 | for (const port of portsToCheck) { 819 | // Check if discovery was cancelled 820 | if (!isDiscoveryInProgress) { 821 | console.log("Discovery process was cancelled during local port scan"); 822 | return false; 823 | } 824 | 825 | // Update progress 826 | progress++; 827 | totalChecked++; 828 | statusText.textContent = `Scanning local ports... (${totalChecked}/${totalAttempts}) - Trying ${host}:${port}`; 829 | console.log(`Checking ${host}:${port}...`); 830 | 831 | const result = await tryServerConnection(host, port); 832 | 833 | // Check for cancellation or success 834 | if (result === "aborted" || !isDiscoveryInProgress) { 835 | console.log("Discovery process was cancelled"); 836 | return false; 837 | } else if (result === true) { 838 | console.log(`Server found at ${host}:${port}`); 839 | return true; // Successfully found server 840 | } 841 | } 842 | } 843 | 844 | // Then scan all the remaining host/port combinations 845 | for (const host of hosts) { 846 | // Skip hosts we already checked 847 | if (localHosts.includes(host)) { 848 | continue; 849 | } 850 | 851 | for (const port of uniquePorts) { 852 | // Check if discovery was cancelled 853 | if (!isDiscoveryInProgress) { 854 | console.log("Discovery process was cancelled during remote scan"); 855 | return false; 856 | } 857 | 858 | // Update progress 859 | progress++; 860 | totalChecked++; 861 | statusText.textContent = `Scanning remote hosts... (${totalChecked}/${totalAttempts}) - Trying ${host}:${port}`; 862 | console.log(`Checking ${host}:${port}...`); 863 | 864 | const result = await tryServerConnection(host, port); 865 | 866 | // Check for cancellation or success 867 | if (result === "aborted" || !isDiscoveryInProgress) { 868 | console.log("Discovery process was cancelled"); 869 | return false; 870 | } else if (result === true) { 871 | console.log(`Server found at ${host}:${port}`); 872 | return true; // Successfully found server 873 | } 874 | } 875 | } 876 | 877 | console.log( 878 | `Discovery process completed, checked ${totalChecked} combinations, no server found` 879 | ); 880 | // If we get here, no server was found 881 | statusIcon.className = "status-indicator status-disconnected"; 882 | statusText.textContent = 883 | "No server found. Please check server is running and try again."; 884 | 885 | serverConnected = false; 886 | 887 | // End the discovery process first before updating the banner 888 | isDiscoveryInProgress = false; 889 | 890 | // Update the connection banner to show the reconnect button 891 | updateConnectionBanner(false, null); 892 | 893 | // Schedule a reconnect attempt 894 | scheduleReconnectAttempt(); 895 | 896 | return false; 897 | } catch (error) { 898 | console.error("Error during server discovery:", error); 899 | statusIcon.className = "status-indicator status-disconnected"; 900 | statusText.textContent = `Error discovering server: ${error.message}`; 901 | 902 | serverConnected = false; 903 | 904 | // End the discovery process first before updating the banner 905 | isDiscoveryInProgress = false; 906 | 907 | // Update the connection banner to show the reconnect button 908 | updateConnectionBanner(false, null); 909 | 910 | // Schedule a reconnect attempt 911 | scheduleReconnectAttempt(); 912 | 913 | return false; 914 | } finally { 915 | console.log("Discovery process finished"); 916 | // Always clean up, even if there was an error 917 | if (discoveryController) { 918 | discoveryController = null; 919 | } 920 | } 921 | } 922 | 923 | // Bind discover server button to the extracted function 924 | discoverServerButton.addEventListener("click", () => discoverServer(false)); 925 | 926 | // Screenshot capture functionality 927 | captureScreenshotButton.addEventListener("click", () => { 928 | captureScreenshotButton.textContent = "Capturing..."; 929 | 930 | // Send message to background script to capture screenshot 931 | chrome.runtime.sendMessage( 932 | { 933 | type: "CAPTURE_SCREENSHOT", 934 | tabId: chrome.devtools.inspectedWindow.tabId, 935 | screenshotPath: settings.screenshotPath, 936 | }, 937 | (response) => { 938 | console.log("Screenshot capture response:", response); 939 | if (!response) { 940 | captureScreenshotButton.textContent = "Failed to capture!"; 941 | console.error("Screenshot capture failed: No response received"); 942 | } else if (!response.success) { 943 | captureScreenshotButton.textContent = "Failed to capture!"; 944 | console.error("Screenshot capture failed:", response.error); 945 | } else { 946 | captureScreenshotButton.textContent = `Captured: ${response.title}`; 947 | console.log("Screenshot captured successfully:", response.path); 948 | } 949 | setTimeout(() => { 950 | captureScreenshotButton.textContent = "Capture Screenshot"; 951 | }, 2000); 952 | } 953 | ); 954 | }); 955 | 956 | // Add wipe logs functionality 957 | const wipeLogsButton = document.getElementById("wipe-logs"); 958 | wipeLogsButton.addEventListener("click", () => { 959 | const serverUrl = `http://${settings.serverHost}:${settings.serverPort}/wipelogs`; 960 | console.log(`Sending wipe request to ${serverUrl}`); 961 | 962 | fetch(serverUrl, { 963 | method: "POST", 964 | headers: { "Content-Type": "application/json" }, 965 | }) 966 | .then((response) => response.json()) 967 | .then((result) => { 968 | console.log("Logs wiped successfully:", result.message); 969 | wipeLogsButton.textContent = "Logs Wiped!"; 970 | setTimeout(() => { 971 | wipeLogsButton.textContent = "Wipe All Logs"; 972 | }, 2000); 973 | }) 974 | .catch((error) => { 975 | console.error("Failed to wipe logs:", error); 976 | wipeLogsButton.textContent = "Failed to Wipe Logs"; 977 | setTimeout(() => { 978 | wipeLogsButton.textContent = "Wipe All Logs"; 979 | }, 2000); 980 | }); 981 | }); 982 | ``` -------------------------------------------------------------------------------- /chrome-extension/devtools.js: -------------------------------------------------------------------------------- ```javascript 1 | // devtools.js 2 | 3 | // Store settings with defaults 4 | let settings = { 5 | logLimit: 50, 6 | queryLimit: 30000, 7 | stringSizeLimit: 500, 8 | maxLogSize: 20000, 9 | showRequestHeaders: false, 10 | showResponseHeaders: false, 11 | screenshotPath: "", // Add new setting for screenshot path 12 | serverHost: "localhost", // Default server host 13 | serverPort: 3025, // Default server port 14 | allowAutoPaste: false, // Default auto-paste setting 15 | }; 16 | 17 | // Keep track of debugger state 18 | let isDebuggerAttached = false; 19 | let attachDebuggerRetries = 0; 20 | const currentTabId = chrome.devtools.inspectedWindow.tabId; 21 | const MAX_ATTACH_RETRIES = 3; 22 | const ATTACH_RETRY_DELAY = 1000; // 1 second 23 | 24 | // Load saved settings on startup 25 | chrome.storage.local.get(["browserConnectorSettings"], (result) => { 26 | if (result.browserConnectorSettings) { 27 | settings = { ...settings, ...result.browserConnectorSettings }; 28 | } 29 | }); 30 | 31 | // Listen for settings updates 32 | chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { 33 | if (message.type === "SETTINGS_UPDATED") { 34 | settings = message.settings; 35 | 36 | // If server settings changed and we have a WebSocket, reconnect 37 | if ( 38 | ws && 39 | (message.settings.serverHost !== settings.serverHost || 40 | message.settings.serverPort !== settings.serverPort) 41 | ) { 42 | console.log("Server settings changed, reconnecting WebSocket..."); 43 | setupWebSocket(); 44 | } 45 | } 46 | 47 | // Handle connection status updates from page refreshes 48 | if (message.type === "CONNECTION_STATUS_UPDATE") { 49 | console.log( 50 | `DevTools received connection status update: ${ 51 | message.isConnected ? "Connected" : "Disconnected" 52 | }` 53 | ); 54 | 55 | // If connection is lost, try to reestablish WebSocket only if we had a previous connection 56 | if (!message.isConnected && ws) { 57 | console.log( 58 | "Connection lost after page refresh, will attempt to reconnect WebSocket" 59 | ); 60 | 61 | // Only reconnect if we actually have a WebSocket that might be stale 62 | if ( 63 | ws && 64 | (ws.readyState === WebSocket.CLOSED || 65 | ws.readyState === WebSocket.CLOSING) 66 | ) { 67 | console.log("WebSocket is already closed or closing, will reconnect"); 68 | setupWebSocket(); 69 | } 70 | } 71 | } 72 | 73 | // Handle auto-discovery requests after page refreshes 74 | if (message.type === "INITIATE_AUTO_DISCOVERY") { 75 | console.log( 76 | `DevTools initiating WebSocket reconnect after page refresh (reason: ${message.reason})` 77 | ); 78 | 79 | // For page refreshes with forceRestart, we should always reconnect if our current connection is not working 80 | if ( 81 | (message.reason === "page_refresh" || message.forceRestart === true) && 82 | (!ws || ws.readyState !== WebSocket.OPEN) 83 | ) { 84 | console.log( 85 | "Page refreshed and WebSocket not open - forcing reconnection" 86 | ); 87 | 88 | // Close existing WebSocket if any 89 | if (ws) { 90 | console.log("Closing existing WebSocket due to page refresh"); 91 | intentionalClosure = true; // Mark as intentional to prevent auto-reconnect 92 | try { 93 | ws.close(); 94 | } catch (e) { 95 | console.error("Error closing WebSocket:", e); 96 | } 97 | ws = null; 98 | intentionalClosure = false; // Reset flag 99 | } 100 | 101 | // Clear any pending reconnect timeouts 102 | if (wsReconnectTimeout) { 103 | clearTimeout(wsReconnectTimeout); 104 | wsReconnectTimeout = null; 105 | } 106 | 107 | // Try to reestablish the WebSocket connection 108 | setupWebSocket(); 109 | } 110 | } 111 | }); 112 | 113 | // Utility to recursively truncate strings in any data structure 114 | function truncateStringsInData(data, maxLength, depth = 0, path = "") { 115 | // Add depth limit to prevent circular references 116 | if (depth > 100) { 117 | console.warn("Max depth exceeded at path:", path); 118 | return "[MAX_DEPTH_EXCEEDED]"; 119 | } 120 | 121 | console.log(`Processing at path: ${path}, type:`, typeof data); 122 | 123 | if (typeof data === "string") { 124 | if (data.length > maxLength) { 125 | console.log( 126 | `Truncating string at path ${path} from ${data.length} to ${maxLength}` 127 | ); 128 | return data.substring(0, maxLength) + "... (truncated)"; 129 | } 130 | return data; 131 | } 132 | 133 | if (Array.isArray(data)) { 134 | console.log(`Processing array at path ${path} with length:`, data.length); 135 | return data.map((item, index) => 136 | truncateStringsInData(item, maxLength, depth + 1, `${path}[${index}]`) 137 | ); 138 | } 139 | 140 | if (typeof data === "object" && data !== null) { 141 | console.log( 142 | `Processing object at path ${path} with keys:`, 143 | Object.keys(data) 144 | ); 145 | const result = {}; 146 | for (const [key, value] of Object.entries(data)) { 147 | try { 148 | result[key] = truncateStringsInData( 149 | value, 150 | maxLength, 151 | depth + 1, 152 | path ? `${path}.${key}` : key 153 | ); 154 | } catch (e) { 155 | console.error(`Error processing key ${key} at path ${path}:`, e); 156 | result[key] = "[ERROR_PROCESSING]"; 157 | } 158 | } 159 | return result; 160 | } 161 | 162 | return data; 163 | } 164 | 165 | // Helper to calculate the size of an object 166 | function calculateObjectSize(obj) { 167 | return JSON.stringify(obj).length; 168 | } 169 | 170 | // Helper to process array of objects with size limit 171 | function processArrayWithSizeLimit(array, maxTotalSize, processFunc) { 172 | let currentSize = 0; 173 | const result = []; 174 | 175 | for (const item of array) { 176 | // Process the item first 177 | const processedItem = processFunc(item); 178 | const itemSize = calculateObjectSize(processedItem); 179 | 180 | // Check if adding this item would exceed the limit 181 | if (currentSize + itemSize > maxTotalSize) { 182 | console.log( 183 | `Reached size limit (${currentSize}/${maxTotalSize}), truncating array` 184 | ); 185 | break; 186 | } 187 | 188 | // Add item and update size 189 | result.push(processedItem); 190 | currentSize += itemSize; 191 | console.log( 192 | `Added item of size ${itemSize}, total size now: ${currentSize}` 193 | ); 194 | } 195 | 196 | return result; 197 | } 198 | 199 | // Modified processJsonString to handle arrays with size limit 200 | function processJsonString(jsonString, maxLength) { 201 | console.log("Processing string of length:", jsonString?.length); 202 | try { 203 | let parsed; 204 | try { 205 | parsed = JSON.parse(jsonString); 206 | console.log( 207 | "Successfully parsed as JSON, structure:", 208 | JSON.stringify(Object.keys(parsed)) 209 | ); 210 | } catch (e) { 211 | console.log("Not valid JSON, treating as string"); 212 | return truncateStringsInData(jsonString, maxLength, 0, "root"); 213 | } 214 | 215 | // If it's an array, process with size limit 216 | if (Array.isArray(parsed)) { 217 | console.log("Processing array of objects with size limit"); 218 | const processed = processArrayWithSizeLimit( 219 | parsed, 220 | settings.maxLogSize, 221 | (item) => truncateStringsInData(item, maxLength, 0, "root") 222 | ); 223 | const result = JSON.stringify(processed); 224 | console.log( 225 | `Processed array: ${parsed.length} -> ${processed.length} items` 226 | ); 227 | return result; 228 | } 229 | 230 | // Otherwise process as before 231 | const processed = truncateStringsInData(parsed, maxLength, 0, "root"); 232 | const result = JSON.stringify(processed); 233 | console.log("Processed JSON string length:", result.length); 234 | return result; 235 | } catch (e) { 236 | console.error("Error in processJsonString:", e); 237 | return jsonString.substring(0, maxLength) + "... (truncated)"; 238 | } 239 | } 240 | 241 | // Helper to send logs to browser-connector 242 | async function sendToBrowserConnector(logData) { 243 | if (!logData) { 244 | console.error("No log data provided to sendToBrowserConnector"); 245 | return; 246 | } 247 | 248 | // First, ensure we're connecting to the right server 249 | if (!(await validateServerIdentity())) { 250 | console.error( 251 | "Cannot send logs: Not connected to a valid browser tools server" 252 | ); 253 | return; 254 | } 255 | 256 | console.log("Sending log data to browser connector:", { 257 | type: logData.type, 258 | timestamp: logData.timestamp, 259 | }); 260 | 261 | // Process any string fields that might contain JSON 262 | const processedData = { ...logData }; 263 | 264 | if (logData.type === "network-request") { 265 | console.log("Processing network request"); 266 | if (processedData.requestBody) { 267 | console.log( 268 | "Request body size before:", 269 | processedData.requestBody.length 270 | ); 271 | processedData.requestBody = processJsonString( 272 | processedData.requestBody, 273 | settings.stringSizeLimit 274 | ); 275 | console.log("Request body size after:", processedData.requestBody.length); 276 | } 277 | if (processedData.responseBody) { 278 | console.log( 279 | "Response body size before:", 280 | processedData.responseBody.length 281 | ); 282 | processedData.responseBody = processJsonString( 283 | processedData.responseBody, 284 | settings.stringSizeLimit 285 | ); 286 | console.log( 287 | "Response body size after:", 288 | processedData.responseBody.length 289 | ); 290 | } 291 | } else if ( 292 | logData.type === "console-log" || 293 | logData.type === "console-error" 294 | ) { 295 | console.log("Processing console message"); 296 | if (processedData.message) { 297 | console.log("Message size before:", processedData.message.length); 298 | processedData.message = processJsonString( 299 | processedData.message, 300 | settings.stringSizeLimit 301 | ); 302 | console.log("Message size after:", processedData.message.length); 303 | } 304 | } 305 | 306 | // Add settings to the request 307 | const payload = { 308 | data: { 309 | ...processedData, 310 | timestamp: Date.now(), 311 | }, 312 | settings: { 313 | logLimit: settings.logLimit, 314 | queryLimit: settings.queryLimit, 315 | showRequestHeaders: settings.showRequestHeaders, 316 | showResponseHeaders: settings.showResponseHeaders, 317 | }, 318 | }; 319 | 320 | const finalPayloadSize = JSON.stringify(payload).length; 321 | console.log("Final payload size:", finalPayloadSize); 322 | 323 | if (finalPayloadSize > 1000000) { 324 | console.warn("Warning: Large payload detected:", finalPayloadSize); 325 | console.warn( 326 | "Payload preview:", 327 | JSON.stringify(payload).substring(0, 1000) + "..." 328 | ); 329 | } 330 | 331 | const serverUrl = `http://${settings.serverHost}:${settings.serverPort}/extension-log`; 332 | console.log(`Sending log to ${serverUrl}`); 333 | 334 | fetch(serverUrl, { 335 | method: "POST", 336 | headers: { "Content-Type": "application/json" }, 337 | body: JSON.stringify(payload), 338 | }) 339 | .then((response) => { 340 | if (!response.ok) { 341 | throw new Error(`HTTP error ${response.status}`); 342 | } 343 | return response.json(); 344 | }) 345 | .then((data) => { 346 | console.log("Log sent successfully:", data); 347 | }) 348 | .catch((error) => { 349 | console.error("Error sending log:", error); 350 | }); 351 | } 352 | 353 | // Validate server identity 354 | async function validateServerIdentity() { 355 | try { 356 | console.log( 357 | `Validating server identity at ${settings.serverHost}:${settings.serverPort}...` 358 | ); 359 | 360 | // Use fetch with a timeout to prevent long-hanging requests 361 | const response = await fetch( 362 | `http://${settings.serverHost}:${settings.serverPort}/.identity`, 363 | { 364 | signal: AbortSignal.timeout(3000), // 3 second timeout 365 | } 366 | ); 367 | 368 | if (!response.ok) { 369 | console.error( 370 | `Server identity validation failed: HTTP ${response.status}` 371 | ); 372 | 373 | // Notify about the connection failure 374 | chrome.runtime.sendMessage({ 375 | type: "SERVER_VALIDATION_FAILED", 376 | reason: "http_error", 377 | status: response.status, 378 | serverHost: settings.serverHost, 379 | serverPort: settings.serverPort, 380 | }); 381 | 382 | return false; 383 | } 384 | 385 | const identity = await response.json(); 386 | 387 | // Validate signature 388 | if (identity.signature !== "mcp-browser-connector-24x7") { 389 | console.error("Server identity validation failed: Invalid signature"); 390 | 391 | // Notify about the invalid signature 392 | chrome.runtime.sendMessage({ 393 | type: "SERVER_VALIDATION_FAILED", 394 | reason: "invalid_signature", 395 | serverHost: settings.serverHost, 396 | serverPort: settings.serverPort, 397 | }); 398 | 399 | return false; 400 | } 401 | 402 | console.log( 403 | `Server identity confirmed: ${identity.name} v${identity.version}` 404 | ); 405 | 406 | // Notify about successful validation 407 | chrome.runtime.sendMessage({ 408 | type: "SERVER_VALIDATION_SUCCESS", 409 | serverInfo: identity, 410 | serverHost: settings.serverHost, 411 | serverPort: settings.serverPort, 412 | }); 413 | 414 | return true; 415 | } catch (error) { 416 | console.error("Server identity validation failed:", error); 417 | 418 | // Notify about the connection error 419 | chrome.runtime.sendMessage({ 420 | type: "SERVER_VALIDATION_FAILED", 421 | reason: "connection_error", 422 | error: error.message, 423 | serverHost: settings.serverHost, 424 | serverPort: settings.serverPort, 425 | }); 426 | 427 | return false; 428 | } 429 | } 430 | 431 | // Function to clear logs on the server 432 | function wipeLogs() { 433 | console.log("Wiping all logs..."); 434 | 435 | const serverUrl = `http://${settings.serverHost}:${settings.serverPort}/wipelogs`; 436 | console.log(`Sending wipe request to ${serverUrl}`); 437 | 438 | fetch(serverUrl, { 439 | method: "POST", 440 | headers: { "Content-Type": "application/json" }, 441 | }) 442 | .then((response) => { 443 | if (!response.ok) { 444 | throw new Error(`HTTP error ${response.status}`); 445 | } 446 | return response.json(); 447 | }) 448 | .then((data) => { 449 | console.log("Logs wiped successfully:", data); 450 | }) 451 | .catch((error) => { 452 | console.error("Error wiping logs:", error); 453 | }); 454 | } 455 | 456 | // Listen for page refreshes 457 | chrome.devtools.network.onNavigated.addListener((url) => { 458 | console.log("Page navigated/refreshed - wiping logs"); 459 | wipeLogs(); 460 | 461 | // Send the new URL to the server 462 | if (ws && ws.readyState === WebSocket.OPEN && url) { 463 | console.log( 464 | "Chrome Extension: Sending page-navigated event with URL:", 465 | url 466 | ); 467 | ws.send( 468 | JSON.stringify({ 469 | type: "page-navigated", 470 | url: url, 471 | tabId: chrome.devtools.inspectedWindow.tabId, 472 | timestamp: Date.now(), 473 | }) 474 | ); 475 | } 476 | }); 477 | 478 | // 1) Listen for network requests 479 | chrome.devtools.network.onRequestFinished.addListener((request) => { 480 | if (request._resourceType === "xhr" || request._resourceType === "fetch") { 481 | request.getContent((responseBody) => { 482 | const entry = { 483 | type: "network-request", 484 | url: request.request.url, 485 | method: request.request.method, 486 | status: request.response.status, 487 | requestHeaders: request.request.headers, 488 | responseHeaders: request.response.headers, 489 | requestBody: request.request.postData?.text ?? "", 490 | responseBody: responseBody ?? "", 491 | }; 492 | sendToBrowserConnector(entry); 493 | }); 494 | } 495 | }); 496 | 497 | // Helper function to attach debugger 498 | async function attachDebugger() { 499 | // First check if we're already attached to this tab 500 | chrome.debugger.getTargets((targets) => { 501 | const isAlreadyAttached = targets.some( 502 | (target) => target.tabId === currentTabId && target.attached 503 | ); 504 | 505 | if (isAlreadyAttached) { 506 | console.log("Found existing debugger attachment, detaching first..."); 507 | // Force detach first to ensure clean state 508 | chrome.debugger.detach({ tabId: currentTabId }, () => { 509 | // Ignore any errors during detach 510 | if (chrome.runtime.lastError) { 511 | console.log("Error during forced detach:", chrome.runtime.lastError); 512 | } 513 | // Now proceed with fresh attachment 514 | performAttach(); 515 | }); 516 | } else { 517 | // No existing attachment, proceed directly 518 | performAttach(); 519 | } 520 | }); 521 | } 522 | 523 | function performAttach() { 524 | console.log("Performing debugger attachment to tab:", currentTabId); 525 | chrome.debugger.attach({ tabId: currentTabId }, "1.3", () => { 526 | if (chrome.runtime.lastError) { 527 | console.error("Failed to attach debugger:", chrome.runtime.lastError); 528 | isDebuggerAttached = false; 529 | return; 530 | } 531 | 532 | isDebuggerAttached = true; 533 | console.log("Debugger successfully attached"); 534 | 535 | // Add the event listener when attaching 536 | chrome.debugger.onEvent.addListener(consoleMessageListener); 537 | 538 | chrome.debugger.sendCommand( 539 | { tabId: currentTabId }, 540 | "Runtime.enable", 541 | {}, 542 | () => { 543 | if (chrome.runtime.lastError) { 544 | console.error("Failed to enable runtime:", chrome.runtime.lastError); 545 | return; 546 | } 547 | console.log("Runtime API successfully enabled"); 548 | } 549 | ); 550 | }); 551 | } 552 | 553 | // Helper function to detach debugger 554 | function detachDebugger() { 555 | // Remove the event listener first 556 | chrome.debugger.onEvent.removeListener(consoleMessageListener); 557 | 558 | // Check if debugger is actually attached before trying to detach 559 | chrome.debugger.getTargets((targets) => { 560 | const isStillAttached = targets.some( 561 | (target) => target.tabId === currentTabId && target.attached 562 | ); 563 | 564 | if (!isStillAttached) { 565 | console.log("Debugger already detached"); 566 | isDebuggerAttached = false; 567 | return; 568 | } 569 | 570 | chrome.debugger.detach({ tabId: currentTabId }, () => { 571 | if (chrome.runtime.lastError) { 572 | console.warn( 573 | "Warning during debugger detach:", 574 | chrome.runtime.lastError 575 | ); 576 | } 577 | isDebuggerAttached = false; 578 | console.log("Debugger detached"); 579 | }); 580 | }); 581 | } 582 | 583 | // Move the console message listener outside the panel creation 584 | const consoleMessageListener = (source, method, params) => { 585 | // Only process events for our tab 586 | if (source.tabId !== currentTabId) { 587 | return; 588 | } 589 | 590 | if (method === "Runtime.exceptionThrown") { 591 | const entry = { 592 | type: "console-error", 593 | message: 594 | params.exceptionDetails.exception?.description || 595 | JSON.stringify(params.exceptionDetails), 596 | level: "error", 597 | timestamp: Date.now(), 598 | }; 599 | console.log("Sending runtime exception:", entry); 600 | sendToBrowserConnector(entry); 601 | } 602 | 603 | if (method === "Runtime.consoleAPICalled") { 604 | // Process all arguments from the console call 605 | let formattedMessage = ""; 606 | const args = params.args || []; 607 | 608 | // Extract all arguments and combine them 609 | if (args.length > 0) { 610 | // Try to build a meaningful representation of all arguments 611 | try { 612 | formattedMessage = args 613 | .map((arg) => { 614 | // Handle different types of arguments 615 | if (arg.type === "string") { 616 | return arg.value; 617 | } else if (arg.type === "object" && arg.preview) { 618 | // For objects, include their preview or description 619 | return JSON.stringify(arg.preview); 620 | } else if (arg.description) { 621 | // Some objects have descriptions 622 | return arg.description; 623 | } else { 624 | // Fallback for other types 625 | return arg.value || arg.description || JSON.stringify(arg); 626 | } 627 | }) 628 | .join(" "); 629 | } catch (e) { 630 | // Fallback if processing fails 631 | console.error("Failed to process console arguments:", e); 632 | formattedMessage = 633 | args[0]?.value || "Unable to process console arguments"; 634 | } 635 | } 636 | 637 | const entry = { 638 | type: params.type === "error" ? "console-error" : "console-log", 639 | level: params.type, 640 | message: formattedMessage, 641 | timestamp: Date.now(), 642 | }; 643 | console.log("Sending console entry:", entry); 644 | sendToBrowserConnector(entry); 645 | } 646 | }; 647 | 648 | // 2) Use DevTools Protocol to capture console logs 649 | chrome.devtools.panels.create("BrowserToolsMCP", "", "panel.html", (panel) => { 650 | // Initial attach - we'll keep the debugger attached as long as DevTools is open 651 | attachDebugger(); 652 | 653 | // Handle panel showing 654 | panel.onShown.addListener((panelWindow) => { 655 | if (!isDebuggerAttached) { 656 | attachDebugger(); 657 | } 658 | }); 659 | }); 660 | 661 | // Clean up when DevTools closes 662 | window.addEventListener("unload", () => { 663 | // Detach debugger 664 | detachDebugger(); 665 | 666 | // Set intentional closure flag before closing 667 | intentionalClosure = true; 668 | 669 | if (ws) { 670 | try { 671 | ws.close(); 672 | } catch (e) { 673 | console.error("Error closing WebSocket during unload:", e); 674 | } 675 | ws = null; 676 | } 677 | 678 | if (wsReconnectTimeout) { 679 | clearTimeout(wsReconnectTimeout); 680 | wsReconnectTimeout = null; 681 | } 682 | 683 | if (heartbeatInterval) { 684 | clearInterval(heartbeatInterval); 685 | heartbeatInterval = null; 686 | } 687 | }); 688 | 689 | // Function to capture and send element data 690 | function captureAndSendElement() { 691 | chrome.devtools.inspectedWindow.eval( 692 | `(function() { 693 | const el = $0; // $0 is the currently selected element in DevTools 694 | if (!el) return null; 695 | 696 | const rect = el.getBoundingClientRect(); 697 | 698 | return { 699 | tagName: el.tagName, 700 | id: el.id, 701 | className: el.className, 702 | textContent: el.textContent?.substring(0, 100), 703 | attributes: Array.from(el.attributes).map(attr => ({ 704 | name: attr.name, 705 | value: attr.value 706 | })), 707 | dimensions: { 708 | width: rect.width, 709 | height: rect.height, 710 | top: rect.top, 711 | left: rect.left 712 | }, 713 | innerHTML: el.innerHTML.substring(0, 500) 714 | }; 715 | })()`, 716 | (result, isException) => { 717 | if (isException || !result) return; 718 | 719 | console.log("Element selected:", result); 720 | 721 | // Send to browser connector 722 | sendToBrowserConnector({ 723 | type: "selected-element", 724 | timestamp: Date.now(), 725 | element: result, 726 | }); 727 | } 728 | ); 729 | } 730 | 731 | // Listen for element selection in the Elements panel 732 | chrome.devtools.panels.elements.onSelectionChanged.addListener(() => { 733 | captureAndSendElement(); 734 | }); 735 | 736 | // WebSocket connection management 737 | let ws = null; 738 | let wsReconnectTimeout = null; 739 | let heartbeatInterval = null; 740 | const WS_RECONNECT_DELAY = 5000; // 5 seconds 741 | const HEARTBEAT_INTERVAL = 30000; // 30 seconds 742 | // Add a flag to track if we need to reconnect after identity validation 743 | let reconnectAfterValidation = false; 744 | // Track if we're intentionally closing the connection 745 | let intentionalClosure = false; 746 | 747 | // Function to send a heartbeat to keep the WebSocket connection alive 748 | function sendHeartbeat() { 749 | if (ws && ws.readyState === WebSocket.OPEN) { 750 | console.log("Chrome Extension: Sending WebSocket heartbeat"); 751 | ws.send(JSON.stringify({ type: "heartbeat" })); 752 | } 753 | } 754 | 755 | async function setupWebSocket() { 756 | // Clear any pending timeouts 757 | if (wsReconnectTimeout) { 758 | clearTimeout(wsReconnectTimeout); 759 | wsReconnectTimeout = null; 760 | } 761 | 762 | if (heartbeatInterval) { 763 | clearInterval(heartbeatInterval); 764 | heartbeatInterval = null; 765 | } 766 | 767 | // Close existing WebSocket if any 768 | if (ws) { 769 | // Set flag to indicate this is an intentional closure 770 | intentionalClosure = true; 771 | try { 772 | ws.close(); 773 | } catch (e) { 774 | console.error("Error closing existing WebSocket:", e); 775 | } 776 | ws = null; 777 | intentionalClosure = false; // Reset flag 778 | } 779 | 780 | // Validate server identity before connecting 781 | console.log("Validating server identity before WebSocket connection..."); 782 | const isValid = await validateServerIdentity(); 783 | 784 | if (!isValid) { 785 | console.error( 786 | "Cannot establish WebSocket: Not connected to a valid browser tools server" 787 | ); 788 | // Set flag to indicate we need to reconnect after a page refresh check 789 | reconnectAfterValidation = true; 790 | 791 | // Try again after delay 792 | wsReconnectTimeout = setTimeout(() => { 793 | console.log("Attempting to reconnect WebSocket after validation failure"); 794 | setupWebSocket(); 795 | }, WS_RECONNECT_DELAY); 796 | return; 797 | } 798 | 799 | // Reset reconnect flag since validation succeeded 800 | reconnectAfterValidation = false; 801 | 802 | const wsUrl = `ws://${settings.serverHost}:${settings.serverPort}/extension-ws`; 803 | console.log(`Connecting to WebSocket at ${wsUrl}`); 804 | 805 | try { 806 | ws = new WebSocket(wsUrl); 807 | 808 | ws.onopen = () => { 809 | console.log(`Chrome Extension: WebSocket connected to ${wsUrl}`); 810 | 811 | // Start heartbeat to keep connection alive 812 | heartbeatInterval = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL); 813 | 814 | // Notify that connection is successful 815 | chrome.runtime.sendMessage({ 816 | type: "WEBSOCKET_CONNECTED", 817 | serverHost: settings.serverHost, 818 | serverPort: settings.serverPort, 819 | }); 820 | 821 | // Send the current URL to the server right after connection 822 | // This ensures the server has the URL even if no navigation occurs 823 | chrome.runtime.sendMessage( 824 | { 825 | type: "GET_CURRENT_URL", 826 | tabId: chrome.devtools.inspectedWindow.tabId, 827 | }, 828 | (response) => { 829 | if (chrome.runtime.lastError) { 830 | console.error( 831 | "Chrome Extension: Error getting URL from background on connection:", 832 | chrome.runtime.lastError 833 | ); 834 | 835 | // If normal method fails, try fallback to chrome.tabs API directly 836 | tryFallbackGetUrl(); 837 | return; 838 | } 839 | 840 | if (response && response.url) { 841 | console.log( 842 | "Chrome Extension: Sending initial URL to server:", 843 | response.url 844 | ); 845 | 846 | // Send the URL to the server via the background script 847 | chrome.runtime.sendMessage({ 848 | type: "UPDATE_SERVER_URL", 849 | tabId: chrome.devtools.inspectedWindow.tabId, 850 | url: response.url, 851 | source: "initial_connection", 852 | }); 853 | } else { 854 | // If response exists but no URL, try fallback 855 | tryFallbackGetUrl(); 856 | } 857 | } 858 | ); 859 | 860 | // Fallback method to get URL directly 861 | function tryFallbackGetUrl() { 862 | console.log("Chrome Extension: Trying fallback method to get URL"); 863 | 864 | // Try to get the URL directly using the tabs API 865 | chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { 866 | if (chrome.runtime.lastError) { 867 | console.error( 868 | "Chrome Extension: Fallback URL retrieval failed:", 869 | chrome.runtime.lastError 870 | ); 871 | return; 872 | } 873 | 874 | if (tabs && tabs.length > 0 && tabs[0].url) { 875 | console.log( 876 | "Chrome Extension: Got URL via fallback method:", 877 | tabs[0].url 878 | ); 879 | 880 | // Send the URL to the server 881 | chrome.runtime.sendMessage({ 882 | type: "UPDATE_SERVER_URL", 883 | tabId: chrome.devtools.inspectedWindow.tabId, 884 | url: tabs[0].url, 885 | source: "fallback_method", 886 | }); 887 | } else { 888 | console.warn( 889 | "Chrome Extension: Could not retrieve URL through fallback method" 890 | ); 891 | } 892 | }); 893 | } 894 | }; 895 | 896 | ws.onerror = (error) => { 897 | console.error(`Chrome Extension: WebSocket error for ${wsUrl}:`, error); 898 | }; 899 | 900 | ws.onclose = (event) => { 901 | console.log(`Chrome Extension: WebSocket closed for ${wsUrl}:`, event); 902 | 903 | // Stop heartbeat 904 | if (heartbeatInterval) { 905 | clearInterval(heartbeatInterval); 906 | heartbeatInterval = null; 907 | } 908 | 909 | // Don't reconnect if this was an intentional closure 910 | if (intentionalClosure) { 911 | console.log( 912 | "Chrome Extension: Intentional WebSocket closure, not reconnecting" 913 | ); 914 | return; 915 | } 916 | 917 | // Only attempt to reconnect if the closure wasn't intentional 918 | // Code 1000 (Normal Closure) and 1001 (Going Away) are normal closures 919 | // Code 1005 often happens with clean closures in Chrome 920 | const isAbnormalClosure = !(event.code === 1000 || event.code === 1001); 921 | 922 | // Check if this was an abnormal closure or if we need to reconnect after validation 923 | if (isAbnormalClosure || reconnectAfterValidation) { 924 | console.log( 925 | `Chrome Extension: Will attempt to reconnect WebSocket (closure code: ${event.code})` 926 | ); 927 | 928 | // Try to reconnect after delay 929 | wsReconnectTimeout = setTimeout(() => { 930 | console.log( 931 | `Chrome Extension: Attempting to reconnect WebSocket to ${wsUrl}` 932 | ); 933 | setupWebSocket(); 934 | }, WS_RECONNECT_DELAY); 935 | } else { 936 | console.log( 937 | `Chrome Extension: Normal WebSocket closure, not reconnecting automatically` 938 | ); 939 | } 940 | }; 941 | 942 | ws.onmessage = async (event) => { 943 | try { 944 | const message = JSON.parse(event.data); 945 | 946 | // Don't log heartbeat responses to reduce noise 947 | if (message.type !== "heartbeat-response") { 948 | console.log("Chrome Extension: Received WebSocket message:", message); 949 | 950 | if (message.type === "server-shutdown") { 951 | console.log("Chrome Extension: Received server shutdown signal"); 952 | // Clear any reconnection attempts 953 | if (wsReconnectTimeout) { 954 | clearTimeout(wsReconnectTimeout); 955 | wsReconnectTimeout = null; 956 | } 957 | // Close the connection gracefully 958 | ws.close(1000, "Server shutting down"); 959 | return; 960 | } 961 | } 962 | 963 | if (message.type === "heartbeat-response") { 964 | // Just a heartbeat response, no action needed 965 | // Uncomment the next line for debug purposes only 966 | // console.log("Chrome Extension: Received heartbeat response"); 967 | } else if (message.type === "take-screenshot") { 968 | console.log("Chrome Extension: Taking screenshot..."); 969 | // Capture screenshot of the current tab 970 | chrome.tabs.captureVisibleTab(null, { format: "png" }, (dataUrl) => { 971 | if (chrome.runtime.lastError) { 972 | console.error( 973 | "Chrome Extension: Screenshot capture failed:", 974 | chrome.runtime.lastError 975 | ); 976 | ws.send( 977 | JSON.stringify({ 978 | type: "screenshot-error", 979 | error: chrome.runtime.lastError.message, 980 | requestId: message.requestId, 981 | }) 982 | ); 983 | return; 984 | } 985 | 986 | console.log("Chrome Extension: Screenshot captured successfully"); 987 | // Just send the screenshot data, let the server handle paths 988 | const response = { 989 | type: "screenshot-data", 990 | data: dataUrl, 991 | requestId: message.requestId, 992 | // Only include path if it's configured in settings 993 | ...(settings.screenshotPath && { path: settings.screenshotPath }), 994 | // Include auto-paste setting 995 | autoPaste: settings.allowAutoPaste, 996 | }; 997 | 998 | console.log("Chrome Extension: Sending screenshot data response", { 999 | ...response, 1000 | data: "[base64 data]", 1001 | }); 1002 | 1003 | ws.send(JSON.stringify(response)); 1004 | }); 1005 | } else if (message.type === "get-current-url") { 1006 | console.log("Chrome Extension: Received request for current URL"); 1007 | 1008 | // Get the current URL from the background script instead of inspectedWindow.eval 1009 | let retryCount = 0; 1010 | const maxRetries = 2; 1011 | 1012 | const requestCurrentUrl = () => { 1013 | chrome.runtime.sendMessage( 1014 | { 1015 | type: "GET_CURRENT_URL", 1016 | tabId: chrome.devtools.inspectedWindow.tabId, 1017 | }, 1018 | (response) => { 1019 | if (chrome.runtime.lastError) { 1020 | console.error( 1021 | "Chrome Extension: Error getting URL from background:", 1022 | chrome.runtime.lastError 1023 | ); 1024 | 1025 | // Retry logic 1026 | if (retryCount < maxRetries) { 1027 | retryCount++; 1028 | console.log( 1029 | `Retrying URL request (${retryCount}/${maxRetries})...` 1030 | ); 1031 | setTimeout(requestCurrentUrl, 500); // Wait 500ms before retrying 1032 | return; 1033 | } 1034 | 1035 | ws.send( 1036 | JSON.stringify({ 1037 | type: "current-url-response", 1038 | url: null, 1039 | tabId: chrome.devtools.inspectedWindow.tabId, 1040 | error: 1041 | "Failed to get URL from background: " + 1042 | chrome.runtime.lastError.message, 1043 | requestId: message.requestId, 1044 | }) 1045 | ); 1046 | return; 1047 | } 1048 | 1049 | if (response && response.success && response.url) { 1050 | console.log( 1051 | "Chrome Extension: Got URL from background:", 1052 | response.url 1053 | ); 1054 | ws.send( 1055 | JSON.stringify({ 1056 | type: "current-url-response", 1057 | url: response.url, 1058 | tabId: chrome.devtools.inspectedWindow.tabId, 1059 | requestId: message.requestId, 1060 | }) 1061 | ); 1062 | } else { 1063 | console.error( 1064 | "Chrome Extension: Invalid URL response from background:", 1065 | response 1066 | ); 1067 | 1068 | // Last resort - try to get URL directly from the tab 1069 | chrome.tabs.query( 1070 | { active: true, currentWindow: true }, 1071 | (tabs) => { 1072 | const url = tabs && tabs[0] && tabs[0].url; 1073 | console.log( 1074 | "Chrome Extension: Got URL directly from tab:", 1075 | url 1076 | ); 1077 | 1078 | ws.send( 1079 | JSON.stringify({ 1080 | type: "current-url-response", 1081 | url: url || null, 1082 | tabId: chrome.devtools.inspectedWindow.tabId, 1083 | error: 1084 | response?.error || 1085 | "Failed to get URL from background", 1086 | requestId: message.requestId, 1087 | }) 1088 | ); 1089 | } 1090 | ); 1091 | } 1092 | } 1093 | ); 1094 | }; 1095 | 1096 | requestCurrentUrl(); 1097 | } 1098 | } catch (error) { 1099 | console.error( 1100 | "Chrome Extension: Error processing WebSocket message:", 1101 | error 1102 | ); 1103 | } 1104 | }; 1105 | } catch (error) { 1106 | console.error("Error creating WebSocket:", error); 1107 | // Try again after delay 1108 | wsReconnectTimeout = setTimeout(setupWebSocket, WS_RECONNECT_DELAY); 1109 | } 1110 | } 1111 | 1112 | // Initialize WebSocket connection when DevTools opens 1113 | setupWebSocket(); 1114 | 1115 | // Clean up WebSocket when DevTools closes 1116 | window.addEventListener("unload", () => { 1117 | if (ws) { 1118 | ws.close(); 1119 | } 1120 | if (wsReconnectTimeout) { 1121 | clearTimeout(wsReconnectTimeout); 1122 | } 1123 | }); 1124 | ```