Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
313 changes: 313 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
119 changes: 119 additions & 0 deletions sidemantic/apps/web/chart-app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
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;

function renderChart(vegaSpec: Record<string, unknown>) {
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) => {
const ro = new ResizeObserver(() => result.view.resize().run());
ro.observe(container);
Comment on lines +43 to +44
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 old chart observers before attaching new ones

renderChart is invoked on every tool result and display-mode change, but each call creates a new ResizeObserver bound 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 👍 / 👎.


if (!isFullscreen) {
addExpandButton();
}

requestAnimationFrame(() => {
if (isFullscreen) {
app.sendSizeChanged({ height: window.innerHeight - 150 });
} else {
const h = Math.max(505, document.documentElement.scrollHeight + 5);
app.sendSizeChanged({ height: h });
}
});
})
.catch((err) => {
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 {
container.innerHTML = '<div class="error">No chart data in tool result</div>';
}
Comment on lines +116 to +117
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 Finalize active chart before replacing it with error content

When no Vega spec is found, this branch replaces the DOM with an error message but never disconnects activeObserver or finalizes activeView. If the previous call rendered a chart and the next tool result is non-chart/error output, the old Vega view remains live in memory and can continue reacting to resize events indefinitely. Run the same cleanup used by renderChart() before setting fallback/error/loading markup.

Useful? React with 👍 / 👎.

Comment on lines +113 to +117
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 Clear cached spec when a result has no vega_spec

In the no-spec branch, the widget shows an error but leaves lastSpec unchanged. A later onhostcontextchanged (for example switching inline/fullscreen) will call renderChart(lastSpec) and resurrect the previous chart even though the latest tool result had no chart data. Resetting lastSpec here avoids replaying stale visualizations after failures/non-chart outputs.

Useful? React with 👍 / 👎.

};

app.ontoolinput = () => {
container.innerHTML = '<div class="loading">Running query...</div>';
Comment on lines +120 to +124
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Invalidate pending embeds when tool input clears the chart

When a new tool call starts, this handler clears the DOM but does not advance renderGeneration. If a previous embed(...) promise resolves afterward, its .then(...) still passes the generation check and reattaches the stale chart/observer on top of the loading state. This race appears whenever a prior render is still in flight and ontoolinput fires before it settles, causing users to see outdated results during a new query.

Useful? React with 👍 / 👎.

Comment on lines +120 to +124
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 Clear cached spec when starting a new tool run

ontoolinput clears the chart and increments renderGeneration, but it leaves lastSpec populated; if a displayMode host-context update arrives before the next tool result, onhostcontextchanged will call renderChart(lastSpec) and resurrect the previous chart during the new query. This causes stale data to reappear and can overwrite the loading state, so the cached spec should be invalidated when a new run starts.

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 });
});
37 changes: 37 additions & 0 deletions sidemantic/apps/web/chart.html
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>
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