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/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/core/inspect.py b/src/holoviz_mcp/core/inspect.py index 96f45b2e..fc0d482b 100644 --- a/src/holoviz_mcp/core/inspect.py +++ b/src/holoviz_mcp/core/inspect.py @@ -19,15 +19,74 @@ import asyncio import atexit import logging +import os from asyncio import sleep from dataclasses import dataclass from dataclasses import field from datetime import datetime from pathlib import Path +from urllib.parse import urlparse from uuid import uuid4 logger = logging.getLogger(__name__) + +def _internalize_url(url: str) -> str: + """Convert an externalized proxy/Codespaces URL back to a localhost URL. + + panel-live-server (and other tools) rewrite localhost URLs to externally + reachable addresses so the *user* can open them in a browser. When the + *inspect* tool uses Playwright on the same machine it should talk directly + to localhost — no round-trip through the internet, no auth required. + + Supported patterns + ------------------ + GitHub Codespaces + ``https://-.app.github.dev`` + → ``http://localhost:`` + + Jupyter server proxy + ``/`` + → ``http://localhost:`` + + All other URLs are returned unchanged. + """ + if not url: + return url + + parsed = urlparse(url) + hostname = (parsed.hostname or "").lower() + + # ── GitHub Codespaces ──────────────────────────────────────────────────── + codespace_name = os.getenv("CODESPACE_NAME", "") + forwarding_domain = os.getenv("GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN", "app.github.dev") + if codespace_name and hostname.endswith(f".{forwarding_domain}"): + # hostname: "-." + inner = hostname[: -len(f".{forwarding_domain}")] # strip domain suffix + # port is always the last hyphen-separated segment + try: + port = int(inner.rsplit("-", 1)[-1]) + except ValueError: + return url + path = parsed.path or "/" + query = f"?{parsed.query}" if parsed.query else "" + return f"http://localhost:{port}{path}{query}" + + # ── Jupyter server proxy ───────────────────────────────────────────────── + proxy_base = os.getenv("JUPYTER_SERVER_PROXY_URL", "").rstrip("/") + if proxy_base and url.startswith(proxy_base): + remainder = url[len(proxy_base) :].lstrip("/") # e.g. "5077/view?id=abc" + parts = remainder.split("/", 1) + try: + port = int(parts[0]) + except ValueError: + return url + rest = "/" + parts[1] if len(parts) > 1 else "/" + return f"http://localhost:{port}{rest}" + + return url + + # Known browser/framework noise patterns to filter from console logs. # These are infrastructure messages, not application errors. _NOISE_PATTERNS: list[str] = [ @@ -180,6 +239,8 @@ async def inspect_app( if not screenshot and not console_logs: raise ValueError("At least one of 'screenshot' or 'console_logs' must be True.") + url = _internalize_url(url) + manager = _get_playwright_manager() browser = await manager.get_browser() page = await browser.new_page( 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/display_mcp/pages/view_page.py b/src/holoviz_mcp/display_mcp/pages/view_page.py index cb7b9b5d..afe8eda4 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.config.design = None + +""" + + 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 56f2f0f2..660d9892 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,31 @@ 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=[ + "'unsafe-inline'", + "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 +547,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 +613,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 +644,92 @@ 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-inline'", + "'unsafe-eval'", + "'wasm-unsafe-eval'", + "blob:", + "data:", + "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", + "https://raw.githubusercontent.com", + ], + 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", + "https://raw.githubusercontent.com", + ], + ) + ), +) +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..0c2a604e --- /dev/null +++ b/src/holoviz_mcp/holoviz_mcp/templates/show.html @@ -0,0 +1,134 @@ + + + + + + + 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..3660d407 --- /dev/null +++ b/src/holoviz_mcp/holoviz_mcp/templates/show_pyodide.html @@ -0,0 +1,140 @@ + + + + + + + HoloViz MCP · show_pyodide + + + + + + +
+
+

show_pyodide

+

Rendering with panel-live (Pyodide runtime).

+
+
Awaiting tool result…
+
+ HELLO! This is a demonstration of the show_pyodide tool result renderer. The code received from the tool will be displayed in the "source" section above, and executed in the "runner" section below using panel-live with a Pyodide runtime. If you see this message, it means that the runner is working correctly, and any code sent by the tool will be executed here. If you encounter any issues or have questions, please reach out to the developers. +
+
+ + + + 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/core/test_inspect.py b/tests/core/test_inspect.py index e6c3b730..2228ec25 100644 --- a/tests/core/test_inspect.py +++ b/tests/core/test_inspect.py @@ -3,9 +3,51 @@ import pytest from holoviz_mcp.core.inspect import InspectResult +from holoviz_mcp.core.inspect import _internalize_url from holoviz_mcp.core.inspect import inspect_app +class TestInternalizeUrl: + def test_codespaces_plain_path(self, monkeypatch): + monkeypatch.setenv("CODESPACE_NAME", "literate-chainsaw-54wjwvrrxv4c4p5q") + url = "https://literate-chainsaw-54wjwvrrxv4c4p5q-5077.app.github.dev/view?id=abc" + assert _internalize_url(url) == "http://localhost:5077/view?id=abc" + + def test_codespaces_root_path(self, monkeypatch): + monkeypatch.setenv("CODESPACE_NAME", "literate-chainsaw-54wjwvrrxv4c4p5q") + url = "https://literate-chainsaw-54wjwvrrxv4c4p5q-5077.app.github.dev/" + assert _internalize_url(url) == "http://localhost:5077/" + + def test_codespaces_custom_forwarding_domain(self, monkeypatch): + monkeypatch.setenv("CODESPACE_NAME", "my-space") + monkeypatch.setenv("GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN", "preview.app.github.dev") + url = "https://my-space-8080.preview.app.github.dev/feed" + assert _internalize_url(url) == "http://localhost:8080/feed" + + def test_jupyter_proxy(self, monkeypatch): + monkeypatch.setenv("JUPYTER_SERVER_PROXY_URL", "https://hub.example.com/user/foo/proxy") + url = "https://hub.example.com/user/foo/proxy/5077/view?id=abc" + assert _internalize_url(url) == "http://localhost:5077/view?id=abc" + + def test_jupyter_proxy_trailing_slash(self, monkeypatch): + monkeypatch.setenv("JUPYTER_SERVER_PROXY_URL", "https://hub.example.com/user/foo/proxy/") + url = "https://hub.example.com/user/foo/proxy/5077/feed" + assert _internalize_url(url) == "http://localhost:5077/feed" + + def test_localhost_passthrough(self, monkeypatch): + monkeypatch.delenv("CODESPACE_NAME", raising=False) + monkeypatch.delenv("JUPYTER_SERVER_PROXY_URL", raising=False) + assert _internalize_url("http://localhost:5006/") == "http://localhost:5006/" + + def test_external_url_passthrough(self, monkeypatch): + monkeypatch.delenv("CODESPACE_NAME", raising=False) + monkeypatch.delenv("JUPYTER_SERVER_PROXY_URL", raising=False) + assert _internalize_url("https://example.com/app") == "https://example.com/app" + + def test_empty_string(self): + assert _internalize_url("") == "" + + class TestInspectResult: def test_default_values(self): result = InspectResult() 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