From de64ee1009d5d10f83c88f99bf97cf813d11de03 Mon Sep 17 00:00:00 2001 From: Marc Skov Madsen Date: Sat, 28 Feb 2026 18:53:48 +0000 Subject: [PATCH 1/4] tmp --- docs/explanation/display-system.md | 31 +++- docs/explanation/tools.md | 27 +++ src/holoviz_mcp/config/loader.py | 16 +- src/holoviz_mcp/display_mcp/database.py | 11 +- src/holoviz_mcp/display_mcp/endpoints.py | 32 +++- src/holoviz_mcp/holoviz_mcp/server.py | 169 ++++++++++++++++- .../holoviz_mcp/templates/show.html | 133 ++++++++++++++ .../holoviz_mcp/templates/show_pyodide.html | 124 +++++++++++++ tests/config/test_loader.py | 35 ++++ tests/display_mcp/test_database.py | 23 +++ tests/display_mcp/test_endpoints.py | 170 ++++++++++++++++++ tests/docs_mcp/test_docs_mcp.py | 146 +++++++++++++++ tests/test_server.py | 2 + 13 files changed, 899 insertions(+), 20 deletions(-) create mode 100644 src/holoviz_mcp/holoviz_mcp/templates/show.html create mode 100644 src/holoviz_mcp/holoviz_mcp/templates/show_pyodide.html create mode 100644 tests/display_mcp/test_endpoints.py diff --git a/docs/explanation/display-system.md b/docs/explanation/display-system.md index 8b09c682..0183c9b6 100644 --- a/docs/explanation/display-system.md +++ b/docs/explanation/display-system.md @@ -6,7 +6,7 @@ The Display Server is a component of the HoloViz MCP that enables AI assistants The Display System uses a decoupled architecture: -1. **MCP Server** (your main process): Hosts the `show` tool, connects via HTTP +1. **MCP Server** (your main process): Hosts the `show` and `show_pyodide` tools 2. **Display Server** (independent process): Executes Python code and serves web pages 3. **Browser** (user interface): Displays visualizations and management interfaces @@ -22,7 +22,14 @@ When you use the `show` tool: 3. Display Server stores the snippet in SQLite database 4. Display Server executes the code and captures output 5. MCP server returns URL to view visualization -6. User accesses visualization via URL in browser +6. User accesses visualization via URL in browser (or via in-chat MCP App UI when supported) + +When you use the `show_pyodide` tool: + +1. AI sends code to the MCP server via the tool +2. MCP server returns a payload for an MCP App resource +3. The host renders the app in a sandboxed iframe +4. panel-live runs the code in a browser/Pyodide runtime This decoupled architecture means: @@ -38,7 +45,7 @@ The `show` MCP tool is the primary interface for creating visualizations. It acc - **app** (required): Python code to execute - **name** (optional): Human-readable title for the visualization - **description** (optional): Explanation of what the code does -- **method** (optional): Execution method - "jupyter" (default) or "panel" +- **method** (optional): Execution method - "jupyter" (default), "panel", or "pyodide" The tool returns a response containing: @@ -48,6 +55,17 @@ The tool returns a response containing: The workflow is designed to be simple: send code, get URL, view in browser. +## The `show_pyodide` Tool + +The `show_pyodide` MCP tool is a browser-runtime path intended for panel-live/Pyodide rendering. +It does not depend on display-server code execution. + +It accepts: + +- **code** (required): Python code to run in panel-live +- **name** (optional): Human-readable title for the app +- **description** (optional): Explanation of what the code does + ## Why an Independent Server? Running visualizations in an independent server process provides several key benefits: @@ -81,7 +99,7 @@ A **snippet** is a stored code sample with metadata. Each snippet has: - Detected packages and Panel extensions - Execution method and timestamp -The Display System supports two execution methods: +The Display System supports three execution methods: ### Jupyter Method (Default) @@ -101,6 +119,11 @@ Executes code that explicitly calls `.servable()` on Panel components. This meth - Multiple objects can be served - Best for complex, interactive applications +### Pyodide Method + +Stores snippets with browser-runtime intent and skips Panel-extension inference. +This method is intended for `show_pyodide` and panel-live flows. + The method is automatically inferred from the code or can be specified explicitly. ## Database and URL Management diff --git a/docs/explanation/tools.md b/docs/explanation/tools.md index 46c00885..965bec48 100644 --- a/docs/explanation/tools.md +++ b/docs/explanation/tools.md @@ -140,6 +140,33 @@ Tools for accessing HoloViews documentation. Tools for searching and accessing HoloViz documentation. +### show + +**Purpose**: Execute visualization code via the display server and return a view URL (with MCP App rendering in capable hosts). + +**Parameters**: +- `code` (string): Python code to execute +- `name` (string, optional): Visualization name +- `description` (string, optional): Visualization description +- `method` (string, optional): `jupyter` (default), `panel`, or `pyodide` + +**Use Case**: Standard display-server backed visualization flow with URL fallback behavior. + +**Returns**: Success/error message containing a view URL. + +### show_pyodide + +**Purpose**: Return an MCP App payload for panel-live/Pyodide browser execution. + +**Parameters**: +- `code` (string): Python code to run in the browser runtime +- `name` (string, optional): App title +- `description` (string, optional): App description + +**Use Case**: Browser-only runtime path where server-side execution should be avoided. + +**Returns**: JSON text payload consumed by the linked MCP App resource. + ### search **Purpose**: Search HoloViz documentation using semantic similarity. diff --git a/src/holoviz_mcp/config/loader.py b/src/holoviz_mcp/config/loader.py index 21417ab8..ee954610 100644 --- a/src/holoviz_mcp/config/loader.py +++ b/src/holoviz_mcp/config/loader.py @@ -102,7 +102,7 @@ def _filter_known_fields(self, config_dict: dict[str, Any]) -> dict[str, Any]: This prevents validation errors when loading user config files that might contain extra fields. """ - known_fields = {"server", "docs", "resources", "prompts", "user_dir", "default_dir", "repos_dir"} + known_fields = {"server", "docs", "resources", "prompts", "display", "user_dir", "default_dir", "repos_dir"} return {k: v for k, v in config_dict.items() if k in known_fields} def _get_default_config(self) -> dict[str, Any]: @@ -123,6 +123,14 @@ def _get_default_config(self) -> dict[str, Any]: }, "resources": {"search_paths": []}, "prompts": {"search_paths": []}, + "display": { + "enabled": True, + "mode": "subprocess", + "port": 5005, + "host": "localhost", + "max_restarts": 3, + "health_check_interval": 60, + }, } def _load_yaml_file(self, file_path: Path) -> dict[str, Any]: @@ -272,6 +280,12 @@ def create_default_user_config(self) -> None: }, "resources": {"search_paths": []}, "prompts": {"search_paths": []}, + "display": { + "enabled": True, + "mode": "subprocess", + "port": 5005, + "host": "localhost", + }, } with open(config_file, "w", encoding="utf-8") as f: diff --git a/src/holoviz_mcp/display_mcp/database.py b/src/holoviz_mcp/display_mcp/database.py index 185ae115..89bca37c 100644 --- a/src/holoviz_mcp/display_mcp/database.py +++ b/src/holoviz_mcp/display_mcp/database.py @@ -40,7 +40,7 @@ class Snippet(BaseModel): name: str = Field(default="", description="User-provided name") description: str = Field(default="", description="Short description of the app") readme: str = Field(default="", description="Longer documentation describing the app") - method: Literal["jupyter", "panel"] = Field(..., description="Execution method") + method: Literal["jupyter", "panel", "pyodide"] = Field(..., description="Execution method") created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) status: Literal["pending", "success", "error"] = Field(default="pending") @@ -437,7 +437,7 @@ def create_visualization( name: str = "", description: str = "", readme: str = "", - method: Literal["jupyter", "panel"] = "jupyter", + method: Literal["jupyter", "panel", "pyodide"] = "jupyter", ) -> Snippet: """Create a visualization request. @@ -455,7 +455,7 @@ def create_visualization( readme : str, optional Longer documentation describing the app method : str, optional - Execution method: "jupyter" or "panel" + Execution method: "jupyter", "panel", or "pyodide" Returns ------- @@ -477,6 +477,11 @@ def create_visualization( if ".show(" in app: raise ValueError("`.show()` calls are not supported in this environment") + supported_methods = {"jupyter", "panel", "pyodide"} + if method not in supported_methods: + supported_text = ", ".join(sorted(supported_methods)) + raise ValueError(f"Unsupported execution method '{method}'. Supported methods: {supported_text}") + # Validate syntax ast.parse(app) # Raises SyntaxError if invalid diff --git a/src/holoviz_mcp/display_mcp/endpoints.py b/src/holoviz_mcp/display_mcp/endpoints.py index 121b9d06..45beed51 100644 --- a/src/holoviz_mcp/display_mcp/endpoints.py +++ b/src/holoviz_mcp/display_mcp/endpoints.py @@ -13,11 +13,39 @@ from tornado.web import RequestHandler +from holoviz_mcp.config.loader import get_config from holoviz_mcp.display_mcp.database import get_db logger = logging.getLogger(__name__) +def _get_external_base_url(request_host: str) -> str | None: + """Get external base URL for links returned to clients. + + Priority order: + 1. Jupyter server proxy URL + 2. GitHub Codespaces forwarded URL + 3. None (caller should fall back to request URL) + """ + jupyter_base = os.getenv("JUPYTER_SERVER_PROXY_URL") + if not jupyter_base: + try: + jupyter_base = get_config().server.jupyter_server_proxy_url + except Exception: + jupyter_base = "" + + if jupyter_base: + port = request_host.split(":")[-1] + return f"{jupyter_base.rstrip('/')}/{port}" + + if codespace_name := os.getenv("CODESPACE_NAME"): + port = request_host.split(":")[-1] + forwarding_domain = os.getenv("GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN", "app.github.dev") + return f"https://{codespace_name}-{port}.{forwarding_domain}" + + return None + + class SnippetEndpoint(RequestHandler): """Tornado RequestHandler for /api/snippet endpoint.""" @@ -44,9 +72,7 @@ def post(self): method=method, ) - if jupyter_base := os.getenv("JUPYTER_SERVER_PROXY_URL"): - port = self.request.host.split(":")[-1] - base_url = f"{jupyter_base.rstrip('/')}/{port}" + if base_url := _get_external_base_url(self.request.host): url = f"{base_url}/view?id={snippet.id}" else: full_url = self.request.full_url() diff --git a/src/holoviz_mcp/holoviz_mcp/server.py b/src/holoviz_mcp/holoviz_mcp/server.py index d01a6563..734ef957 100644 --- a/src/holoviz_mcp/holoviz_mcp/server.py +++ b/src/holoviz_mcp/holoviz_mcp/server.py @@ -9,14 +9,18 @@ import atexit import dataclasses import json +import os from contextlib import asynccontextmanager from pathlib import Path from typing import Literal from typing import Optional +from urllib.parse import urlparse from fastmcp import Context from fastmcp import FastMCP from fastmcp.resources import FileResource +from fastmcp.server.apps import AppConfig +from fastmcp.server.apps import ResourceCSP from fastmcp.utilities.types import Image from mcp.types import ImageContent from mcp.types import TextContent @@ -40,6 +44,40 @@ # Global display client instance (lazy-loaded) _display_client: Optional["DisplayClient"] = None +SHOW_RESOURCE_URI = "ui://holoviz-mcp/show.html" +SHOW_TEMPLATE_PATH = Path(__file__).parent / "templates" / "show.html" +SHOW_PYODIDE_RESOURCE_URI = "ui://holoviz-mcp/show-pyodide.html" +SHOW_PYODIDE_TEMPLATE_PATH = Path(__file__).parent / "templates" / "show_pyodide.html" + + +def _externalize_display_url(url: str) -> str: + """Convert local display URLs to externally reachable proxy/Codespaces URLs.""" + if not url: + return url + + parsed = urlparse(url) + host = (parsed.hostname or "").lower() + + if host not in {"localhost", "127.0.0.1"}: + return url + + port = parsed.port + if not port: + return url + + config = get_config() + + proxy_base = os.getenv("JUPYTER_SERVER_PROXY_URL") or config.server.jupyter_server_proxy_url + if proxy_base: + return f"{proxy_base.rstrip('/')}/{port}{parsed.path}" + (f"?{parsed.query}" if parsed.query else "") + + codespace_name = os.getenv("CODESPACE_NAME") + if codespace_name: + forwarding_domain = os.getenv("GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN", "app.github.dev") + return f"https://{codespace_name}-{port}.{forwarding_domain}{parsed.path}" + (f"?{parsed.query}" if parsed.query else "") + + return url + def _get_display_manager() -> Optional["PanelServerManager"]: """Get or create the Panel server manager (subprocess mode only).""" @@ -450,7 +488,28 @@ async def update_index(ctx: Context) -> str: return error_msg -@mcp.tool(name="show") +@mcp.resource( + SHOW_RESOURCE_URI, + app=AppConfig( + csp=ResourceCSP( + resource_domains=["https://unpkg.com"], + frame_domains=[ + "http://localhost", + "http://127.0.0.1", + "https://localhost", + "https://127.0.0.1", + "https://*.app.github.dev", + "https://*.github.dev", + ], + ) + ), +) +def show_view() -> str: + """Return the HTML resource used by the show MCP App.""" + return SHOW_TEMPLATE_PATH.read_text(encoding="utf-8") + + +@mcp.tool(name="show", app=AppConfig(resource_uri=SHOW_RESOURCE_URI)) async def display( code: str, name: str = "", @@ -485,7 +544,7 @@ async def display( Returns ------- str - URL to view the rendered visualization (e.g., http://localhost:5005/view?id=abc123) + JSON payload as text for MCP App rendering, including a visualization URL. Raises ------ @@ -551,17 +610,27 @@ async def display( description=description, method=method, ) - url = response.get("url", "") + url = _externalize_display_url(response.get("url", "")) # Check for errors in response - if error_message := response.get("error_message", None): - return f""" -Visualization created with errors. View here {url} + payload: dict[str, str] = { + "tool": "show", + "name": name, + "description": description, + "method": method, + "code": code, + "url": url, + } -{error_message} -""" + if error_message := response.get("error_message", None): + payload["status"] = "warning" + payload["message"] = "Visualization created with errors." + payload["error_message"] = str(error_message) + return json.dumps(payload) - return f"Visualization created successfully!\n\nView here {url}" + payload["status"] = "success" + payload["message"] = "Visualization created successfully." + return json.dumps(payload) except Exception as e: logger.exception(f"Error creating visualization: {e}") @@ -572,6 +641,88 @@ async def display( return f"Error: Failed to create visualization: {str(e)}" +@mcp.resource( + SHOW_PYODIDE_RESOURCE_URI, + app=AppConfig( + csp=ResourceCSP( + resource_domains=[ + "'unsafe-eval'", + "'wasm-unsafe-eval'", + "blob:", + "https://unpkg.com", + "https://panel-extensions.github.io", + "https://cdn.holoviz.org", + "https://cdn.jsdelivr.net", + "https://cdn.plot.ly", + "https://pyodide-cdn2.iodide.io", + "https://pypi.org", + "https://files.pythonhosted.org", + "https://cdn.bokeh.org", + ], + connect_domains=[ + "https://panel-extensions.github.io", + "https://cdn.holoviz.org", + "https://cdn.jsdelivr.net", + "https://cdn.plot.ly", + "https://pyodide-cdn2.iodide.io", + "https://pypi.org", + "https://files.pythonhosted.org", + "https://cdn.bokeh.org", + ], + ) + ), +) +def show_pyodide_view() -> str: + """Return the HTML resource used by the show_pyodide MCP App.""" + return SHOW_PYODIDE_TEMPLATE_PATH.read_text(encoding="utf-8") + + +@mcp.tool(name="show_pyodide", app=AppConfig(resource_uri=SHOW_PYODIDE_RESOURCE_URI)) +async def show_pyodide( + code: str, + name: str = "", + description: str = "", + ctx: Context | None = None, +) -> str: + """Display Python code as a panel-live Pyodide app in MCP Apps-capable clients. + + This tool is intended for browser/Pyodide rendering paths where direct + display-server execution should be avoided. It returns a payload consumed + by the linked MCP App resource. + + Parameters + ---------- + code : str + Python code to run inside panel-live/Pyodide runtime. + name : str, optional + Optional display name used by the app UI. + description : str, optional + Optional description shown in the app UI. + ctx : Context | None, optional + FastMCP execution context. + + Returns + ------- + str + JSON payload as text for the MCP App resource. + """ + if not code.strip(): + return json.dumps({"error": "Code is required for show_pyodide."}) + + payload = { + "tool": "show_pyodide", + "name": name, + "description": description, + "code": code, + "runtime": "panel-live-pyodide", + } + + if ctx: + await ctx.info("Prepared show_pyodide payload for MCP App rendering.") + + return json.dumps(payload) + + @mcp.tool() async def inspect( url: str = "http://localhost:5006/", diff --git a/src/holoviz_mcp/holoviz_mcp/templates/show.html b/src/holoviz_mcp/holoviz_mcp/templates/show.html new file mode 100644 index 00000000..7687e02a --- /dev/null +++ b/src/holoviz_mcp/holoviz_mcp/templates/show.html @@ -0,0 +1,133 @@ + + + + + + + HoloViz MCP · show + + + +
+
+ + +
+

Awaiting tool result…

+ +
+ + + + diff --git a/src/holoviz_mcp/holoviz_mcp/templates/show_pyodide.html b/src/holoviz_mcp/holoviz_mcp/templates/show_pyodide.html new file mode 100644 index 00000000..d5feef82 --- /dev/null +++ b/src/holoviz_mcp/holoviz_mcp/templates/show_pyodide.html @@ -0,0 +1,124 @@ + + + + + + + HoloViz MCP · show_pyodide + + + + + +
+
+

show_pyodide

+

Rendering with panel-live (Pyodide runtime).

+
+
Awaiting tool result…
+
+
+ + + + diff --git a/tests/config/test_loader.py b/tests/config/test_loader.py index 25cc4510..a28c6826 100644 --- a/tests/config/test_loader.py +++ b/tests/config/test_loader.py @@ -210,6 +210,41 @@ def test_config_merge_deep(self, config_loader: ConfigLoader): assert "repo1" in merged["docs"]["repositories"] assert "repo2" in merged["docs"]["repositories"] + def test_filter_known_fields_keeps_display(self, config_loader: ConfigLoader): + """Display config should not be filtered out as unknown.""" + filtered = config_loader._filter_known_fields( + { + "display": {"enabled": False}, + "server": {"name": "test"}, + "unknown": {"x": 1}, + } + ) + + assert "display" in filtered + assert "unknown" not in filtered + + def test_user_display_config_override(self, config_loader: ConfigLoader, test_config: HoloVizMCPConfig): + """User config display settings are merged and applied.""" + config_file = test_config.config_file_path() + config_file.parent.mkdir(parents=True, exist_ok=True) + + user_config = { + "display": { + "enabled": False, + "mode": "remote", + "server_url": "http://localhost:5008", + } + } + + with open(config_file, "w") as f: + yaml.dump(user_config, f) + + config = config_loader.load_config() + + assert config.display.enabled is False + assert config.display.mode == "remote" + assert config.display.server_url == "http://localhost:5008" + class TestConfigLoaderGlobalFunctions: """Test global configuration functions.""" diff --git a/tests/display_mcp/test_database.py b/tests/display_mcp/test_database.py index 5d951c46..ec8a4228 100644 --- a/tests/display_mcp/test_database.py +++ b/tests/display_mcp/test_database.py @@ -135,3 +135,26 @@ def test_search_snippets(self, temp_db): results = temp_db.search_snippets("pandas") assert len(results) >= 1 assert any("pandas" in r.app.lower() or "pandas" in r.name.lower() for r in results) + + def test_create_visualization_with_pyodide_method(self, temp_db): + """Pyodide execution method is accepted and persisted.""" + snippet = temp_db.create_visualization( + app="print('hello from pyodide')", + name="Pyodide snippet", + method="pyodide", + ) + + assert snippet.method == "pyodide" + assert snippet.status in {"success", "error"} + + persisted = temp_db.get_snippet(snippet.id) + assert persisted is not None + assert persisted.method == "pyodide" + + def test_create_visualization_with_invalid_method_raises_value_error(self, temp_db): + """Invalid execution methods should raise ValueError (for clean 400 mapping).""" + with pytest.raises(ValueError, match="Unsupported execution method"): + temp_db.create_visualization( + app="print('hello')", + method="invalid", # type: ignore[arg-type] + ) diff --git a/tests/display_mcp/test_endpoints.py b/tests/display_mcp/test_endpoints.py new file mode 100644 index 00000000..f29c42a5 --- /dev/null +++ b/tests/display_mcp/test_endpoints.py @@ -0,0 +1,170 @@ +"""Tests for display REST API endpoints.""" + +import json +import os +from types import SimpleNamespace +from unittest.mock import patch + +from tornado.testing import AsyncHTTPTestCase +from tornado.web import Application + +import holoviz_mcp.display_mcp.endpoints as endpoints_module +from holoviz_mcp.display_mcp.endpoints import HealthEndpoint +from holoviz_mcp.display_mcp.endpoints import SnippetEndpoint + + +class _FakeDB: + """Minimal fake DB for endpoint tests.""" + + def __init__(self) -> None: + self.method_seen: str | None = None + self.raise_value_error: bool = False + + def create_visualization(self, app: str, name: str = "", description: str = "", method: str = "jupyter") -> SimpleNamespace: + self.method_seen = method + if self.raise_value_error: + raise ValueError("Unsupported execution method 'invalid'. Supported methods: jupyter, panel, pyodide") + return SimpleNamespace(id="snippet-123", error_message=None) + + +class TestSnippetEndpoint(AsyncHTTPTestCase): + """Endpoint tests for /api/snippet.""" + + def setUp(self) -> None: + self.fake_db = _FakeDB() + self._original_get_db = endpoints_module.get_db + endpoints_module.get_db = lambda: self.fake_db + super().setUp() + + def tearDown(self) -> None: + endpoints_module.get_db = self._original_get_db + super().tearDown() + + def get_app(self) -> Application: + return Application( + [ + (r"/api/snippet", SnippetEndpoint), + (r"/api/health", HealthEndpoint), + ] + ) + + def test_create_snippet_accepts_pyodide_method(self) -> None: + """POST /api/snippet accepts pyodide and returns a URL payload.""" + body = { + "code": "print('hello')", + "name": "Pyodide test", + "description": "Smoke test", + "method": "pyodide", + } + + response = self.fetch( + "/api/snippet", + method="POST", + body=json.dumps(body), + headers={"Content-Type": "application/json"}, + ) + + assert response.code == 200 + payload = json.loads(response.body.decode("utf-8")) + assert payload["id"] == "snippet-123" + assert "view?id=snippet-123" in payload["url"] + assert self.fake_db.method_seen == "pyodide" + + def test_create_snippet_invalid_method_returns_400(self) -> None: + """POST /api/snippet maps ValueError to HTTP 400.""" + self.fake_db.raise_value_error = True + + body = { + "code": "print('hello')", + "method": "invalid", + } + + response = self.fetch( + "/api/snippet", + method="POST", + body=json.dumps(body), + headers={"Content-Type": "application/json"}, + ) + + assert response.code == 400 + payload = json.loads(response.body.decode("utf-8")) + assert payload["error"] == "ValueError" + assert "Unsupported execution method" in payload["message"] + + def test_create_snippet_uses_codespaces_url_when_available(self) -> None: + """POST /api/snippet should return Codespaces-forwarded URL when configured.""" + body = { + "code": "print('hello')", + "method": "jupyter", + } + + with patch.dict( + os.environ, + { + "CODESPACE_NAME": "literate-chainsaw-54wjwvrrxv4c4p5q", + "GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN": "app.github.dev", + }, + clear=False, + ): + response = self.fetch( + "/api/snippet", + method="POST", + body=json.dumps(body), + headers={"Content-Type": "application/json"}, + ) + + assert response.code == 200 + payload = json.loads(response.body.decode("utf-8")) + assert payload["url"].startswith("https://literate-chainsaw-54wjwvrrxv4c4p5q-") + assert payload["url"].endswith(".app.github.dev/view?id=snippet-123") + + def test_create_snippet_jupyter_proxy_takes_precedence_over_codespaces(self) -> None: + """Jupyter proxy URL should take precedence when both proxy and codespaces are set.""" + body = { + "code": "print('hello')", + "method": "jupyter", + } + + with patch.dict( + os.environ, + { + "JUPYTER_SERVER_PROXY_URL": "https://proxy.example.dev/user/foo/proxy", + "CODESPACE_NAME": "literate-chainsaw-54wjwvrrxv4c4p5q", + "GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN": "app.github.dev", + }, + clear=False, + ): + response = self.fetch( + "/api/snippet", + method="POST", + body=json.dumps(body), + headers={"Content-Type": "application/json"}, + ) + + assert response.code == 200 + payload = json.loads(response.body.decode("utf-8")) + assert payload["url"].startswith("https://proxy.example.dev/user/foo/proxy/") + assert payload["url"].endswith("/view?id=snippet-123") + + def test_create_snippet_uses_configured_jupyter_proxy_when_env_missing(self) -> None: + """Configured jupyter_server_proxy_url should be used if env var is absent.""" + body = { + "code": "print('hello')", + "method": "jupyter", + } + + fake_config = SimpleNamespace(server=SimpleNamespace(jupyter_server_proxy_url="https://config-proxy.example.dev/user/proxy")) + + with patch.dict(os.environ, {"JUPYTER_SERVER_PROXY_URL": "", "CODESPACE_NAME": ""}, clear=False): + with patch.object(endpoints_module, "get_config", return_value=fake_config): + response = self.fetch( + "/api/snippet", + method="POST", + body=json.dumps(body), + headers={"Content-Type": "application/json"}, + ) + + assert response.code == 200 + payload = json.loads(response.body.decode("utf-8")) + assert payload["url"].startswith("https://config-proxy.example.dev/user/proxy/") + assert payload["url"].endswith("/view?id=snippet-123") diff --git a/tests/docs_mcp/test_docs_mcp.py b/tests/docs_mcp/test_docs_mcp.py index f5d06e3d..bb899f78 100644 --- a/tests/docs_mcp/test_docs_mcp.py +++ b/tests/docs_mcp/test_docs_mcp.py @@ -4,10 +4,14 @@ Tests just the docs server functionality without the composed server. """ +import json +from unittest.mock import patch + import pytest from fastmcp import Client from fastmcp.exceptions import ToolError +import holoviz_mcp.holoviz_mcp.server as docs_server from holoviz_mcp.holoviz_mcp.server import mcp @@ -20,6 +24,148 @@ async def test_skills_resource(): assert result.data +@pytest.mark.asyncio +async def test_show_pyodide_payload(): + """show_pyodide returns a JSON text payload for app rendering.""" + client = Client(mcp) + async with client: + result = await client.call_tool( + "show_pyodide", + { + "code": "import panel as pn\npn.extension()\npn.pane.Markdown('hello').servable()", + "name": "Pyodide test", + "description": "Contract smoke test", + }, + ) + + assert isinstance(result.data, str) + assert '"tool": "show_pyodide"' in result.data + assert '"runtime": "panel-live-pyodide"' in result.data + + +@pytest.mark.asyncio +async def test_show_pyodide_requires_code(): + """show_pyodide returns a clear error when code is blank.""" + client = Client(mcp) + async with client: + result = await client.call_tool("show_pyodide", {"code": " "}) + + assert isinstance(result.data, str) + assert "Code is required for show_pyodide" in result.data + + +@pytest.mark.asyncio +async def test_show_display_disabled_returns_legacy_error(monkeypatch: pytest.MonkeyPatch): + """show preserves existing fallback error string when display is disabled.""" + + class _DisplayConfig: + enabled = False + + class _Config: + display = _DisplayConfig() + + monkeypatch.setattr(docs_server, "get_config", lambda: _Config()) + + client = Client(mcp) + async with client: + result = await client.call_tool("show", {"code": "1 + 1"}) + + assert isinstance(result.data, str) + assert result.data == "Error: Display server is not enabled. Set display.enabled=true in config." + + +@pytest.mark.asyncio +async def test_show_pyodide_does_not_use_display_client(monkeypatch: pytest.MonkeyPatch): + """show_pyodide is runtime-isolated from display client/server path.""" + + def _should_not_be_called(): + raise AssertionError("show_pyodide should not request display client") + + monkeypatch.setattr(docs_server, "_get_display_client", _should_not_be_called) + + client = Client(mcp) + async with client: + result = await client.call_tool("show_pyodide", {"code": "print('hello')"}) + + assert isinstance(result.data, str) + assert '"runtime": "panel-live-pyodide"' in result.data + + +@pytest.mark.asyncio +async def test_show_rewrites_localhost_url_for_codespaces(monkeypatch: pytest.MonkeyPatch): + """show should rewrite localhost URLs to Codespaces forwarding URL when available.""" + + class _DisplayConfig: + enabled = True + mode = "subprocess" + + class _ServerConfig: + jupyter_server_proxy_url = "" + + class _Config: + display = _DisplayConfig() + server = _ServerConfig() + + class _FakeClient: + def is_healthy(self): + return True + + def create_snippet(self, code: str, name: str, description: str, method: str): + return {"url": "http://localhost:5005/view?id=abc123"} + + monkeypatch.setattr(docs_server, "get_config", lambda: _Config()) + monkeypatch.setattr(docs_server, "_get_display_client", lambda: _FakeClient()) + + with patch.dict( + "os.environ", + { + "CODESPACE_NAME": "literate-chainsaw-54wjwvrrxv4c4p5q", + "GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN": "app.github.dev", + }, + clear=False, + ): + result = await docs_server.display(code="1 + 1") + + assert "https://literate-chainsaw-54wjwvrrxv4c4p5q-5005.app.github.dev/view?id=abc123" in result + + +@pytest.mark.asyncio +async def test_show_returns_json_payload_for_mcp_app(monkeypatch: pytest.MonkeyPatch): + """show returns a structured JSON payload consumable by the MCP App template.""" + + class _DisplayConfig: + enabled = True + mode = "subprocess" + + class _ServerConfig: + jupyter_server_proxy_url = "" + + class _Config: + display = _DisplayConfig() + server = _ServerConfig() + + class _FakeClient: + def is_healthy(self): + return True + + def create_snippet(self, code: str, name: str, description: str, method: str): + return {"url": "http://localhost:5005/view?id=abc123"} + + monkeypatch.setattr(docs_server, "get_config", lambda: _Config()) + monkeypatch.setattr(docs_server, "_get_display_client", lambda: _FakeClient()) + + raw_result = await docs_server.display(code="1 + 1", name="Demo", description="Plot", method="jupyter") + payload = json.loads(raw_result) + + assert payload["tool"] == "show" + assert payload["status"] == "success" + assert payload["name"] == "Demo" + assert payload["description"] == "Plot" + assert payload["method"] == "jupyter" + assert payload["url"].startswith("http") + assert payload["url"].endswith("/view?id=abc123") + + @pytest.mark.integration @pytest.mark.skip(reason="this test is very slow") @pytest.mark.asyncio diff --git a/tests/test_server.py b/tests/test_server.py index 576314f1..88374792 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -19,6 +19,8 @@ async def test_server(): async with Client(mcp) as client: tools = await client.list_tools() assert tools + tool_names = {tool.name for tool in tools} + assert "show_pyodide" in tool_names result = await client.call_tool("hvplot_list", {}) assert result.data From d76e96f5ec77f679d3e821d3175afb37e56e5af3 Mon Sep 17 00:00:00 2001 From: Marc Skov Madsen Date: Fri, 6 Mar 2026 18:17:00 +0000 Subject: [PATCH 2/4] mcp apps --- .vscode/settings.json | 3 +-- .../display_mcp/pages/view_page.py | 15 ++++++++++++-- src/holoviz_mcp/holoviz_mcp/server.py | 9 ++++++++- .../holoviz_mcp/templates/show.html | 1 + .../holoviz_mcp/templates/show_pyodide.html | 20 +++++++++++++++++-- 5 files changed, 41 insertions(+), 7 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index a281e49a..2fb1fda9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,3 @@ { - "python-envs.defaultEnvManager": "ms-python.python:system", - "python-envs.pythonProjects": [] + "python-envs.defaultEnvManager": "ms-python.python:system" } diff --git a/src/holoviz_mcp/display_mcp/pages/view_page.py b/src/holoviz_mcp/display_mcp/pages/view_page.py index cb7b9b5d..dce6fe3d 100644 --- a/src/holoviz_mcp/display_mcp/pages/view_page.py +++ b/src/holoviz_mcp/display_mcp/pages/view_page.py @@ -117,10 +117,21 @@ def _execute_code(snippet: Snippet) -> pn.viewable.Viewable | None: """ module_name = f"holoviz_snippet_{snippet.id.replace('-', '_')}" + # We need to reset the material design + app: str = ( + """\ +import panel as pn + +pn.extension(design="native") + +""" + + snippet.app + ) + if snippet.method == "jupyter": # Extract last expression try: - statements, last_expr = extract_last_expression(snippet.app) + statements, last_expr = extract_last_expression(app) except ValueError as e: raise ValueError(f"Failed to parse code: {e}") from e @@ -150,7 +161,7 @@ def _execute_code(snippet: Snippet) -> pn.viewable.Viewable | None: else: # panel method # Execute code that should call .servable() execute_in_module( - snippet.app, + app, module_name=module_name, cleanup=True, # Can cleanup immediately ) diff --git a/src/holoviz_mcp/holoviz_mcp/server.py b/src/holoviz_mcp/holoviz_mcp/server.py index 734ef957..44a4321b 100644 --- a/src/holoviz_mcp/holoviz_mcp/server.py +++ b/src/holoviz_mcp/holoviz_mcp/server.py @@ -492,7 +492,10 @@ async def update_index(ctx: Context) -> str: SHOW_RESOURCE_URI, app=AppConfig( csp=ResourceCSP( - resource_domains=["https://unpkg.com"], + resource_domains=[ + "'unsafe-inline'", + "https://unpkg.com", + ], frame_domains=[ "http://localhost", "http://127.0.0.1", @@ -646,9 +649,11 @@ async def display( app=AppConfig( csp=ResourceCSP( resource_domains=[ + "'unsafe-inline'", "'unsafe-eval'", "'wasm-unsafe-eval'", "blob:", + "data:", "https://unpkg.com", "https://panel-extensions.github.io", "https://cdn.holoviz.org", @@ -658,6 +663,7 @@ async def display( "https://pypi.org", "https://files.pythonhosted.org", "https://cdn.bokeh.org", + "https://raw.githubusercontent.com", ], connect_domains=[ "https://panel-extensions.github.io", @@ -668,6 +674,7 @@ async def display( "https://pypi.org", "https://files.pythonhosted.org", "https://cdn.bokeh.org", + "https://raw.githubusercontent.com", ], ) ), diff --git a/src/holoviz_mcp/holoviz_mcp/templates/show.html b/src/holoviz_mcp/holoviz_mcp/templates/show.html index 7687e02a..0c2a604e 100644 --- a/src/holoviz_mcp/holoviz_mcp/templates/show.html +++ b/src/holoviz_mcp/holoviz_mcp/templates/show.html @@ -54,6 +54,7 @@ min-height: 420px; border: 1px solid rgba(120, 120, 120, 0.4); border-radius: 8px; + background: white; } diff --git a/src/holoviz_mcp/holoviz_mcp/templates/show_pyodide.html b/src/holoviz_mcp/holoviz_mcp/templates/show_pyodide.html index d5feef82..3660d407 100644 --- a/src/holoviz_mcp/holoviz_mcp/templates/show_pyodide.html +++ b/src/holoviz_mcp/holoviz_mcp/templates/show_pyodide.html @@ -6,6 +6,7 @@ HoloViz MCP · show_pyodide +