Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions docs/explanation/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/"**

Expand Down
3 changes: 3 additions & 0 deletions src/holoviz_mcp/config/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
46 changes: 45 additions & 1 deletion src/holoviz_mcp/panel_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand All @@ -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()
Expand All @@ -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()

Expand Down
141 changes: 141 additions & 0 deletions tests/test_panel_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,<html><body><h1>Screenshot</h1></body></html>"
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,<html><body><h1>Screenshot</h1></body></html>"
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,<html><body><h1>Screenshot</h1></body></html>"
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,<html><body><h1>Screenshot</h1></body></html>"
# 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()