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/5] 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/5] 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/5] 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/5] 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/5] 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}")