Skip to content
Open
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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ mcp = [
]
apps = [
"mcp[cli]>=1.25.0,<2",
"pyarrow>=14.0.0",
"altair>=5.0.0",
"vl-convert-python>=1.0.0",
]
Expand Down
52 changes: 43 additions & 9 deletions sidemantic/apps/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
"""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
Expand All @@ -10,16 +12,48 @@

from sidemantic.vendor_assets import inline_vendor_scripts

_WIDGET_TEMPLATE: str | None = None
_CHART_HTML: str | None = None
_EXPLORER_HTML: str | None = None


def _get_widget_template() -> str:
"""Load the chart widget HTML template."""
global _WIDGET_TEMPLATE
if _WIDGET_TEMPLATE is None:
"""Load the built chart widget HTML for the MCP Apps resource handler."""
global _CHART_HTML
if _CHART_HTML is None:
built = Path(__file__).parent / "chart.html"
if built.exists():
_CHART_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 _CHART_HTML


def _get_explorer_template() -> str:
"""Load the built explorer widget HTML for the MCP Apps resource handler."""
global _EXPLORER_HTML
if _EXPLORER_HTML is None:
built = Path(__file__).parent / "explorer.html"
if built.exists():
_EXPLORER_HTML = built.read_text()
else:
raise FileNotFoundError(
f"Explorer widget not built at {built}. Run: cd sidemantic/apps/web && bun install && bun run build"
)
return _EXPLORER_HTML


_CHART_WIDGET_TEMPLATE: str | None = None


def _get_chart_widget_template() -> str:
"""Load the templated chart widget HTML with vendor-script placeholders."""
global _CHART_WIDGET_TEMPLATE
if _CHART_WIDGET_TEMPLATE is None:
path = Path(__file__).parent / "chart_widget.html"
_WIDGET_TEMPLATE = path.read_text()
return _WIDGET_TEMPLATE
_CHART_WIDGET_TEMPLATE = path.read_text()
return _CHART_WIDGET_TEMPLATE


def build_chart_html(vega_spec: dict[str, Any]) -> str:
Expand All @@ -31,7 +65,7 @@ def build_chart_html(vega_spec: dict[str, Any]) -> str:
Returns:
Complete HTML string with the spec injected.
"""
template = _get_widget_template()
template = _get_chart_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")
Expand Down
313 changes: 313 additions & 0 deletions sidemantic/apps/chart.html

Large diffs are not rendered by default.

192 changes: 192 additions & 0 deletions sidemantic/apps/explorer.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
141 changes: 141 additions & 0 deletions sidemantic/apps/web/chart-app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
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;
let activeObserver: ResizeObserver | null = null;
let activeView: { finalize: () => void } | null = null;
let renderGeneration = 0;

function cleanupChart() {
if (activeObserver) { activeObserver.disconnect(); activeObserver = null; }
if (activeView) { activeView.finalize(); activeView = null; }
}

function renderChart(vegaSpec: Record<string, unknown>) {
cleanupChart();
const generation = ++renderGeneration;

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) => {
if (generation !== renderGeneration) { result.finalize(); return; }

activeView = result;
const ro = new ResizeObserver(() => result.view.resize().run());
ro.observe(container);
Comment thread
nicosuave marked this conversation as resolved.
activeObserver = ro;
Comment thread
nicosuave marked this conversation as resolved.

if (!isFullscreen) {
addExpandButton();
}

requestAnimationFrame(() => {
if (generation !== renderGeneration) return;
if (isFullscreen) {
app.sendSizeChanged({ height: window.innerHeight - 150 });
} else {
const h = Math.max(505, document.documentElement.scrollHeight + 5);
app.sendSizeChanged({ height: h });
}
});
})
.catch((err) => {
if (generation !== renderGeneration) return;
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 {
cleanupChart();
lastSpec = null;
container.innerHTML = '<div class="error">No chart data in tool result</div>';
}
Comment thread
nicosuave marked this conversation as resolved.
Comment thread
nicosuave marked this conversation as resolved.
};

app.ontoolinput = () => {
cleanupChart();
lastSpec = null;
++renderGeneration;
container.innerHTML = '<div class="loading">Running query...</div>';
Comment thread
nicosuave marked this conversation as resolved.
Comment thread
nicosuave marked this conversation as resolved.
};

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>
Loading
Loading