Skip to content

Commit dc3ba0e

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 dc3ba0e

11 files changed

Lines changed: 601 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: 318 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: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
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+
// Clear chart content but preserve the fullscreen button
10+
Array.from(container.children).forEach(c => {
11+
if (c.id !== "fullscreen-btn") c.remove();
12+
});
13+
const spec = { ...vegaSpec };
14+
spec.width = "container";
15+
spec.height = 500;
16+
spec.background = "transparent";
17+
18+
const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches;
19+
20+
embed(container, spec as any, {
21+
actions: false,
22+
theme: prefersDark ? "dark" : undefined,
23+
// CSP-safe: use AST interpreter instead of eval
24+
ast: true,
25+
expr: expressionInterpreter,
26+
})
27+
.then((result) => {
28+
const ro = new ResizeObserver(() => result.view.resize().run());
29+
ro.observe(container);
30+
// Tell host the actual content height after render
31+
requestAnimationFrame(() => {
32+
const h = Math.max(500, document.documentElement.scrollHeight);
33+
app.sendSizeChanged({ height: h });
34+
});
35+
})
36+
.catch((err) => {
37+
container.innerHTML = `<div class="error">Chart render error: ${err.message}</div>`;
38+
});
39+
}
40+
41+
function extractVegaSpec(result: CallToolResult): Record<string, unknown> | null {
42+
// Try structuredContent first
43+
const sc = result.structuredContent as Record<string, unknown> | undefined;
44+
if (sc?.vega_spec) return sc.vega_spec as Record<string, unknown>;
45+
// Then parse from text content
46+
if (result.content) {
47+
for (const item of result.content) {
48+
if (item.type === "text") {
49+
try {
50+
const data = JSON.parse((item as { text: string }).text);
51+
if (data.vega_spec) return data.vega_spec;
52+
} catch {}
53+
}
54+
}
55+
}
56+
return null;
57+
}
58+
59+
// Create app and register handlers before connecting
60+
const fullscreenBtn = document.getElementById("fullscreen-btn")!;
61+
let currentDisplayMode: "inline" | "fullscreen" = "inline";
62+
63+
const app = new App(
64+
{ name: "sidemantic-chart", version: "1.0.0" },
65+
{ availableDisplayModes: ["inline", "fullscreen"] },
66+
{ autoResize: false },
67+
);
68+
69+
app.ontoolresult = (result: CallToolResult) => {
70+
const spec = extractVegaSpec(result);
71+
if (spec) {
72+
renderChart(spec);
73+
} else {
74+
container.innerHTML = '<div class="error">No chart data in tool result</div>';
75+
}
76+
};
77+
78+
app.ontoolinput = () => {
79+
container.innerHTML = '<div class="loading">Running query...</div>';
80+
};
81+
82+
app.onhostcontextchanged = (ctx: McpUiHostContext) => {
83+
if (ctx.theme) applyDocumentTheme(ctx.theme);
84+
if (ctx.availableDisplayModes) {
85+
const canFullscreen = ctx.availableDisplayModes.includes("fullscreen");
86+
fullscreenBtn.classList.toggle("available", canFullscreen);
87+
}
88+
if (ctx.displayMode === "inline" || ctx.displayMode === "fullscreen") {
89+
currentDisplayMode = ctx.displayMode;
90+
document.body.classList.toggle("fullscreen", currentDisplayMode === "fullscreen");
91+
fullscreenBtn.style.display = currentDisplayMode === "fullscreen" ? "none" : "";
92+
}
93+
};
94+
95+
fullscreenBtn.addEventListener("click", async () => {
96+
const newMode = currentDisplayMode === "fullscreen" ? "inline" : "fullscreen";
97+
const ctx = app.getHostContext();
98+
if (ctx?.availableDisplayModes?.includes(newMode)) {
99+
const result = await app.requestDisplayMode({ mode: newMode });
100+
currentDisplayMode = result.mode as "inline" | "fullscreen";
101+
document.body.classList.toggle("fullscreen", currentDisplayMode === "fullscreen");
102+
fullscreenBtn.style.display = currentDisplayMode === "fullscreen" ? "none" : "";
103+
}
104+
});
105+
106+
app.connect().then(() => {
107+
const ctx = app.getHostContext();
108+
if (ctx?.theme) applyDocumentTheme(ctx.theme);
109+
if (ctx?.availableDisplayModes?.includes("fullscreen")) {
110+
fullscreenBtn.classList.add("available");
111+
}
112+
// Keep fullscreen button, replace only the chart content area
113+
const loading = container.querySelector(".loading");
114+
if (loading) loading.textContent = "Waiting for chart data...";
115+
app.sendSizeChanged({ height: 500 });
116+
});

sidemantic/apps/web/chart.html

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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 { margin: 0; padding: 0; width: 100%; background: transparent; }
10+
#chart { width: 100%; min-height: 500px; position: relative; }
11+
.vega-embed { background: transparent !important; }
12+
.fullscreen-btn {
13+
position: absolute; top: 8px; right: 8px; z-index: 10;
14+
width: 32px; height: 32px; border: 1px solid #ccc; border-radius: 6px;
15+
background: #fff; cursor: pointer; color: #333;
16+
display: none; opacity: 0; transition: opacity 0.2s;
17+
align-items: center; justify-content: center; font-size: 16px;
18+
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
19+
}
20+
.fullscreen-btn.available { display: flex; }
21+
#chart:hover .fullscreen-btn.available { opacity: 1; }
22+
.fullscreen-btn:hover { background: #f0f0f0; }
23+
body.fullscreen #chart { min-height: 100vh; }
24+
@media (prefers-color-scheme: dark) {
25+
.fullscreen-btn { background: rgba(255,255,255,0.1); }
26+
.fullscreen-btn:hover { background: rgba(255,255,255,0.2); }
27+
}
28+
#chart .vega-embed, #chart .vega-embed > div,
29+
#chart .vega-embed canvas, #chart .vega-embed svg { overflow: hidden !important; }
30+
#chart .vega-actions { overflow: visible; }
31+
.error { padding: 2rem; text-align: center; color: #dc2626; }
32+
.loading { padding: 2rem; text-align: center; color: #999; }
33+
</style>
34+
</head>
35+
<body>
36+
<div id="chart">
37+
<button id="fullscreen-btn" class="fullscreen-btn" title="Toggle fullscreen"></button>
38+
<div class="loading">Loading...</div>
39+
</div>
40+
<script type="module" src="./chart-app.ts"></script>
41+
</body>
42+
</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)