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/"** 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..625ec74 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 @@ -499,7 +502,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 = False, +) -> ImageContent: """ Take a screenshot of the specified url. @@ -515,6 +525,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=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) """ manager = _get_playwright_manager() browser = await manager.get_browser() @@ -529,6 +544,35 @@ async def take_screenshot(url: str = "http://localhost:5006/", width: int = 1920 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() 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()