From b2d0ff0739a4ced18f8a43afd39e62c078472c51 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 12:00:32 +0000 Subject: [PATCH 1/6] Initial plan From bcfd181d550349ff021279d6cd13fc4d8db2b766 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 12:49:56 +0000 Subject: [PATCH 2/6] Implement screenshot persistence feature with save_screenshot parameter Co-authored-by: MarcSkovMadsen <42288570+MarcSkovMadsen@users.noreply.github.com> --- src/holoviz_mcp/config/models.py | 3 + src/holoviz_mcp/panel_mcp/server.py | 43 ++++++++- tests/test_panel_mcp.py | 141 ++++++++++++++++++++++++++++ 3 files changed, 186 insertions(+), 1 deletion(-) diff --git a/src/holoviz_mcp/config/models.py b/src/holoviz_mcp/config/models.py index 205f477..11d5a29 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_take_screenshot tool." + ) class DisplayConfig(BaseModel): diff --git a/src/holoviz_mcp/panel_mcp/server.py b/src/holoviz_mcp/panel_mcp/server.py index fad2ec5..267573d 100644 --- a/src/holoviz_mcp/panel_mcp/server.py +++ b/src/holoviz_mcp/panel_mcp/server.py @@ -499,7 +499,14 @@ 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 take_screenshot( + url: str = "http://localhost:5006/", + width: int = 1920, + height: int = 1200, + full_page: bool = False, + delay: int = 2, + save_screenshot: bool | str = True, +) -> ImageContent: """ Take a screenshot of the specified url. @@ -515,6 +522,11 @@ async def take_screenshot(url: str = "http://localhost:5006/", width: int = 1920 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. + save_screenshot : bool | str, default=True + 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) """ manager = _get_playwright_manager() browser = await manager.get_browser() @@ -529,6 +541,35 @@ async def take_screenshot(url: str = "http://localhost:5006/", width: int = 1920 finally: await page.close() + # Handle screenshot saving + if save_screenshot: + from datetime import datetime + from pathlib import Path + from uuid import uuid4 + + if isinstance(save_screenshot, str): + # Custom path specified + save_path = Path(save_screenshot) + 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") return image.to_image_content() diff --git a/tests/test_panel_mcp.py b/tests/test_panel_mcp.py index 4f5d500..412ffe3 100644 --- a/tests/test_panel_mcp.py +++ b/tests/test_panel_mcp.py @@ -339,3 +339,144 @@ async def test_take_screenshot_returns_image(self): image_content = result.content[0] assert isinstance(image_content, ImageContent) + + @pytest.mark.asyncio + async def test_take_screenshot_saves_to_default_location(self): + """Test that take_screenshot saves to default location when save_screenshot=True.""" + import tempfile + from pathlib import Path + + playwright = pytest.importorskip("playwright.async_api") + + # 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}") + + # Use a temporary directory for this test + with tempfile.TemporaryDirectory() as tmpdir: + from holoviz_mcp.config.loader import get_config + + config = get_config() + original_screenshots_dir = config.server.screenshots_dir + config.server.screenshots_dir = Path(tmpdir) / "screenshots" + + try: + client = Client(mcp) + async with client: + url = "data:text/html,

Screenshot

" + result = await client.call_tool("take_screenshot", {"url": url, "save_screenshot": True}) + + # Verify image was returned + image_content = result.content[0] + assert isinstance(image_content, ImageContent) + + # Verify file was saved + 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: + config.server.screenshots_dir = original_screenshots_dir + + @pytest.mark.asyncio + async def test_take_screenshot_no_save_when_false(self): + """Test that take_screenshot doesn't save when save_screenshot=False.""" + import tempfile + from pathlib import Path + + playwright = pytest.importorskip("playwright.async_api") + + # 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}") + + # Use a temporary directory for this test + with tempfile.TemporaryDirectory() as tmpdir: + from holoviz_mcp.config.loader import get_config + + config = get_config() + original_screenshots_dir = config.server.screenshots_dir + config.server.screenshots_dir = Path(tmpdir) / "screenshots" + + try: + client = Client(mcp) + async with client: + url = "data:text/html,

Screenshot

" + result = await client.call_tool("take_screenshot", {"url": url, "save_screenshot": False}) + + # Verify image was returned + image_content = result.content[0] + assert isinstance(image_content, ImageContent) + + # Verify no file was saved + screenshots_dir = Path(tmpdir) / "screenshots" + if screenshots_dir.exists(): + saved_files = list(screenshots_dir.glob("screenshot_*.png")) + assert len(saved_files) == 0 + finally: + config.server.screenshots_dir = original_screenshots_dir + + @pytest.mark.asyncio + async def test_take_screenshot_saves_to_custom_path(self): + """Test that take_screenshot saves to custom absolute path.""" + import tempfile + from pathlib import Path + + playwright = pytest.importorskip("playwright.async_api") + + # 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}") + + # Use a temporary directory for this test + with tempfile.TemporaryDirectory() as tmpdir: + custom_path = str(Path(tmpdir) / "custom_screenshot.png") + + client = Client(mcp) + async with client: + url = "data:text/html,

Screenshot

" + result = await client.call_tool("take_screenshot", {"url": url, "save_screenshot": custom_path}) + + # Verify image was returned + image_content = result.content[0] + assert isinstance(image_content, ImageContent) + + # Verify file was saved to custom path + assert Path(custom_path).exists() + assert Path(custom_path).stat().st_size > 0 + + @pytest.mark.asyncio + async def test_take_screenshot_rejects_relative_path(self): + """Test that take_screenshot raises ValueError for relative paths.""" + playwright = pytest.importorskip("playwright.async_api") + + # 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}") + + client = Client(mcp) + async with client: + url = "data:text/html,

Screenshot

" + # Should raise ValueError for relative path + with pytest.raises(Exception) as exc_info: + await client.call_tool("take_screenshot", {"url": url, "save_screenshot": "./relative.png"}) + + # The exception should mention that path must be absolute + assert "absolute" in str(exc_info.value).lower() From 50515648858e79757ae22154b0d9cb3508eac675 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 12:50:21 +0000 Subject: [PATCH 3/6] Update documentation for panel_take_screenshot tool with new save_screenshot parameter Co-authored-by: MarcSkovMadsen <42288570+MarcSkovMadsen@users.noreply.github.com> --- docs/explanation/tools.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/explanation/tools.md b/docs/explanation/tools.md index 1aa8085..2aa6bf7 100644 --- a/docs/explanation/tools.md +++ b/docs/explanation/tools.md @@ -81,10 +81,20 @@ Tools for discovering and working with Panel components. **Parameters**: - `url` (string): The url to take the screenshot of. 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 taking the screenshot. Default is 2 +- `save_screenshot` (bool | string): Whether and where to save the screenshot to disk. Default is True + - `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) -**Use Case**: Understand how the app looks +**Use Case**: Understand how the app looks and save screenshots for later review -**Returns**: ImageContent. +**Returns**: ImageContent (screenshot image returned to AI). + +**Note**: 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 Query**: *"Take a screenshot of http://127.0.0.1:8000/"** From 8a8251d6811f64887f324e95d61918848c8ad386 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 14:58:55 +0000 Subject: [PATCH 4/6] Move imports to top of file for better practice Co-authored-by: MarcSkovMadsen <42288570+MarcSkovMadsen@users.noreply.github.com> --- src/holoviz_mcp/panel_mcp/server.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/holoviz_mcp/panel_mcp/server.py b/src/holoviz_mcp/panel_mcp/server.py index 267573d..8d99872 100644 --- a/src/holoviz_mcp/panel_mcp/server.py +++ b/src/holoviz_mcp/panel_mcp/server.py @@ -12,7 +12,10 @@ import atexit 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 @@ -543,10 +546,6 @@ async def take_screenshot( # Handle screenshot saving if save_screenshot: - from datetime import datetime - from pathlib import Path - from uuid import uuid4 - if isinstance(save_screenshot, str): # Custom path specified save_path = Path(save_screenshot) From 89f0f48e3a52ef5b5a055cf17a0cd5e5cc51278e Mon Sep 17 00:00:00 2001 From: Marc Skov Madsen Date: Mon, 9 Feb 2026 16:10:23 +0000 Subject: [PATCH 5/6] review feedback --- src/holoviz_mcp/panel_mcp/server.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/holoviz_mcp/panel_mcp/server.py b/src/holoviz_mcp/panel_mcp/server.py index 8d99872..625ec74 100644 --- a/src/holoviz_mcp/panel_mcp/server.py +++ b/src/holoviz_mcp/panel_mcp/server.py @@ -508,7 +508,7 @@ async def take_screenshot( height: int = 1200, full_page: bool = False, delay: int = 2, - save_screenshot: bool | str = True, + save_screenshot: bool | str = False, ) -> ImageContent: """ Take a screenshot of the specified url. @@ -525,7 +525,7 @@ async def take_screenshot( 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. - save_screenshot : bool | str, default=True + 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) @@ -544,6 +544,10 @@ async def take_screenshot( finally: await page.close() + # Coerce string "false"/"true" to bool (MCP clients may serialize bools as strings) + if isinstance(save_screenshot, str) and save_screenshot.lower() in ("true", "false"): + save_screenshot = save_screenshot.lower() == "true" + # Handle screenshot saving if save_screenshot: if isinstance(save_screenshot, str): @@ -555,7 +559,7 @@ async def take_screenshot( # 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] @@ -564,7 +568,7 @@ async def take_screenshot( # 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}") From 92bdf487ddceb4fb5940d8577d7d7acd69947bdf Mon Sep 17 00:00:00 2001 From: Marc Skov Madsen Date: Thu, 19 Feb 2026 18:03:39 +0000 Subject: [PATCH 6/6] browser tools --- CLAUDE.md | 1 + docs/explanation/tools.md | 26 ++- src/holoviz_mcp/config/config.yaml | 18 ++ src/holoviz_mcp/config/models.py | 2 +- src/holoviz_mcp/panel_mcp/models.py | 8 + src/holoviz_mcp/panel_mcp/server.py | 127 +++++++++----- tests/test_panel_mcp.py | 245 ++++++++++++++-------------- 7 files changed, 257 insertions(+), 170 deletions(-) create mode 100644 CLAUDE.md 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 2aa6bf7..a10ed3b 100644 --- a/docs/explanation/tools.md +++ b/docs/explanation/tools.md @@ -75,28 +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/' +- `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 taking the screenshot. Default is 2 -- `save_screenshot` (bool | string): Whether and where to save the screenshot to disk. Default is True +- `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 and save screenshots for later review +**Use Case**: Understand how the app looks, debug JavaScript errors, and inspect browser console output — all in a single call. -**Returns**: ImageContent (screenshot image returned to AI). +**Returns**: A list containing: +- `ImageContent` (screenshot) when `screenshot=True` +- `TextContent` (console logs as JSON) when `console_logs=True` -**Note**: 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`). +**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 Query**: *"Take a screenshot of http://127.0.0.1:8000/"** +**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 11d5a29..64a5b99 100644 --- a/src/holoviz_mcp/config/models.py +++ b/src/holoviz_mcp/config/models.py @@ -150,7 +150,7 @@ class ServerConfig(BaseModel): 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_take_screenshot tool." + default_factory=lambda: (_holoviz_mcp_user_dir() / "screenshots").expanduser(), description="Directory for saving screenshots from panel_inspect_app tool." ) 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 625ec74..a8c490c 100644 --- a/src/holoviz_mcp/panel_mcp/server.py +++ b/src/holoviz_mcp/panel_mcp/server.py @@ -10,6 +10,7 @@ import asyncio import atexit +import json import logging from asyncio import sleep from datetime import datetime @@ -21,12 +22,14 @@ 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 @@ -502,21 +505,29 @@ def _get_playwright_manager() -> PlaywrightManager: @mcp.tool() -async def take_screenshot( +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, -) -> ImageContent: + 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 @@ -524,57 +535,99 @@ async def take_screenshot( 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() - # Coerce string "false"/"true" to bool (MCP clients may serialize bools as strings) - if isinstance(save_screenshot, str) and save_screenshot.lower() in ("true", "false"): - save_screenshot = save_screenshot.lower() == "true" - - # Handle screenshot saving - if save_screenshot: - if isinstance(save_screenshot, str): - # Custom path specified - save_path = Path(save_screenshot) - 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") - 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 412ffe3..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,165 +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,

Screenshot

" - result = await client.call_tool("take_screenshot", {"url": url}) + url = "data:text/html,

Hello

" + result = await client.call_tool("inspect_app", {"url": url}) - image_content = result.content[0] + # Should have both image and text content + assert len(result.content) == 2 + assert isinstance(result.content[0], ImageContent) + assert isinstance(result.content[1], TextContent) - assert isinstance(image_content, ImageContent) + # 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_take_screenshot_saves_to_default_location(self): - """Test that take_screenshot saves to default location when save_screenshot=True.""" - import tempfile - from pathlib import Path + async def test_inspect_app_screenshot_only(self): + """Test inspect_app with console_logs=False returns only a screenshot.""" + pytest.importorskip("playwright.async_api") - playwright = 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}) - # 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}") + assert len(result.content) == 1 + assert isinstance(result.content[0], ImageContent) - # Use a temporary directory for this test - with tempfile.TemporaryDirectory() as tmpdir: - from holoviz_mcp.config.loader import get_config + import holoviz_mcp.panel_mcp.server as _srv - config = get_config() - original_screenshots_dir = config.server.screenshots_dir - config.server.screenshots_dir = Path(tmpdir) / "screenshots" + if _srv._playwright_manager is not None: + await _srv._playwright_manager.close() + _srv._playwright_manager = None - try: - client = Client(mcp) - async with client: - url = "data:text/html,

Screenshot

" - result = await client.call_tool("take_screenshot", {"url": url, "save_screenshot": True}) + @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") - # Verify image was returned - image_content = result.content[0] - assert isinstance(image_content, ImageContent) + client = Client(mcp) + async with client: + url = "data:text/html," + result = await client.call_tool("inspect_app", {"url": url, "screenshot": False}) - # Verify file was saved - 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: - config.server.screenshots_dir = original_screenshots_dir + 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," + 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 + + 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() @pytest.mark.asyncio - async def test_take_screenshot_no_save_when_false(self): - """Test that take_screenshot doesn't save when save_screenshot=False.""" + 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 - playwright = pytest.importorskip("playwright.async_api") + pytest.importorskip("playwright.async_api") - # 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}") + import holoviz_mcp.panel_mcp.server as _srv - # Use a temporary directory for this test with tempfile.TemporaryDirectory() as tmpdir: - from holoviz_mcp.config.loader import get_config - - config = get_config() - original_screenshots_dir = config.server.screenshots_dir - config.server.screenshots_dir = Path(tmpdir) / "screenshots" + # 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,

Screenshot

" - result = await client.call_tool("take_screenshot", {"url": url, "save_screenshot": False}) + url = "data:text/html,

Hello

" + result = await client.call_tool("inspect_app", {"url": url, "save_screenshot": True, "console_logs": False}) - # Verify image was returned image_content = result.content[0] assert isinstance(image_content, ImageContent) - # Verify no file was saved screenshots_dir = Path(tmpdir) / "screenshots" - if screenshots_dir.exists(): - saved_files = list(screenshots_dir.glob("screenshot_*.png")) - assert len(saved_files) == 0 + 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: - config.server.screenshots_dir = original_screenshots_dir + _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_take_screenshot_saves_to_custom_path(self): - """Test that take_screenshot saves to custom absolute path.""" - import tempfile - from pathlib import Path + 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") - playwright = pytest.importorskip("playwright.async_api") - - # 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}") - - # Use a temporary directory for this test - with tempfile.TemporaryDirectory() as tmpdir: - custom_path = str(Path(tmpdir) / "custom_screenshot.png") + import holoviz_mcp.panel_mcp.server as _srv + try: client = Client(mcp) async with client: - url = "data:text/html,

Screenshot

" - result = await client.call_tool("take_screenshot", {"url": url, "save_screenshot": custom_path}) - - # Verify image was returned - image_content = result.content[0] - assert isinstance(image_content, ImageContent) - - # Verify file was saved to custom path - assert Path(custom_path).exists() - assert Path(custom_path).stat().st_size > 0 - - @pytest.mark.asyncio - async def test_take_screenshot_rejects_relative_path(self): - """Test that take_screenshot raises ValueError for relative paths.""" - playwright = pytest.importorskip("playwright.async_api") - - # 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}") - - client = Client(mcp) - async with client: - url = "data:text/html,

Screenshot

" - # Should raise ValueError for relative path - with pytest.raises(Exception) as exc_info: - await client.call_tool("take_screenshot", {"url": url, "save_screenshot": "./relative.png"}) - - # The exception should mention that path must be absolute - assert "absolute" in str(exc_info.value).lower() + 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