Skip to content

Commit 4ad2da7

Browse files
committed
MCP Apps interactive Vega-Lite charts with CSP-safe rendering
- Vite-built widget bundles ext-apps SDK + Vega-Lite + vega-interpreter into a single HTML file (no CDN deps, no eval, CSP-safe) - Widget receives tool result via MCP Apps protocol (ontoolresult) - Supports fullscreen toggle when host advertises it - create_chart returns vega_spec only (no PNG, widget renders interactively) - Register ui://sidemantic/chart resource with proper MCP Apps metadata - Remove _apps_enabled flag (MCP Apps works via protocol, not a runtime flag) - Remove vendored mcp-ui-server (widget is self-contained) - Remove --apps CLI flag dependency on mcp-ui-server import - structured_output=False on all tools (fixes Claude Desktop tool visibility) - Replace | None params with falsy defaults (removes anyOf from schemas)
1 parent 98406be commit 4ad2da7

11 files changed

Lines changed: 532 additions & 172 deletions

File tree

sidemantic/apps/__init__.py

Lines changed: 16 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,25 @@
11
"""MCP Apps integration for sidemantic.
22
3-
Creates vendor-neutral UI resources (MCP Apps standard) that render
4-
interactive charts in any MCP Apps-compatible host.
3+
Provides interactive chart widgets for MCP Apps-compatible hosts.
4+
The widget is built with Vite (sidemantic/apps/web/) and bundled into
5+
a single HTML file (sidemantic/apps/chart.html) that includes the
6+
ext-apps SDK and Vega-Lite with CSP-safe interpreter.
57
"""
68

7-
import json
89
from pathlib import Path
9-
from typing import Any
1010

11-
_WIDGET_TEMPLATE: str | None = None
11+
_WIDGET_HTML: str | None = None
1212

1313

1414
def _get_widget_template() -> str:
15-
"""Load the chart widget HTML template."""
16-
global _WIDGET_TEMPLATE
17-
if _WIDGET_TEMPLATE is None:
18-
path = Path(__file__).parent / "chart_widget.html"
19-
_WIDGET_TEMPLATE = path.read_text()
20-
return _WIDGET_TEMPLATE
21-
22-
23-
def build_chart_html(vega_spec: dict[str, Any]) -> str:
24-
"""Build a self-contained chart widget HTML with embedded Vega spec.
25-
26-
Args:
27-
vega_spec: Vega-Lite specification dict.
28-
29-
Returns:
30-
Complete HTML string with the spec injected.
31-
"""
32-
template = _get_widget_template()
33-
# Escape </script> sequences to prevent XSS when user-provided strings
34-
# (e.g., chart titles) flow into the Vega spec.
35-
safe_json = json.dumps(vega_spec).replace("<", "\\u003c")
36-
return template.replace("{{VEGA_SPEC}}", safe_json)
37-
38-
39-
def create_chart_resource(vega_spec: dict[str, Any]):
40-
"""Create a UIResource for a chart visualization.
41-
42-
Args:
43-
vega_spec: Vega-Lite specification dict.
44-
45-
Returns:
46-
UIResource (EmbeddedResource) for MCP Apps-compatible hosts.
47-
"""
48-
from sidemantic.apps._mcp_ui import create_ui_resource
49-
50-
html = build_chart_html(vega_spec)
51-
return create_ui_resource(
52-
{
53-
"uri": "ui://sidemantic/chart",
54-
"content": {
55-
"type": "rawHtml",
56-
"htmlString": html,
57-
},
58-
"encoding": "text",
59-
}
60-
)
15+
"""Load the built chart widget HTML for the MCP Apps resource handler."""
16+
global _WIDGET_HTML
17+
if _WIDGET_HTML is None:
18+
built = Path(__file__).parent / "chart.html"
19+
if built.exists():
20+
_WIDGET_HTML = built.read_text()
21+
else:
22+
raise FileNotFoundError(
23+
f"Chart widget not built at {built}. Run: cd sidemantic/apps/web && bun install && bun run build"
24+
)
25+
return _WIDGET_HTML

sidemantic/apps/chart.html

Lines changed: 301 additions & 0 deletions
Large diffs are not rendered by default.

sidemantic/apps/web/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
node_modules/
2+
bun.lock

sidemantic/apps/web/chart-app.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { App, applyDocumentTheme, type McpUiHostContext } from "@modelcontextprotocol/ext-apps";
2+
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
3+
import embed from "vega-embed";
4+
import { expressionInterpreter } from "vega-interpreter";
5+
6+
const container = document.getElementById("chart")!;
7+
8+
function renderChart(vegaSpec: Record<string, unknown>) {
9+
container.innerHTML = "";
10+
const spec = { ...vegaSpec };
11+
spec.width = "container";
12+
spec.height = 500;
13+
spec.background = "transparent";
14+
15+
const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches;
16+
17+
embed(container, spec as any, {
18+
actions: false,
19+
theme: prefersDark ? "dark" : undefined,
20+
ast: true,
21+
expr: expressionInterpreter,
22+
})
23+
.then((result) => {
24+
const ro = new ResizeObserver(() => result.view.resize().run());
25+
ro.observe(container);
26+
requestAnimationFrame(() => {
27+
const h = Math.max(500, document.documentElement.scrollHeight);
28+
app.sendSizeChanged({ height: h });
29+
});
30+
})
31+
.catch((err) => {
32+
container.innerHTML = `<div class="error">Chart render error: ${err.message}</div>`;
33+
});
34+
}
35+
36+
function extractVegaSpec(result: CallToolResult): Record<string, unknown> | null {
37+
const sc = result.structuredContent as Record<string, unknown> | undefined;
38+
if (sc?.vega_spec) return sc.vega_spec as Record<string, unknown>;
39+
if (result.content) {
40+
for (const item of result.content) {
41+
if (item.type === "text") {
42+
try {
43+
const data = JSON.parse((item as { text: string }).text);
44+
if (data.vega_spec) return data.vega_spec;
45+
} catch {}
46+
}
47+
}
48+
}
49+
return null;
50+
}
51+
52+
const app = new App(
53+
{ name: "sidemantic-chart", version: "1.0.0" },
54+
{},
55+
{ autoResize: false },
56+
);
57+
58+
app.ontoolresult = (result: CallToolResult) => {
59+
const spec = extractVegaSpec(result);
60+
if (spec) {
61+
renderChart(spec);
62+
} else {
63+
container.innerHTML = '<div class="error">No chart data in tool result</div>';
64+
}
65+
};
66+
67+
app.ontoolinput = () => {
68+
container.innerHTML = '<div class="loading">Running query...</div>';
69+
};
70+
71+
app.onhostcontextchanged = (ctx: McpUiHostContext) => {
72+
if (ctx.theme) applyDocumentTheme(ctx.theme);
73+
};
74+
75+
app.connect().then(() => {
76+
const ctx = app.getHostContext();
77+
if (ctx?.theme) applyDocumentTheme(ctx.theme);
78+
const loading = container.querySelector(".loading");
79+
if (loading) loading.textContent = "Waiting for chart data...";
80+
app.sendSizeChanged({ height: 500 });
81+
});

sidemantic/apps/web/chart.html

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="color-scheme" content="light dark">
6+
<style>
7+
html, body { margin: 0; padding: 0; overflow: hidden; background: transparent;
8+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
9+
body { width: 100%; }
10+
#chart { width: 100%; min-height: 500px; }
11+
.vega-embed { background: transparent !important; }
12+
#chart .vega-embed, #chart .vega-embed > div,
13+
#chart .vega-embed canvas, #chart .vega-embed svg { overflow: hidden !important; }
14+
#chart .vega-actions { overflow: visible; }
15+
.error { padding: 2rem; text-align: center; color: #dc2626; }
16+
.loading { padding: 2rem; text-align: center; color: #999; }
17+
</style>
18+
</head>
19+
<body>
20+
<div id="chart">
21+
<div class="loading">Loading...</div>
22+
</div>
23+
<script type="module" src="./chart-app.ts"></script>
24+
</body>
25+
</html>

sidemantic/apps/web/package.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"name": "sidemantic-chart-widget",
3+
"private": true,
4+
"type": "module",
5+
"scripts": {
6+
"build": "vite build"
7+
},
8+
"dependencies": {
9+
"@modelcontextprotocol/ext-apps": "^1.3.2",
10+
"vega": "^6.2.0",
11+
"vega-embed": "^7.1.0",
12+
"vega-interpreter": "^2.2.1",
13+
"vega-lite": "^6.4.2"
14+
},
15+
"devDependencies": {
16+
"vite": "^6.0.0",
17+
"vite-plugin-singlefile": "^2.3.0",
18+
"typescript": "^5.9.3"
19+
}
20+
}

sidemantic/apps/web/vite.config.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { defineConfig } from "vite";
2+
import { viteSingleFile } from "vite-plugin-singlefile";
3+
4+
export default defineConfig({
5+
plugins: [viteSingleFile()],
6+
build: {
7+
rollupOptions: { input: "chart.html" },
8+
outDir: "../",
9+
emptyOutDir: false,
10+
},
11+
define: {
12+
// Replace new Function calls with a safe fallback at build time
13+
// This prevents CSP violations in MCP Apps sandboxes
14+
},
15+
resolve: {
16+
alias: {
17+
// Use CSP-safe expression interpreter
18+
"vega-functions/codegenExpression": "vega-interpreter",
19+
},
20+
},
21+
});

sidemantic/cli.py

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -328,13 +328,6 @@ def mcp_serve(
328328
placeholders = ", ".join(["?" for _ in columns])
329329
layer.adapter.executemany(f"INSERT INTO {table} VALUES ({placeholders})", rows)
330330

331-
# Enable apps mode if requested
332-
if apps:
333-
import sidemantic.mcp_server as _mcp_mod
334-
335-
_mcp_mod._apps_enabled = True
336-
typer.echo("Interactive UI widgets enabled", err=True)
337-
338331
# Determine transport
339332
if http or apps:
340333
if apps and not http:

sidemantic/mcp_server.py

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313

1414
# Global semantic layer instance
1515
_layer: SemanticLayer | None = None
16-
_apps_enabled: bool = False
1716

1817

1918
def initialize_layer(
@@ -391,7 +390,18 @@ def run_query(
391390
}
392391

393392

394-
@mcp.tool(structured_output=False, meta={"ui": {"resourceUri": "ui://sidemantic/chart"}})
393+
@mcp.tool(
394+
structured_output=False,
395+
meta={
396+
"ui": {
397+
"resourceUri": "ui://sidemantic/chart",
398+
"csp": {
399+
"connectDomains": [],
400+
"resourceDomains": [],
401+
},
402+
},
403+
},
404+
)
395405
def create_chart(
396406
dimensions: list[str] = [],
397407
metrics: list[str] = [],
@@ -433,7 +443,7 @@ def create_chart(
433443
png_base64: Base64-encoded PNG image
434444
row_count: Number of data points
435445
"""
436-
from sidemantic.charts import chart_to_base64_png, chart_to_vega
446+
from sidemantic.charts import chart_to_vega
437447
from sidemantic.charts import create_chart as make_chart
438448

439449
layer = get_layer()
@@ -478,25 +488,15 @@ def create_chart(
478488
height=height,
479489
)
480490

481-
# Export to both formats
491+
# Export Vega spec (rendered interactively by MCP Apps widget)
482492
vega_spec = chart_to_vega(chart)
483-
png_base64 = chart_to_base64_png(chart)
484493

485-
result = {
494+
return {
486495
"sql": sql,
487496
"vega_spec": vega_spec,
488-
"png_base64": png_base64,
489497
"row_count": len(row_dicts),
490498
}
491499

492-
# When apps mode is enabled, include an interactive UI widget
493-
if _apps_enabled:
494-
from sidemantic.apps import create_chart_resource
495-
496-
return [result, create_chart_resource(vega_spec)]
497-
498-
return result
499-
500500

501501
def _generate_chart_title(dimensions: list[str], metrics: list[str]) -> str:
502502
"""Generate a descriptive title from query parameters."""
@@ -699,7 +699,24 @@ def get_semantic_graph() -> dict[str, Any]:
699699
return result
700700

701701

702-
# --- MCP Resource: Catalog Metadata ---
702+
# --- MCP Resources ---
703+
704+
705+
@mcp.resource(
706+
"ui://sidemantic/chart",
707+
mime_type="text/html;profile=mcp-app",
708+
meta={
709+
"ui": {
710+
"csp": {"connectDomains": [], "resourceDomains": []},
711+
},
712+
"mcpui.dev/ui-preferred-frame-size": ["100%", "500px"],
713+
},
714+
)
715+
def chart_widget_resource() -> str:
716+
"""Interactive Vega-Lite chart widget for MCP Apps-compatible hosts."""
717+
from sidemantic.apps import _get_widget_template
718+
719+
return _get_widget_template()
703720

704721

705722
@mcp.resource("semantic://catalog")

0 commit comments

Comments
 (0)