Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
{
"python-envs.defaultEnvManager": "ms-python.python:system",
"python-envs.pythonProjects": []
"python-envs.defaultEnvManager": "ms-python.python:system"
}
31 changes: 27 additions & 4 deletions docs/explanation/display-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Comment on lines 18 to +25

Copilot AI Mar 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This flow description still says the MCP server “returns URL to view visualization”, but show now returns a JSON payload (with url embedded) for MCP App rendering. Adjust the wording here to reflect the new contract, otherwise this doc contradicts the implementation and the show MCP App template logic.

Copilot uses AI. Check for mistakes.

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:

Expand All @@ -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"
Comment on lines 43 to +48

Copilot AI Mar 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the show tool section, the parameter name is documented as app, but the MCP tool parameter is code. Also, the surrounding text claims the tool returns id/created_at, but the current implementation returns a JSON payload with fields like status, message, url, and echoes code. Please update this section to match the actual show tool interface and payload.

Copilot uses AI. Check for mistakes.

The tool returns a response containing:

Expand All @@ -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:
Expand Down Expand Up @@ -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)

Expand All @@ -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
Expand Down
27 changes: 27 additions & 0 deletions docs/explanation/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copilot AI Mar 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The show tool now returns a JSON text payload (as implemented in holoviz_mcp.server.display()), not a plain success/error message string. This “Returns” description is now misleading; update it to describe the JSON payload shape (and/or the fallback behavior in non-MCP-App hosts).

Suggested change
**Returns**: Success/error message containing a view URL.
**Returns**: JSON text payload (as returned by `holoviz_mcp.server.display()`) describing the view, including a view URL and rendering metadata. In non–MCP-App hosts, this may be surfaced to the user as a success/error message string containing the view URL.

Copilot uses AI. Check for mistakes.

### 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.
Expand Down
16 changes: 15 additions & 1 deletion src/holoviz_mcp/config/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand All @@ -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]:
Expand Down Expand Up @@ -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:
Expand Down
11 changes: 8 additions & 3 deletions src/holoviz_mcp/display_mcp/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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.

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

Expand Down
32 changes: 29 additions & 3 deletions src/holoviz_mcp/display_mcp/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand All @@ -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()
Expand Down
15 changes: 13 additions & 2 deletions src/holoviz_mcp/display_mcp/pages/view_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
)
Comment on lines 131 to 167

Copilot AI Mar 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Snippet.method now allows "pyodide", but _execute_code() treats any non-"jupyter" method as the Panel execution path, so pyodide snippets would be executed server-side and expected to call .servable(). This contradicts the intent of the pyodide method and can lead to confusing failures. Add an explicit elif snippet.method == "pyodide" branch (e.g., render a message + the source code, or redirect users to the show_pyodide MCP App flow) and keep the current behavior only for "panel".

Copilot uses AI. Check for mistakes.
Expand Down
Loading
Loading