-
Notifications
You must be signed in to change notification settings - Fork 11
MCP Apps: interactive Vega-Lite chart widget #133
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
dc3ba0e
520c751
606d450
f894c5e
9d4786a
4d8fe76
aa70f56
8e2ce34
6e28033
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -44,6 +44,7 @@ mcp = [ | |
| ] | ||
| apps = [ | ||
| "mcp[cli]>=1.25.0,<2", | ||
| "pyarrow>=14.0.0", | ||
| ] | ||
| charts = [ | ||
| "altair>=5.0.0", | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,60 +1,40 @@ | ||
| """MCP Apps integration for sidemantic. | ||
|
|
||
| Creates vendor-neutral UI resources (MCP Apps standard) that render | ||
| interactive charts in any MCP Apps-compatible host. | ||
| Provides interactive chart widgets for MCP Apps-compatible hosts. | ||
| The widget is built with Vite (sidemantic/apps/web/) and bundled into | ||
| a single HTML file (sidemantic/apps/chart.html) that includes the | ||
| ext-apps SDK and Vega-Lite with CSP-safe interpreter. | ||
| """ | ||
|
|
||
| import json | ||
| from pathlib import Path | ||
| from typing import Any | ||
|
|
||
| _WIDGET_TEMPLATE: str | None = None | ||
| _CHART_HTML: str | None = None | ||
| _EXPLORER_HTML: str | None = None | ||
|
|
||
|
|
||
| def _get_widget_template() -> str: | ||
| """Load the chart widget HTML template.""" | ||
| global _WIDGET_TEMPLATE | ||
| if _WIDGET_TEMPLATE is None: | ||
| path = Path(__file__).parent / "chart_widget.html" | ||
| _WIDGET_TEMPLATE = path.read_text() | ||
| return _WIDGET_TEMPLATE | ||
|
|
||
|
|
||
| def build_chart_html(vega_spec: dict[str, Any]) -> str: | ||
| """Build a self-contained chart widget HTML with embedded Vega spec. | ||
|
|
||
| Args: | ||
| vega_spec: Vega-Lite specification dict. | ||
|
|
||
| Returns: | ||
| Complete HTML string with the spec injected. | ||
| """ | ||
| template = _get_widget_template() | ||
| # Escape </script> sequences to prevent XSS when user-provided strings | ||
| # (e.g., chart titles) flow into the Vega spec. | ||
| safe_json = json.dumps(vega_spec).replace("<", "\\u003c") | ||
| return template.replace("{{VEGA_SPEC}}", safe_json) | ||
|
|
||
|
|
||
| def create_chart_resource(vega_spec: dict[str, Any]): | ||
| """Create a UIResource for a chart visualization. | ||
|
|
||
| Args: | ||
| vega_spec: Vega-Lite specification dict. | ||
|
|
||
| Returns: | ||
| UIResource (EmbeddedResource) for MCP Apps-compatible hosts. | ||
| """ | ||
| from sidemantic.apps._mcp_ui import create_ui_resource | ||
|
|
||
| html = build_chart_html(vega_spec) | ||
| return create_ui_resource( | ||
| { | ||
| "uri": "ui://sidemantic/chart", | ||
| "content": { | ||
| "type": "rawHtml", | ||
| "htmlString": html, | ||
| }, | ||
| "encoding": "text", | ||
| } | ||
| ) | ||
| """Load the built chart widget HTML for the MCP Apps resource handler.""" | ||
| global _CHART_HTML | ||
| if _CHART_HTML is None: | ||
| built = Path(__file__).parent / "chart.html" | ||
| if built.exists(): | ||
| _CHART_HTML = built.read_text() | ||
| else: | ||
| raise FileNotFoundError( | ||
| f"Chart widget not built at {built}. Run: cd sidemantic/apps/web && bun install && bun run build" | ||
| ) | ||
| return _CHART_HTML | ||
|
|
||
|
|
||
| def _get_explorer_template() -> str: | ||
| """Load the built explorer widget HTML for the MCP Apps resource handler.""" | ||
| global _EXPLORER_HTML | ||
| if _EXPLORER_HTML is None: | ||
| built = Path(__file__).parent / "explorer.html" | ||
| if built.exists(): | ||
| _EXPLORER_HTML = built.read_text() | ||
| else: | ||
| raise FileNotFoundError( | ||
| f"Explorer widget not built at {built}. Run: cd sidemantic/apps/web && bun install && bun run build" | ||
| ) | ||
| return _EXPLORER_HTML |
Large diffs are not rendered by default.
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| node_modules/ | ||
| bun.lock |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,141 @@ | ||
| import { App, applyDocumentTheme, type McpUiHostContext } from "@modelcontextprotocol/ext-apps"; | ||
| import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; | ||
| import embed from "vega-embed"; | ||
| import { expressionInterpreter } from "vega-interpreter"; | ||
|
|
||
| const container = document.getElementById("chart")!; | ||
| let currentDisplayMode: "inline" | "fullscreen" = "inline"; | ||
| let lastSpec: Record<string, unknown> | null = null; | ||
| let activeObserver: ResizeObserver | null = null; | ||
| let activeView: { finalize: () => void } | null = null; | ||
| let renderGeneration = 0; | ||
|
|
||
| function cleanupChart() { | ||
| if (activeObserver) { activeObserver.disconnect(); activeObserver = null; } | ||
| if (activeView) { activeView.finalize(); activeView = null; } | ||
| } | ||
|
|
||
| function renderChart(vegaSpec: Record<string, unknown>) { | ||
| cleanupChart(); | ||
| const generation = ++renderGeneration; | ||
|
|
||
| container.innerHTML = ""; | ||
| const isFullscreen = currentDisplayMode === "fullscreen"; | ||
| document.documentElement.classList.toggle("fullscreen", isFullscreen); | ||
|
|
||
| const spec = { ...vegaSpec }; | ||
| spec.width = "container"; | ||
| spec.height = isFullscreen ? "container" : 500; | ||
| spec.background = "transparent"; | ||
|
|
||
| const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches; | ||
|
|
||
| embed(container, spec as any, { | ||
| actions: false, | ||
| theme: prefersDark ? "dark" : undefined, | ||
| ast: true, | ||
| expr: expressionInterpreter, | ||
| }) | ||
| .then((result) => { | ||
| if (generation !== renderGeneration) { result.finalize(); return; } | ||
|
|
||
| activeView = result; | ||
| const ro = new ResizeObserver(() => result.view.resize().run()); | ||
| ro.observe(container); | ||
| activeObserver = ro; | ||
|
Comment on lines
+39
to
+45
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The Useful? React with 👍 / 👎. |
||
|
|
||
| if (!isFullscreen) { | ||
| addExpandButton(); | ||
| } | ||
|
|
||
| requestAnimationFrame(() => { | ||
| if (generation !== renderGeneration) return; | ||
| if (isFullscreen) { | ||
| app.sendSizeChanged({ height: window.innerHeight - 150 }); | ||
| } else { | ||
| const h = Math.max(505, document.documentElement.scrollHeight + 5); | ||
| app.sendSizeChanged({ height: h }); | ||
| } | ||
| }); | ||
| }) | ||
| .catch((err) => { | ||
| if (generation !== renderGeneration) return; | ||
| container.innerHTML = `<div class="error">Chart render error: ${err.message}</div>`; | ||
| }); | ||
| } | ||
|
|
||
| function addExpandButton() { | ||
| const btn = document.createElement("div"); | ||
| btn.className = "expand-btn"; | ||
| btn.title = "Expand to fullscreen"; | ||
| btn.textContent = "Expand ↗"; | ||
| btn.addEventListener("click", goFullscreen); | ||
| container.appendChild(btn); | ||
| } | ||
|
|
||
| async function goFullscreen() { | ||
| try { | ||
| const result = await app.requestDisplayMode({ mode: "fullscreen" }); | ||
| currentDisplayMode = result.mode as "inline" | "fullscreen"; | ||
| if (lastSpec) renderChart(lastSpec); | ||
| } catch { | ||
| // host doesn't support fullscreen | ||
| } | ||
| } | ||
|
|
||
| function extractVegaSpec(result: CallToolResult): Record<string, unknown> | null { | ||
| const sc = result.structuredContent as Record<string, unknown> | undefined; | ||
| if (sc?.vega_spec) return sc.vega_spec as Record<string, unknown>; | ||
| if (result.content) { | ||
| for (const item of result.content) { | ||
| if (item.type === "text") { | ||
| try { | ||
| const data = JSON.parse((item as { text: string }).text); | ||
| if (data.vega_spec) return data.vega_spec; | ||
| } catch {} | ||
| } | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| const app = new App( | ||
| { name: "sidemantic-chart", version: "1.0.0" }, | ||
| {}, | ||
| { autoResize: false }, | ||
| ); | ||
|
|
||
| app.ontoolresult = (result: CallToolResult) => { | ||
| const spec = extractVegaSpec(result); | ||
| if (spec) { | ||
| lastSpec = spec; | ||
| renderChart(spec); | ||
| } else { | ||
| cleanupChart(); | ||
| lastSpec = null; | ||
| container.innerHTML = '<div class="error">No chart data in tool result</div>'; | ||
| } | ||
|
Comment on lines
+116
to
+117
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When no Vega spec is found, this branch replaces the DOM with an error message but never disconnects Useful? React with 👍 / 👎.
Comment on lines
+113
to
+117
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In the no-spec branch, the widget shows an error but leaves Useful? React with 👍 / 👎. |
||
| }; | ||
|
|
||
| app.ontoolinput = () => { | ||
| cleanupChart(); | ||
| lastSpec = null; | ||
| ++renderGeneration; | ||
| container.innerHTML = '<div class="loading">Running query...</div>'; | ||
|
Comment on lines
+120
to
+124
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a new tool call starts, this handler clears the DOM but does not advance Useful? React with 👍 / 👎.
Comment on lines
+120
to
+124
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| }; | ||
|
|
||
| app.onhostcontextchanged = (ctx: McpUiHostContext) => { | ||
| if (ctx.theme) applyDocumentTheme(ctx.theme); | ||
| if (ctx.displayMode === "inline" || ctx.displayMode === "fullscreen") { | ||
| currentDisplayMode = ctx.displayMode; | ||
| if (lastSpec) renderChart(lastSpec); | ||
| } | ||
| }; | ||
|
|
||
| app.connect().then(() => { | ||
| const ctx = app.getHostContext(); | ||
| if (ctx?.theme) applyDocumentTheme(ctx.theme); | ||
| const loading = container.querySelector(".loading"); | ||
| if (loading) loading.textContent = "Waiting for chart data..."; | ||
| app.sendSizeChanged({ height: 500 }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| <!DOCTYPE html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="UTF-8"> | ||
| <meta name="color-scheme" content="light dark"> | ||
| <style> | ||
| html, body { margin: 0; padding: 0; background: transparent; | ||
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } | ||
| #chart { width: 100%; min-height: 500px; position: relative; } | ||
| html.fullscreen, html.fullscreen body { height: 100%; } | ||
| html.fullscreen { padding: 16px 24px 0; box-sizing: border-box; } | ||
| html.fullscreen #chart { height: calc(100vh - 150px - 16px); min-height: auto; } | ||
| .vega-embed { background: transparent !important; } | ||
| #chart .vega-embed, #chart .vega-embed > div, | ||
| #chart .vega-embed canvas, #chart .vega-embed svg { overflow: hidden !important; } | ||
| .error { padding: 2rem; text-align: center; color: #dc2626; } | ||
| .loading { padding: 2rem; text-align: center; color: #999; } | ||
| .expand-btn { | ||
| position: absolute; top: 6px; right: 8px; z-index: 10; | ||
| cursor: pointer; color: #666; font-size: 13px; | ||
| line-height: 1; padding: 4px 8px; border-radius: 4px; | ||
| transition: color 0.2s, background 0.2s; | ||
| } | ||
| .expand-btn:hover { color: #333; background: rgba(0,0,0,0.06); } | ||
| @media (prefers-color-scheme: dark) { | ||
| .expand-btn { color: #999; } | ||
| .expand-btn:hover { color: #ddd; background: rgba(255,255,255,0.1); } | ||
| } | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <div id="chart"> | ||
| <div class="loading">Loading...</div> | ||
| </div> | ||
| <script type="module" src="./chart-app.ts"></script> | ||
| </body> | ||
| </html> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
renderChartis invoked on every tool result and display-mode change, but each call creates a newResizeObserverbound to that render's Vega view without disconnecting prior observers. After multiple chart updates in one session, resize events fan out to stale views, which causes avoidable CPU/memory growth and progressively slower interaction. Track the active observer/view and clean them up before creating a new embed.Useful? React with 👍 / 👎.