diff --git a/sidemantic/apps/__init__.py b/sidemantic/apps/__init__.py index 736df9b..476c3f1 100644 --- a/sidemantic/apps/__init__.py +++ b/sidemantic/apps/__init__.py @@ -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 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 diff --git a/sidemantic/apps/chart.html b/sidemantic/apps/chart.html new file mode 100644 index 0000000..3e7c903 --- /dev/null +++ b/sidemantic/apps/chart.html @@ -0,0 +1,318 @@ + + + + + + + + + +
+ +
Loading...
+
+ + diff --git a/sidemantic/apps/web/.gitignore b/sidemantic/apps/web/.gitignore new file mode 100644 index 0000000..d77474a --- /dev/null +++ b/sidemantic/apps/web/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +bun.lock diff --git a/sidemantic/apps/web/chart-app.ts b/sidemantic/apps/web/chart-app.ts new file mode 100644 index 0000000..dab9259 --- /dev/null +++ b/sidemantic/apps/web/chart-app.ts @@ -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) { + // 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, + // 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); + // 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 = `
Chart render error: ${err.message}
`; + }); +} + +function extractVegaSpec(result: CallToolResult): Record | null { + // Try structuredContent first + const sc = result.structuredContent as Record | undefined; + if (sc?.vega_spec) return sc.vega_spec as Record; + // 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 = '
No chart data in tool result
'; + } +}; + +app.ontoolinput = () => { + container.innerHTML = '
Running query...
'; +}; + +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 }); +}); diff --git a/sidemantic/apps/web/chart.html b/sidemantic/apps/web/chart.html new file mode 100644 index 0000000..d600fff --- /dev/null +++ b/sidemantic/apps/web/chart.html @@ -0,0 +1,42 @@ + + + + + + + + +
+ +
Loading...
+
+ + + diff --git a/sidemantic/apps/web/package.json b/sidemantic/apps/web/package.json new file mode 100644 index 0000000..e3745bd --- /dev/null +++ b/sidemantic/apps/web/package.json @@ -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" + } +} diff --git a/sidemantic/apps/web/vite.config.ts b/sidemantic/apps/web/vite.config.ts new file mode 100644 index 0000000..e8b27df --- /dev/null +++ b/sidemantic/apps/web/vite.config.ts @@ -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", + }, + }, +}); diff --git a/sidemantic/cli.py b/sidemantic/cli.py index 31a58f7..f7b0f66 100644 --- a/sidemantic/cli.py +++ b/sidemantic/cli.py @@ -328,13 +328,6 @@ def mcp_serve( placeholders = ", ".join(["?" for _ in columns]) layer.adapter.executemany(f"INSERT INTO {table} VALUES ({placeholders})", rows) - # Enable apps mode if requested - if apps: - import sidemantic.mcp_server as _mcp_mod - - _mcp_mod._apps_enabled = True - typer.echo("Interactive UI widgets enabled", err=True) - # Determine transport if http or apps: if apps and not http: diff --git a/sidemantic/mcp_server.py b/sidemantic/mcp_server.py index 56b366f..c464c84 100644 --- a/sidemantic/mcp_server.py +++ b/sidemantic/mcp_server.py @@ -13,7 +13,6 @@ # Global semantic layer instance _layer: SemanticLayer | None = None -_apps_enabled: bool = False def initialize_layer( @@ -391,7 +390,18 @@ def run_query( } -@mcp.tool(structured_output=False, meta={"ui": {"resourceUri": "ui://sidemantic/chart"}}) +@mcp.tool( + structured_output=False, + meta={ + "ui": { + "resourceUri": "ui://sidemantic/chart", + "csp": { + "connectDomains": [], + "resourceDomains": [], + }, + }, + }, +) def create_chart( dimensions: list[str] = [], metrics: list[str] = [], @@ -433,7 +443,7 @@ def create_chart( png_base64: Base64-encoded PNG image row_count: Number of data points """ - from sidemantic.charts import chart_to_base64_png, chart_to_vega + from sidemantic.charts import chart_to_vega from sidemantic.charts import create_chart as make_chart layer = get_layer() @@ -478,25 +488,15 @@ def create_chart( height=height, ) - # Export to both formats + # Export Vega spec (rendered interactively by MCP Apps widget) vega_spec = chart_to_vega(chart) - png_base64 = chart_to_base64_png(chart) - result = { + return { "sql": sql, "vega_spec": vega_spec, - "png_base64": png_base64, "row_count": len(row_dicts), } - # When apps mode is enabled, include an interactive UI widget - if _apps_enabled: - from sidemantic.apps import create_chart_resource - - return [result, create_chart_resource(vega_spec)] - - return result - def _generate_chart_title(dimensions: list[str], metrics: list[str]) -> str: """Generate a descriptive title from query parameters.""" @@ -699,7 +699,24 @@ def get_semantic_graph() -> dict[str, Any]: return result -# --- MCP Resource: Catalog Metadata --- +# --- MCP Resources --- + + +@mcp.resource( + "ui://sidemantic/chart", + mime_type="text/html;profile=mcp-app", + meta={ + "ui": { + "csp": {"connectDomains": [], "resourceDomains": []}, + }, + "mcpui.dev/ui-preferred-frame-size": ["100%", "500px"], + }, +) +def chart_widget_resource() -> str: + """Interactive Vega-Lite chart widget for MCP Apps-compatible hosts.""" + from sidemantic.apps import _get_widget_template + + return _get_widget_template() @mcp.resource("semantic://catalog") diff --git a/tests/test_mcp_apps.py b/tests/test_mcp_apps.py index ca75c71..13586c1 100644 --- a/tests/test_mcp_apps.py +++ b/tests/test_mcp_apps.py @@ -4,7 +4,7 @@ pytest.importorskip("mcp") # Skip if mcp extra not installed -from sidemantic.apps import build_chart_html, create_chart_resource +from sidemantic.apps import _get_widget_template from sidemantic.mcp_server import create_chart, initialize_layer @@ -40,96 +40,35 @@ def demo_layer(tmp_path): yield layer -def test_build_chart_html(): - """Test that build_chart_html embeds the Vega spec.""" - spec = {"$schema": "https://vega.github.io/schema/vega-lite/v5.json", "mark": "bar"} - html = build_chart_html(spec) - - assert "{{VEGA_SPEC}}" not in html - assert '"$schema"' in html - assert '"mark"' in html - assert "vega-embed" in html - - -def test_build_chart_html_escapes_json(): - """Test that JSON with special chars is properly embedded.""" - spec = {"title": "Revenue <&> Costs", "description": 'Test\'s "spec"'} - html = build_chart_html(spec) - - # < in the JSON data should be escaped to \u003c - assert "\\u003c" in html - # The raw < from user input should not appear in the JSON block - assert "Revenue <&>" not in html - assert "Revenue \\u003c&>" in html - - -def test_build_chart_html_prevents_script_injection(): - """Test that in user input cannot break out of the JSON block.""" - spec = {"title": ''} - html = build_chart_html(spec) - - assert "