Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
4 changes: 2 additions & 2 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ pn.Column(metric, pn.bind(plot, metric))

## Panel dashboard

A multi-component layout with a sidebar and reactive widgets. Use `method="panel"` and
A multi-component layout with a sidebar and reactive widgets. Use `method="server"` and
call `.servable()` on the objects you want displayed.

```python
Expand Down Expand Up @@ -237,7 +237,7 @@ pn.template.FastListTemplate(
## DataFrame table

An interactive, sortable and filterable table using `pn.widgets.Tabulator`.
Use `method="panel"`.
Use `method="server"`.

```python
import panel as pn
Expand Down
4,999 changes: 1,960 additions & 3,039 deletions pixi.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions pixi.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ pip = "*"
hatchling = "*"
hatch-vcs = "*"
panel = ">=1.5.0"
holoviews = ">=1.19"
hvplot = ">=0.10"
setuptools = ">=61"
setuptools-scm = "*"

Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ classifiers = [
dependencies = [
"packaging",
"panel >=1.5.0",
"holoviews >=1.19",
"hvplot >=0.10",
"panel-material-ui",
"psutil",
"requests",
Expand Down
2 changes: 2 additions & 0 deletions src/panel_live_server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from urllib.parse import urlparse

from panel_live_server.config import get_config
from panel_live_server.endpoints import EmbedEndpoint
from panel_live_server.endpoints import HealthEndpoint
from panel_live_server.endpoints import SnippetEndpoint

Expand Down Expand Up @@ -90,6 +91,7 @@ def main(address: str = "localhost", port: int = 5077, show: bool = True) -> Non
# Configure extra patterns for Tornado handlers (REST API endpoints)
extra_patterns = [
(r"/api/snippet", SnippetEndpoint),
(r"/api/embed", EmbedEndpoint),
(r"/api/health", HealthEndpoint),
]

Expand Down
22 changes: 22 additions & 0 deletions src/panel_live_server/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,28 @@ def create_snippet(self, code: str, name: str = "", description: str = "", metho
logger.exception(f"Error creating visualization: {e}")
raise RuntimeError(f"Failed to create visualization: {e}") from e

def get_embed_html(self, snippet_id: str) -> str | None:
"""Fetch self-contained static HTML for an inline-method snippet.

Returns ``None`` on failure so the caller can degrade gracefully.
"""
try:
response = self.session.get(
f"{self.base_url}/api/embed",
params={"id": snippet_id},
timeout=self.timeout,
)
if response.status_code != 200:
logger.warning("Embed render failed (HTTP %s) for snippet %s", response.status_code, snippet_id)
return None
if "text/html" not in response.headers.get("Content-Type", ""):
logger.warning("Embed render returned non-HTML content-type for snippet %s", snippet_id)
return None
return response.text
except requests.RequestException as e:
logger.warning("Embed render request error for snippet %s: %s", snippet_id, e)
return None

def close(self) -> None:
"""Close the HTTP session and cleanup resources."""
if self.session:
Expand Down
114 changes: 114 additions & 0 deletions src/panel_live_server/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
HTTP endpoints for creating visualizations and checking server health.
"""

import ast
import json
import logging
import traceback
Expand All @@ -19,6 +20,31 @@
logger = logging.getLogger(__name__)


def _has_python_callbacks(code: str) -> bool:
"""Return True if code uses Panel/param Python callbacks that require a live server.

Detects .on_click(), .on_change(), .watch(), pn.bind(), and @pn.depends patterns.
These callbacks are not captured by Panel's embed mode and won't work in a srcdoc iframe.
"""
try:
tree = ast.parse(code)
except SyntaxError:
return False
for node in ast.walk(tree):
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
attr = node.func.attr
if attr in ("on_click", "on_change", "on_edit", "watch"):
return True
if attr == "bind" and isinstance(node.func.value, ast.Name) and node.func.value.id in ("pn", "panel"):
return True
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
for dec in node.decorator_list:
check = dec.func if isinstance(dec, ast.Call) else dec
if isinstance(check, ast.Attribute) and check.attr == "depends":
return True
return False


def _get_external_base_url(request_host: str) -> str | None:
"""Get external base URL for links returned to clients.

Expand Down Expand Up @@ -101,6 +127,94 @@ def post(self):
)


class EmbedEndpoint(RequestHandler):
"""Render a snippet to self-contained HTML via GET /api/embed?id=...

Used for ``method="inline"`` visualizations (hvplot, holoviews, bokeh,
matplotlib, plotly). Returns a complete HTML document with CDN-loaded
Bokeh/Panel resources suitable for embedding via ``iframe.srcdoc``.
JS-side interactivity (CustomJS, jslink, hover/zoom) is preserved;
Python callbacks are not.
"""

def get(self):
"""Render the snippet identified by ``?id=`` as static HTML."""
import io
import sys

import panel as pn

from panel_live_server.utils import execute_in_module
from panel_live_server.utils import extract_last_expression
from panel_live_server.utils import find_extensions

snippet_id = self.get_argument("id", "")
if not snippet_id:
self.set_status(400)
self.set_header("Content-Type", "application/json")
self.write({"error": "Missing 'id' parameter"})
return

db = get_db()
snippet = db.get_snippet(snippet_id)
if not snippet:
self.set_status(404)
self.set_header("Content-Type", "application/json")
self.write({"error": f"Snippet {snippet_id} not found"})
return

if _has_python_callbacks(snippet.app):
# Python callbacks (on_click, pn.bind, etc.) are not captured by Panel's embed
# mode — the button/widget renders but clicking/interacting does nothing in a
# srcdoc iframe without a live server. Return empty so the caller falls back to
# the live-server placeholder.
self.set_status(200)
self.set_header("Content-Type", "text/html; charset=utf-8")
self.write("")
return

try:
extensions = list({"bokeh"} | set(find_extensions(snippet.app)))
pn.extension(*extensions)

preamble = "import panel as pn\n\npn.config.design = None\n\n"
app = preamble + snippet.app
module_name = f"bokeh_app_embed_{snippet.id.replace('-', '_')}"
result = None

statements, last_expr = extract_last_expression(app)
namespace = execute_in_module(statements, module_name=module_name, cleanup=False)
try:
result = eval(last_expr, namespace) if last_expr else None # noqa: S307
finally:
sys.modules.pop(module_name, None)

if result is None:
self.set_status(200)
self.set_header("Content-Type", "text/html; charset=utf-8")
self.write(
"<!doctype html><html><body style='font-family:system-ui;padding:2em;opacity:.7'>"
"<p>Code executed successfully (no output to display).</p>"
"</body></html>"
)
return

obj = pn.panel(result, sizing_mode="stretch_width")
buf = io.StringIO()
obj.save(buf, resources="cdn", embed=True, max_states=500)
html = buf.getvalue()

self.set_status(200)
self.set_header("Content-Type", "text/html; charset=utf-8")
self.write(html)

except Exception as e:
logger.exception(f"Error rendering embed for snippet {snippet_id}")
self.set_status(500)
self.set_header("Content-Type", "application/json")
self.write({"error": str(e), "traceback": traceback.format_exc()})


class HealthEndpoint(RequestHandler):
"""Tornado RequestHandler for /api/health endpoint."""

Expand Down
63 changes: 60 additions & 3 deletions src/panel_live_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

import asyncio
import atexit
import base64
import gzip
import json
import logging
import os
Expand Down Expand Up @@ -140,6 +142,17 @@ def _externalize_url(url: str) -> str:
return f"{external_url.rstrip('/')}{path}{query}"


def _get_mcp_client_name(ctx: Context | None) -> str:
"""Return the MCP client name from the initialize handshake (lowercased).

Claude Desktop sends ``'claude-ai'``. Returns empty string if unavailable.
"""
try:
return ctx.request_context.session.client_params.clientInfo.name.lower() # type: ignore[union-attr]
except Exception:
return ""


def _start_panel_server() -> tuple[PanelServerManager | None, DisplayClient | None]:
"""Start the Panel server subprocess and create a client."""
config = get_config()
Expand Down Expand Up @@ -218,6 +231,24 @@ async def app_lifespan(app):
_cleanup()


_CLAUDE_DESKTOP_INSTRUCTIONS = (
"IF YOU ARE CLAUDE DESKTOP\n"
"Visualizations are embedded inline in the chat via srcdoc — no live server connection. "
"Follow these rules strictly:\n"
"- For ANY interactive visualization (sliders, dropdowns, buttons): use Bokeh widgets "
" (Slider, Select, CheckboxGroup) with jslink or CustomJS. "
" Do NOT use pn.bind, @pn.depends, param.watch, or .servable() — "
" these require a live Python server and will NOT work inline.\n"
"- Use method='inline' for all plots, static or interactive.\n"
"- Use method='server' ONLY for complex Panel dashboards that truly need a running Python server "
" (e.g. FastListTemplate, real-time streaming, database queries). "
" The user will need to open these via the 'Show Visualization' link.\n\n"
"IF YOU ARE ANY OTHER CLIENT (Cursor, VS Code, etc.)\n"
"Visualizations load via iframe.src — the live server is always reachable. "
"Use Panel reactive patterns (pn.bind, @pn.depends) freely. "
"Use method='server' for any interactive Panel app.\n\n"
)

mcp = FastMCP(
"Panel Live Server",
instructions=(
Expand All @@ -240,14 +271,13 @@ async def app_lifespan(app):
"LIBRARY SELECTION (prefer in this order when suitable):\n"
"- hvPlot: quick interactive plots from DataFrames (.plot API)\n"
"- HoloViews: advanced composable, interactive visualizations\n"
"- Panel: dashboards, data apps, complex layouts (use method='panel')\n"
"- Panel: dashboards, data apps, complex layouts (use method='server')\n"
"- Matplotlib: publication-quality static plots\n"
"- Plotly: interactive charts with 3D, hover\n"
"- ECharts (pn.pane.ECharts): modern business-quality charts with data transitions and animations\n"
"- Bokeh: low-level interactive web plots\n"
"- deck.gl (pn.pane.DeckGL): large-scale geospatial and 3D data visualization\n"
"Always verify the library is installed via `list_packages` first.\n\n"
"OUTPUT\n"
"Always verify the library is installed via `list_packages` first.\n\n" + _CLAUDE_DESKTOP_INSTRUCTIONS + "OUTPUT\n"
"After calling `show`, ALWAYS present the returned URL to the user as a "
"clickable Markdown link: [Show Visualization](url)\n\n"
"ERRORS\n"
Expand Down Expand Up @@ -292,6 +322,10 @@ def _build_frame_domains() -> list[str]:
resource_domains=[
"'unsafe-inline'",
"https://unpkg.com",
"https://cdn.bokeh.org",
"https://cdn.holoviz.org",
"https://cdn.jsdelivr.net",
"https://cdn.plot.ly",
],
frame_domains=_build_frame_domains(),
)
Expand Down Expand Up @@ -557,6 +591,9 @@ async def show(
"""
global _manager, _client

client_name = _get_mcp_client_name(ctx)
is_claude = client_name == "claude-ai"

# Clamp zoom to nearest valid level
_valid_zooms = [25, 50, 75, 100]
zoom = min(_valid_zooms, key=lambda z: abs(z - zoom))
Expand Down Expand Up @@ -623,6 +660,26 @@ async def show(
# clear text error instead of a blank App pane.
raise ToolError(f"Visualization created but failed at runtime:\n{error_message}\nFix the code and try again.")

snippet_id = response.get("id", "")

if is_claude:
# Embed as srcdoc to bypass Claude Desktop's frame-src CSP.
# If embed is empty (e.g. pn.bind / DynamicMap need a live server), fall back to placeholder.
_EMBED_SIZE_CAP = 150_000
if snippet_id:
embed_html = await asyncio.to_thread(_client.get_embed_html, snippet_id)
if embed_html:
compressed = gzip.compress(embed_html.encode("utf-8"))
encoded = base64.b64encode(compressed).decode("ascii")
if len(encoded) <= _EMBED_SIZE_CAP:
payload["embed_html_gz"] = encoded
else:
payload["panel_server"] = True
else:
# Empty string (Python callbacks detected) or None (embed failed):
# in either case the live-server URL will be CSP-blocked in Claude Desktop.
payload["panel_server"] = True

payload["status"] = "success"
payload["message"] = "Visualization created successfully."
return json.dumps(payload)
Expand Down
Loading
Loading