|
10 | 10 |
|
11 | 11 | import asyncio |
12 | 12 | import atexit |
| 13 | +import json |
13 | 14 | import logging |
14 | 15 | from asyncio import sleep |
15 | 16 | from datetime import datetime |
|
21 | 22 | from fastmcp import FastMCP |
22 | 23 | from fastmcp.utilities.types import Image |
23 | 24 | from mcp.types import ImageContent |
| 25 | +from mcp.types import TextContent |
24 | 26 |
|
25 | 27 | from holoviz_mcp.config.loader import get_config |
26 | 28 | from holoviz_mcp.panel_mcp.data import get_components as _get_components_org |
27 | 29 | from holoviz_mcp.panel_mcp.models import ComponentDetails |
28 | 30 | from holoviz_mcp.panel_mcp.models import ComponentSummary |
29 | 31 | from holoviz_mcp.panel_mcp.models import ComponentSummarySearchResult |
| 32 | +from holoviz_mcp.panel_mcp.models import ConsoleLogEntry |
30 | 33 | from holoviz_mcp.panel_mcp.models import ParameterInfo |
31 | 34 |
|
32 | 35 | # Create the FastMCP server |
@@ -502,79 +505,129 @@ def _get_playwright_manager() -> PlaywrightManager: |
502 | 505 |
|
503 | 506 |
|
504 | 507 | @mcp.tool() |
505 | | -async def take_screenshot( |
| 508 | +async def inspect_app( |
506 | 509 | url: str = "http://localhost:5006/", |
507 | 510 | width: int = 1920, |
508 | 511 | height: int = 1200, |
509 | 512 | full_page: bool = False, |
510 | 513 | delay: int = 2, |
511 | 514 | save_screenshot: bool | str = False, |
512 | | -) -> ImageContent: |
| 515 | + screenshot: bool = True, |
| 516 | + console_logs: bool = True, |
| 517 | + log_level: str | None = None, |
| 518 | +) -> list[TextContent | ImageContent]: |
513 | 519 | """ |
514 | | - Take a screenshot of the specified url. |
| 520 | + Inspect a running Panel app by capturing a screenshot and/or browser console logs. |
| 521 | +
|
| 522 | + Panel apps (especially custom components) often have JavaScript errors that are |
| 523 | + invisible to users and LLMs. This tool captures both a visual screenshot and the |
| 524 | + browser console output in a single call, making it easy to diagnose rendering |
| 525 | + issues, JS errors, and runtime warnings. |
515 | 526 |
|
516 | 527 | Arguments |
517 | 528 | ---------- |
518 | 529 | url : str, default="http://localhost:5006/" |
519 | | - The URL of the page to take a screenshot of. |
| 530 | + The URL of the page to inspect. |
520 | 531 | width : int, default=1920 |
521 | 532 | The width of the browser viewport. |
522 | 533 | height : int, default=1200 |
523 | 534 | The height of the browser viewport. |
524 | 535 | full_page : bool, default=False |
525 | 536 | Whether to capture the full scrollable page. |
526 | 537 | delay : int, default=2 |
527 | | - Seconds to wait after page load before taking the screenshot, to allow dynamic content to render. |
| 538 | + Seconds to wait after page load before capturing, to allow dynamic content to render. |
528 | 539 | save_screenshot : bool | str, default=False |
529 | 540 | Whether and where to save the screenshot to disk: |
530 | 541 | - True: Save to default screenshots directory (~/.holoviz-mcp/screenshots/) with auto-generated filename |
531 | 542 | - False: Don't save screenshot to disk (only return to AI) |
532 | 543 | - str: Save to specified absolute path (raises ValueError if path is not absolute) |
| 544 | + screenshot : bool, default=True |
| 545 | + Whether to capture a screenshot of the page. |
| 546 | + console_logs : bool, default=True |
| 547 | + Whether to capture browser console log messages. |
| 548 | + log_level : str | None, default=None |
| 549 | + Filter console logs by level. If None, all levels are captured. |
| 550 | + Common levels: 'log', 'info', 'warning', 'error', 'debug'. |
533 | 551 | """ |
| 552 | + if not screenshot and not console_logs: |
| 553 | + raise ValueError("At least one of 'screenshot' or 'console_logs' must be True.") |
| 554 | + |
534 | 555 | manager = _get_playwright_manager() |
535 | 556 | browser = await manager.get_browser() |
536 | 557 | page = await browser.new_page( |
537 | 558 | ignore_https_errors=True, |
538 | 559 | viewport={"width": width, "height": height}, |
539 | 560 | ) |
| 561 | + |
| 562 | + # Collect console log entries |
| 563 | + collected_logs: list[ConsoleLogEntry] = [] |
| 564 | + |
| 565 | + if console_logs: |
| 566 | + |
| 567 | + def _on_console(msg): |
| 568 | + collected_logs.append( |
| 569 | + ConsoleLogEntry( |
| 570 | + level=msg.type, |
| 571 | + message=msg.text, |
| 572 | + timestamp=datetime.now().isoformat(), |
| 573 | + ) |
| 574 | + ) |
| 575 | + |
| 576 | + page.on("console", _on_console) |
| 577 | + |
540 | 578 | try: |
541 | 579 | await page.goto(url, wait_until="networkidle") |
542 | 580 | await sleep(delay=delay) |
543 | | - buffer = await page.screenshot(type="png", full_page=full_page) |
| 581 | + buffer = await page.screenshot(type="png", full_page=full_page) if screenshot else None |
544 | 582 | finally: |
545 | 583 | await page.close() |
546 | 584 |
|
547 | | - # Coerce string "false"/"true" to bool (MCP clients may serialize bools as strings) |
548 | | - if isinstance(save_screenshot, str) and save_screenshot.lower() in ("true", "false"): |
549 | | - save_screenshot = save_screenshot.lower() == "true" |
550 | | - |
551 | | - # Handle screenshot saving |
552 | | - if save_screenshot: |
553 | | - if isinstance(save_screenshot, str): |
554 | | - # Custom path specified |
555 | | - save_path = Path(save_screenshot) |
556 | | - if not save_path.is_absolute(): |
557 | | - raise ValueError(f"save_screenshot path must be absolute, got: {save_screenshot}") |
558 | | - else: |
559 | | - # Default path - use screenshots_dir from config |
560 | | - screenshots_dir = _config.server.screenshots_dir |
561 | | - screenshots_dir.mkdir(parents=True, exist_ok=True) |
562 | | - |
563 | | - # Generate filename with timestamp and UUID |
564 | | - timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") |
565 | | - unique_id = str(uuid4())[:8] |
566 | | - filename = f"screenshot_{timestamp}_{unique_id}.png" |
567 | | - save_path = screenshots_dir / filename |
568 | | - |
569 | | - # Ensure parent directory exists |
570 | | - save_path.parent.mkdir(parents=True, exist_ok=True) |
571 | | - |
572 | | - # Write the screenshot to disk |
573 | | - save_path.write_bytes(buffer) |
574 | | - logger.info(f"Screenshot saved to: {save_path}") |
575 | | - |
576 | | - image = Image(data=buffer, format="png") |
577 | | - return image.to_image_content() |
| 585 | + result: list[TextContent | ImageContent] = [] |
| 586 | + |
| 587 | + # Handle screenshot saving and result |
| 588 | + if screenshot and buffer is not None: |
| 589 | + # Coerce string "false"/"true" to bool (MCP clients may serialize bools as strings) |
| 590 | + save = save_screenshot |
| 591 | + if isinstance(save, str) and save.lower() in ("true", "false"): |
| 592 | + save = save.lower() == "true" |
| 593 | + |
| 594 | + if save: |
| 595 | + if isinstance(save, str): |
| 596 | + # Custom path specified |
| 597 | + save_path = Path(save) |
| 598 | + if not save_path.is_absolute(): |
| 599 | + raise ValueError(f"save_screenshot path must be absolute, got: {save_screenshot}") |
| 600 | + else: |
| 601 | + # Default path - use screenshots_dir from config |
| 602 | + screenshots_dir = _config.server.screenshots_dir |
| 603 | + screenshots_dir.mkdir(parents=True, exist_ok=True) |
| 604 | + |
| 605 | + # Generate filename with timestamp and UUID |
| 606 | + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") |
| 607 | + unique_id = str(uuid4())[:8] |
| 608 | + filename = f"screenshot_{timestamp}_{unique_id}.png" |
| 609 | + save_path = screenshots_dir / filename |
| 610 | + |
| 611 | + # Ensure parent directory exists |
| 612 | + save_path.parent.mkdir(parents=True, exist_ok=True) |
| 613 | + |
| 614 | + # Write the screenshot to disk |
| 615 | + save_path.write_bytes(buffer) |
| 616 | + logger.info(f"Screenshot saved to: {save_path}") |
| 617 | + |
| 618 | + image = Image(data=buffer, format="png") |
| 619 | + result.append(image.to_image_content()) |
| 620 | + |
| 621 | + # Handle console logs result |
| 622 | + if console_logs: |
| 623 | + filtered_logs = collected_logs |
| 624 | + if log_level is not None: |
| 625 | + filtered_logs = [entry for entry in collected_logs if entry.level == log_level] |
| 626 | + |
| 627 | + logs_json = json.dumps([entry.model_dump() for entry in filtered_logs], indent=2) |
| 628 | + result.append(TextContent(type="text", text=logs_json)) |
| 629 | + |
| 630 | + return result |
578 | 631 |
|
579 | 632 |
|
580 | 633 | if __name__ == "__main__": |
|
0 commit comments