diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/docs/explanation/tools.md b/docs/explanation/tools.md index 1aa8085..a10ed3b 100644 --- a/docs/explanation/tools.md +++ b/docs/explanation/tools.md @@ -75,18 +75,36 @@ Tools for discovering and working with Panel components. **Demo**: [https://awesome-panel-holoviz-mcp-ui.hf.space/panel_get_component_parameters](https://awesome-panel-holoviz-mcp-ui.hf.space/panel_get_component_parameters) -### panel_take_screenshot +### panel_inspect_app -**Purpose**: Take a screenshot of your (panel) web app. +**Purpose**: Inspect your (Panel) web app by capturing a screenshot and/or browser console logs. **Parameters**: -- `url` (string): The url to take the screenshot of. Default is 'http://localhost:5006/' - -**Use Case**: Understand how the app looks - -**Returns**: ImageContent. - -**Example Query**: *"Take a screenshot of http://127.0.0.1:8000/"** +- `url` (string): The URL to inspect. Default is `http://localhost:5006/` +- `width` (int): The width of the browser viewport. Default is 1920 +- `height` (int): The height of the browser viewport. Default is 1200 +- `full_page` (bool): Whether to capture the full scrollable page. Default is False +- `delay` (int): Seconds to wait after page load before capturing. Default is 2 +- `save_screenshot` (bool | string): Whether and where to save the screenshot to disk. Default is False + - `True`: Save to default screenshots directory (`~/.holoviz-mcp/screenshots/`) with auto-generated filename + - `False`: Don't save screenshot to disk (only return to AI) + - `string`: Save to specified absolute path (raises ValueError if path is not absolute) +- `screenshot` (bool): Whether to capture a screenshot. Default is True +- `console_logs` (bool): Whether to capture browser console logs. Default is True +- `log_level` (string, optional): Filter console logs by level (e.g., `"error"`, `"warning"`, `"log"`) + +**Use Case**: Understand how the app looks, debug JavaScript errors, and inspect browser console output — all in a single call. + +**Returns**: A list containing: +- `ImageContent` (screenshot) when `screenshot=True` +- `TextContent` (console logs as JSON) when `console_logs=True` + +**Note**: At least one of `screenshot` or `console_logs` must be True. When `save_screenshot=True` or a path is provided, the screenshot is also saved to disk with a timestamp-based filename (e.g., `screenshot_2026-02-09_12-01-03_abc123.png`). + +**Example Queries**: +- *"Take a screenshot of http://127.0.0.1:8000/"* +- *"Check my Panel app for JavaScript errors"* +- *"Show me the console errors from my app"* ## HoloViews Tool diff --git a/src/holoviz_mcp/config/config.yaml b/src/holoviz_mcp/config/config.yaml index 8eeeaa6..077c1ed 100644 --- a/src/holoviz_mcp/config/config.yaml +++ b/src/holoviz_mcp/config/config.yaml @@ -167,6 +167,24 @@ docs: folders: docs/bokeh/source/docs: url_path: "/" + panel-live: + url: "https://github.com/panel-extensions/panel-live.git" + branch: main + url_transform: "plotly" + folders: + docs: + url_path: "" + base_url: "https://panel-extensions.github.io/panel-live" + reference_patterns: [] + panel-reactflow: + url: "https://github.com/panel-extensions/panel-reactflow.git" + branch: main + url_transform: "plotly" + folders: + docs: + url_path: "" + base_url: "https://panel-extensions.github.io/panel-reactflow" + reference_patterns: [] index_patterns: - "**/*.md" diff --git a/src/holoviz_mcp/config/models.py b/src/holoviz_mcp/config/models.py index 205f477..64a5b99 100644 --- a/src/holoviz_mcp/config/models.py +++ b/src/holoviz_mcp/config/models.py @@ -149,6 +149,9 @@ class ServerConfig(BaseModel): vector_db_path: Path = Field( default_factory=lambda: (_holoviz_mcp_user_dir() / "vector_db" / "chroma").expanduser(), description="Path to the Chroma vector database." ) + screenshots_dir: Path = Field( + default_factory=lambda: (_holoviz_mcp_user_dir() / "screenshots").expanduser(), description="Directory for saving screenshots from panel_inspect_app tool." + ) class DisplayConfig(BaseModel): diff --git a/src/holoviz_mcp/panel_mcp/models.py b/src/holoviz_mcp/panel_mcp/models.py index cf184e1..35fbe36 100644 --- a/src/holoviz_mcp/panel_mcp/models.py +++ b/src/holoviz_mcp/panel_mcp/models.py @@ -122,3 +122,11 @@ def to_base(self) -> ComponentSummary: package=self.package, description=self.description, ) + + +class ConsoleLogEntry(BaseModel): + """A single browser console log entry captured during app inspection.""" + + level: str = Field(description="Console message level: 'log', 'info', 'warning', 'error', 'debug', etc.") + message: str = Field(description="The text content of the console message.") + timestamp: Optional[str] = Field(default=None, description="ISO 8601 timestamp when the message was captured.") diff --git a/src/holoviz_mcp/panel_mcp/server.py b/src/holoviz_mcp/panel_mcp/server.py index fad2ec5..a8c490c 100644 --- a/src/holoviz_mcp/panel_mcp/server.py +++ b/src/holoviz_mcp/panel_mcp/server.py @@ -10,20 +10,26 @@ import asyncio import atexit +import json import logging from asyncio import sleep +from datetime import datetime from importlib.metadata import distributions +from pathlib import Path +from uuid import uuid4 from fastmcp import Context from fastmcp import FastMCP from fastmcp.utilities.types import Image from mcp.types import ImageContent +from mcp.types import TextContent from holoviz_mcp.config.loader import get_config from holoviz_mcp.panel_mcp.data import get_components as _get_components_org from holoviz_mcp.panel_mcp.models import ComponentDetails from holoviz_mcp.panel_mcp.models import ComponentSummary from holoviz_mcp.panel_mcp.models import ComponentSummarySearchResult +from holoviz_mcp.panel_mcp.models import ConsoleLogEntry from holoviz_mcp.panel_mcp.models import ParameterInfo # Create the FastMCP server @@ -499,14 +505,29 @@ def _get_playwright_manager() -> PlaywrightManager: @mcp.tool() -async def take_screenshot(url: str = "http://localhost:5006/", width: int = 1920, height: int = 1200, full_page: bool = False, delay: int = 2) -> ImageContent: +async def inspect_app( + url: str = "http://localhost:5006/", + width: int = 1920, + height: int = 1200, + full_page: bool = False, + delay: int = 2, + save_screenshot: bool | str = False, + screenshot: bool = True, + console_logs: bool = True, + log_level: str | None = None, +) -> list[TextContent | ImageContent]: """ - Take a screenshot of the specified url. + Inspect a running Panel app by capturing a screenshot and/or browser console logs. + + Panel apps (especially custom components) often have JavaScript errors that are + invisible to users and LLMs. This tool captures both a visual screenshot and the + browser console output in a single call, making it easy to diagnose rendering + issues, JS errors, and runtime warnings. Arguments ---------- url : str, default="http://localhost:5006/" - The URL of the page to take a screenshot of. + The URL of the page to inspect. width : int, default=1920 The width of the browser viewport. height : int, default=1200 @@ -514,23 +535,99 @@ async def take_screenshot(url: str = "http://localhost:5006/", width: int = 1920 full_page : bool, default=False Whether to capture the full scrollable page. delay : int, default=2 - Seconds to wait after page load before taking the screenshot, to allow dynamic content to render. + Seconds to wait after page load before capturing, to allow dynamic content to render. + save_screenshot : bool | str, default=False + Whether and where to save the screenshot to disk: + - True: Save to default screenshots directory (~/.holoviz-mcp/screenshots/) with auto-generated filename + - False: Don't save screenshot to disk (only return to AI) + - str: Save to specified absolute path (raises ValueError if path is not absolute) + screenshot : bool, default=True + Whether to capture a screenshot of the page. + console_logs : bool, default=True + Whether to capture browser console log messages. + log_level : str | None, default=None + Filter console logs by level. If None, all levels are captured. + Common levels: 'log', 'info', 'warning', 'error', 'debug'. """ + if not screenshot and not console_logs: + raise ValueError("At least one of 'screenshot' or 'console_logs' must be True.") + manager = _get_playwright_manager() browser = await manager.get_browser() page = await browser.new_page( ignore_https_errors=True, viewport={"width": width, "height": height}, ) + + # Collect console log entries + collected_logs: list[ConsoleLogEntry] = [] + + if console_logs: + + def _on_console(msg): + collected_logs.append( + ConsoleLogEntry( + level=msg.type, + message=msg.text, + timestamp=datetime.now().isoformat(), + ) + ) + + page.on("console", _on_console) + try: await page.goto(url, wait_until="networkidle") await sleep(delay=delay) - buffer = await page.screenshot(type="png", full_page=full_page) + buffer = await page.screenshot(type="png", full_page=full_page) if screenshot else None finally: await page.close() - image = Image(data=buffer, format="png") - return image.to_image_content() + result: list[TextContent | ImageContent] = [] + + # Handle screenshot saving and result + if screenshot and buffer is not None: + # Coerce string "false"/"true" to bool (MCP clients may serialize bools as strings) + save = save_screenshot + if isinstance(save, str) and save.lower() in ("true", "false"): + save = save.lower() == "true" + + if save: + if isinstance(save, str): + # Custom path specified + save_path = Path(save) + if not save_path.is_absolute(): + raise ValueError(f"save_screenshot path must be absolute, got: {save_screenshot}") + else: + # Default path - use screenshots_dir from config + screenshots_dir = _config.server.screenshots_dir + screenshots_dir.mkdir(parents=True, exist_ok=True) + + # Generate filename with timestamp and UUID + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + unique_id = str(uuid4())[:8] + filename = f"screenshot_{timestamp}_{unique_id}.png" + save_path = screenshots_dir / filename + + # Ensure parent directory exists + save_path.parent.mkdir(parents=True, exist_ok=True) + + # Write the screenshot to disk + save_path.write_bytes(buffer) + logger.info(f"Screenshot saved to: {save_path}") + + image = Image(data=buffer, format="png") + result.append(image.to_image_content()) + + # Handle console logs result + if console_logs: + filtered_logs = collected_logs + if log_level is not None: + filtered_logs = [entry for entry in collected_logs if entry.level == log_level] + + logs_json = json.dumps([entry.model_dump() for entry in filtered_logs], indent=2) + result.append(TextContent(type="text", text=logs_json)) + + return result if __name__ == "__main__": diff --git a/tests/test_panel_mcp.py b/tests/test_panel_mcp.py index 4f5d500..7bf11ee 100644 --- a/tests/test_panel_mcp.py +++ b/tests/test_panel_mcp.py @@ -17,9 +17,12 @@ works correctly with actual Panel installations. """ +import json + import pytest from fastmcp import Client from mcp.types import ImageContent +from mcp.types import TextContent from holoviz_mcp.panel_mcp.server import mcp @@ -243,7 +246,7 @@ async def test_server_tools_available(self): "search_components", "get_component", "get_component_parameters", - "take_screenshot", + "inspect_app", ] for tool in expected_tools: @@ -318,24 +321,161 @@ async def test_component_parameters_structure(self): # The actual structure depends on the implementation @pytest.mark.asyncio - async def test_take_screenshot_returns_image(self): - """Test the take_screenshot tool returns an image payload.""" + async def test_inspect_app_returns_screenshot_and_logs(self): + """Test inspect_app returns both screenshot and console logs by default.""" + pytest.importorskip("playwright.async_api") - playwright = pytest.importorskip("playwright.async_api") + import holoviz_mcp.panel_mcp.server as _srv - # Skip cleanly if the browser binary is not available - async with playwright.async_playwright() as p: - try: - browser = await p.chromium.launch(headless=True) - await browser.close() - except Exception as exc: # pragma: no cover - defensive skip - pytest.skip(f"Playwright chromium not available: {exc}") + # Reset the PlaywrightManager singleton so the browser is created + # in the current event loop context (avoids stale cross-context refs). + if _srv._playwright_manager is not None: + await _srv._playwright_manager.close() + _srv._playwright_manager = None + + client = Client(mcp) + async with client: + url = "data:text/html,

Hello

" + result = await client.call_tool("inspect_app", {"url": url}) + + # Should have both image and text content + assert len(result.content) == 2 + assert isinstance(result.content[0], ImageContent) + assert isinstance(result.content[1], TextContent) + + # Parse the console logs JSON + logs = json.loads(result.content[1].text) + assert isinstance(logs, list) + assert any(entry["message"] == "hello" for entry in logs) + + # Clean up to avoid cross-test browser leaks + if _srv._playwright_manager is not None: + await _srv._playwright_manager.close() + _srv._playwright_manager = None + + @pytest.mark.asyncio + async def test_inspect_app_screenshot_only(self): + """Test inspect_app with console_logs=False returns only a screenshot.""" + pytest.importorskip("playwright.async_api") + + client = Client(mcp) + async with client: + url = "data:text/html,

Hello

" + result = await client.call_tool("inspect_app", {"url": url, "console_logs": False}) + + assert len(result.content) == 1 + assert isinstance(result.content[0], ImageContent) + + import holoviz_mcp.panel_mcp.server as _srv + + if _srv._playwright_manager is not None: + await _srv._playwright_manager.close() + _srv._playwright_manager = None + + @pytest.mark.asyncio + async def test_inspect_app_console_logs_only(self): + """Test inspect_app with screenshot=False returns only console logs.""" + pytest.importorskip("playwright.async_api") + + client = Client(mcp) + async with client: + url = "data:text/html," + result = await client.call_tool("inspect_app", {"url": url, "screenshot": False}) + + assert len(result.content) == 1 + assert isinstance(result.content[0], TextContent) + + logs = json.loads(result.content[0].text) + assert any(entry["message"] == "test-msg" for entry in logs) + + import holoviz_mcp.panel_mcp.server as _srv + + if _srv._playwright_manager is not None: + await _srv._playwright_manager.close() + _srv._playwright_manager = None + + @pytest.mark.asyncio + async def test_inspect_app_log_level_filter(self): + """Test inspect_app filters console logs by level.""" + pytest.importorskip("playwright.async_api") client = Client(mcp) async with client: - url = "data:text/html,

Screenshot

" - result = await client.call_tool("take_screenshot", {"url": url}) + url = "data:text/html," + result = await client.call_tool("inspect_app", {"url": url, "screenshot": False, "log_level": "error"}) + + assert len(result.content) == 1 + logs = json.loads(result.content[0].text) + # Only error-level messages should be present + assert all(entry["level"] == "error" for entry in logs) + assert any(entry["message"] == "err-msg" for entry in logs) + + import holoviz_mcp.panel_mcp.server as _srv - image_content = result.content[0] + if _srv._playwright_manager is not None: + await _srv._playwright_manager.close() + _srv._playwright_manager = None + + @pytest.mark.asyncio + async def test_inspect_app_both_false_raises(self): + """Test inspect_app raises ValueError when both screenshot and console_logs are False.""" + pytest.importorskip("playwright.async_api") + + client = Client(mcp) + async with client: + with pytest.raises(Exception) as exc_info: + await client.call_tool("inspect_app", {"url": "data:text/html,", "screenshot": False, "console_logs": False}) + assert "at least one" in str(exc_info.value).lower() - assert isinstance(image_content, ImageContent) + @pytest.mark.asyncio + async def test_inspect_app_saves_to_default_location(self): + """Test that inspect_app saves screenshot to default location when save_screenshot=True.""" + import tempfile + from pathlib import Path + + pytest.importorskip("playwright.async_api") + + import holoviz_mcp.panel_mcp.server as _srv + + with tempfile.TemporaryDirectory() as tmpdir: + # Patch the module-level _config directly so the tool sees the change + original_screenshots_dir = _srv._config.server.screenshots_dir + _srv._config.server.screenshots_dir = Path(tmpdir) / "screenshots" + + try: + client = Client(mcp) + async with client: + url = "data:text/html,

Hello

" + result = await client.call_tool("inspect_app", {"url": url, "save_screenshot": True, "console_logs": False}) + + image_content = result.content[0] + assert isinstance(image_content, ImageContent) + + screenshots_dir = Path(tmpdir) / "screenshots" + assert screenshots_dir.exists() + saved_files = list(screenshots_dir.glob("screenshot_*.png")) + assert len(saved_files) == 1 + assert saved_files[0].stat().st_size > 0 + finally: + _srv._config.server.screenshots_dir = original_screenshots_dir + if _srv._playwright_manager is not None: + await _srv._playwright_manager.close() + _srv._playwright_manager = None + + @pytest.mark.asyncio + async def test_inspect_app_rejects_relative_path(self): + """Test that inspect_app raises ValueError for relative save_screenshot paths.""" + pytest.importorskip("playwright.async_api") + + import holoviz_mcp.panel_mcp.server as _srv + + try: + client = Client(mcp) + async with client: + with pytest.raises(Exception) as exc_info: + await client.call_tool("inspect_app", {"url": "data:text/html,", "save_screenshot": "./relative.png", "console_logs": False}) + assert "absolute" in str(exc_info.value).lower() + finally: + if _srv._playwright_manager is not None: + await _srv._playwright_manager.close() + _srv._playwright_manager = None