Skip to content
Closed
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
67 changes: 16 additions & 51 deletions sidemantic/apps/__init__.py
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
318 changes: 318 additions & 0 deletions sidemantic/apps/chart.html

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions sidemantic/apps/web/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules/
bun.lock
116 changes: 116 additions & 0 deletions sidemantic/apps/web/chart-app.ts
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;
Comment on lines +14 to +15
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Respect chart size from tool result when rendering

renderChart always forces spec.width = "container" and spec.height = 500, so the widget ignores the width/height requested via create_chart and 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 👍 / 👎.

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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use host-provided theme when selecting Vega theme

The chart theme is chosen from window.matchMedia('(prefers-color-scheme: dark)') instead of MCP host context, so embedded charts can render with the wrong Vega theme whenever host theme and OS theme differ (or when the host theme changes at runtime). This leads to inconsistent colors/contrast in clients like Claude Desktop that provide their own theme via host context.

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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Dispose previous chart observers before rerendering

Each tool result creates a new ResizeObserver bound to a new Vega view, but neither the observer nor the prior view is ever cleaned up when a subsequent query rerenders the chart. In sessions where users run multiple queries, observers accumulate and continue firing on every resize, causing redundant view.resize().run() calls and growing memory/CPU usage over time.

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 });
});
42 changes: 42 additions & 0 deletions sidemantic/apps/web/chart.html
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>
20 changes: 20 additions & 0 deletions sidemantic/apps/web/package.json
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"
}
}
21 changes: 21 additions & 0 deletions sidemantic/apps/web/vite.config.ts
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",
},
},
});
7 changes: 0 additions & 7 deletions sidemantic/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
49 changes: 33 additions & 16 deletions sidemantic/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@

# Global semantic layer instance
_layer: SemanticLayer | None = None
_apps_enabled: bool = False


def initialize_layer(
Expand Down Expand Up @@ -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] = [],
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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")
Expand Down
Loading
Loading