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