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 @@
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
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 "