-
Notifications
You must be signed in to change notification settings - Fork 11
MCP Apps: interactive Vega-Lite charts #132
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
Changes from all commits
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 |
|---|---|---|
| @@ -1,60 +1,25 @@ | ||
| """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 | ||
| _WIDGET_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 _WIDGET_HTML | ||
| if _WIDGET_HTML is None: | ||
| built = Path(__file__).parent / "chart.html" | ||
| if built.exists(): | ||
| _WIDGET_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 _WIDGET_HTML |
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,116 @@ | ||
| 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")!; | ||
|
|
||
| function renderChart(vegaSpec: Record<string, unknown>) { | ||
| // Clear chart content but preserve the fullscreen button | ||
| Array.from(container.children).forEach(c => { | ||
| if (c.id !== "fullscreen-btn") c.remove(); | ||
| }); | ||
| const spec = { ...vegaSpec }; | ||
| spec.width = "container"; | ||
| spec.height = 500; | ||
| spec.background = "transparent"; | ||
|
|
||
| const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches; | ||
|
|
||
| embed(container, spec as any, { | ||
| actions: false, | ||
| theme: prefersDark ? "dark" : undefined, | ||
|
Comment on lines
+18
to
+22
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 chart theme is chosen from Useful? React with 👍 / 👎. |
||
| // CSP-safe: use AST interpreter instead of eval | ||
| ast: true, | ||
| expr: expressionInterpreter, | ||
| }) | ||
| .then((result) => { | ||
| const ro = new ResizeObserver(() => result.view.resize().run()); | ||
| ro.observe(container); | ||
|
Comment on lines
+28
to
+29
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.
Each tool result creates a new Useful? React with 👍 / 👎. |
||
| // Tell host the actual content height after render | ||
| requestAnimationFrame(() => { | ||
| const h = Math.max(500, document.documentElement.scrollHeight); | ||
| app.sendSizeChanged({ height: h }); | ||
| }); | ||
| }) | ||
| .catch((err) => { | ||
| container.innerHTML = `<div class="error">Chart render error: ${err.message}</div>`; | ||
| }); | ||
| } | ||
|
|
||
| function extractVegaSpec(result: CallToolResult): Record<string, unknown> | null { | ||
| // Try structuredContent first | ||
| const sc = result.structuredContent as Record<string, unknown> | undefined; | ||
| if (sc?.vega_spec) return sc.vega_spec as Record<string, unknown>; | ||
| // Then parse from text content | ||
| 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; | ||
| } | ||
|
|
||
| // Create app and register handlers before connecting | ||
| const fullscreenBtn = document.getElementById("fullscreen-btn")!; | ||
| let currentDisplayMode: "inline" | "fullscreen" = "inline"; | ||
|
|
||
| const app = new App( | ||
| { name: "sidemantic-chart", version: "1.0.0" }, | ||
| { availableDisplayModes: ["inline", "fullscreen"] }, | ||
| { autoResize: false }, | ||
| ); | ||
|
|
||
| app.ontoolresult = (result: CallToolResult) => { | ||
| const spec = extractVegaSpec(result); | ||
| if (spec) { | ||
| renderChart(spec); | ||
| } else { | ||
| container.innerHTML = '<div class="error">No chart data in tool result</div>'; | ||
| } | ||
| }; | ||
|
|
||
| app.ontoolinput = () => { | ||
| container.innerHTML = '<div class="loading">Running query...</div>'; | ||
| }; | ||
|
|
||
| app.onhostcontextchanged = (ctx: McpUiHostContext) => { | ||
| if (ctx.theme) applyDocumentTheme(ctx.theme); | ||
| if (ctx.availableDisplayModes) { | ||
| const canFullscreen = ctx.availableDisplayModes.includes("fullscreen"); | ||
| fullscreenBtn.classList.toggle("available", canFullscreen); | ||
| } | ||
| if (ctx.displayMode === "inline" || ctx.displayMode === "fullscreen") { | ||
| currentDisplayMode = ctx.displayMode; | ||
| document.body.classList.toggle("fullscreen", currentDisplayMode === "fullscreen"); | ||
| fullscreenBtn.style.display = currentDisplayMode === "fullscreen" ? "none" : ""; | ||
| } | ||
| }; | ||
|
|
||
| fullscreenBtn.addEventListener("click", async () => { | ||
| const newMode = currentDisplayMode === "fullscreen" ? "inline" : "fullscreen"; | ||
| const ctx = app.getHostContext(); | ||
| if (ctx?.availableDisplayModes?.includes(newMode)) { | ||
| const result = await app.requestDisplayMode({ mode: newMode }); | ||
| currentDisplayMode = result.mode as "inline" | "fullscreen"; | ||
| document.body.classList.toggle("fullscreen", currentDisplayMode === "fullscreen"); | ||
| fullscreenBtn.style.display = currentDisplayMode === "fullscreen" ? "none" : ""; | ||
| } | ||
| }); | ||
|
|
||
| app.connect().then(() => { | ||
| const ctx = app.getHostContext(); | ||
| if (ctx?.theme) applyDocumentTheme(ctx.theme); | ||
| if (ctx?.availableDisplayModes?.includes("fullscreen")) { | ||
| fullscreenBtn.classList.add("available"); | ||
| } | ||
| // Keep fullscreen button, replace only the chart content area | ||
| 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,42 @@ | ||
| <!DOCTYPE html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="UTF-8"> | ||
| <meta name="color-scheme" content="light dark"> | ||
| <style> | ||
| html, body { margin: 0; padding: 0; overflow: hidden; background: transparent; | ||
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } | ||
| body { margin: 0; padding: 0; width: 100%; background: transparent; } | ||
| #chart { width: 100%; min-height: 500px; position: relative; } | ||
| .vega-embed { background: transparent !important; } | ||
| .fullscreen-btn { | ||
| position: absolute; top: 8px; right: 8px; z-index: 10; | ||
| width: 32px; height: 32px; border: 1px solid #ccc; border-radius: 6px; | ||
| background: #fff; cursor: pointer; color: #333; | ||
| display: none; opacity: 0; transition: opacity 0.2s; | ||
| align-items: center; justify-content: center; font-size: 16px; | ||
| box-shadow: 0 1px 3px rgba(0,0,0,0.1); | ||
| } | ||
| .fullscreen-btn.available { display: flex; } | ||
| #chart:hover .fullscreen-btn.available { opacity: 1; } | ||
| .fullscreen-btn:hover { background: #f0f0f0; } | ||
| body.fullscreen #chart { min-height: 100vh; } | ||
| @media (prefers-color-scheme: dark) { | ||
| .fullscreen-btn { background: rgba(255,255,255,0.1); } | ||
| .fullscreen-btn:hover { background: rgba(255,255,255,0.2); } | ||
| } | ||
| #chart .vega-embed, #chart .vega-embed > div, | ||
| #chart .vega-embed canvas, #chart .vega-embed svg { overflow: hidden !important; } | ||
| #chart .vega-actions { overflow: visible; } | ||
| .error { padding: 2rem; text-align: center; color: #dc2626; } | ||
| .loading { padding: 2rem; text-align: center; color: #999; } | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <div id="chart"> | ||
| <button id="fullscreen-btn" class="fullscreen-btn" title="Toggle fullscreen">⛶</button> | ||
| <div class="loading">Loading...</div> | ||
| </div> | ||
| <script type="module" src="./chart-app.ts"></script> | ||
| </body> | ||
| </html> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| { | ||
| "name": "sidemantic-chart-widget", | ||
| "private": true, | ||
| "type": "module", | ||
| "scripts": { | ||
| "build": "vite build" | ||
| }, | ||
| "dependencies": { | ||
| "@modelcontextprotocol/ext-apps": "^1.3.2", | ||
| "vega": "^6.2.0", | ||
| "vega-embed": "^7.1.0", | ||
| "vega-interpreter": "^2.2.1", | ||
| "vega-lite": "^6.4.2" | ||
| }, | ||
| "devDependencies": { | ||
| "vite": "^6.0.0", | ||
| "vite-plugin-singlefile": "^2.3.0", | ||
| "typescript": "^5.9.3" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| import { defineConfig } from "vite"; | ||
| import { viteSingleFile } from "vite-plugin-singlefile"; | ||
|
|
||
| export default defineConfig({ | ||
| plugins: [viteSingleFile()], | ||
| build: { | ||
| rollupOptions: { input: "chart.html" }, | ||
| outDir: "../", | ||
| emptyOutDir: false, | ||
| }, | ||
| define: { | ||
| // Replace new Function calls with a safe fallback at build time | ||
| // This prevents CSP violations in MCP Apps sandboxes | ||
| }, | ||
| resolve: { | ||
| alias: { | ||
| // Use CSP-safe expression interpreter | ||
| "vega-functions/codegenExpression": "vega-interpreter", | ||
| }, | ||
| }, | ||
| }); |
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.
renderChartalways forcesspec.width = "container"andspec.height = 500, so the widget ignores thewidth/heightrequested viacreate_chartand remains 500px tall even in fullscreen mode. In practice this can clip dense charts (many categories/labels) and makes fullscreen mostly unused vertical space; the renderer should preserve the incoming size or compute height from the current container/display mode instead of hard-coding 500.Useful? React with 👍 / 👎.