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 @@ -44,6 +44,7 @@ mcp = [
]
apps = [
"mcp[cli]>=1.25.0,<2",
"pyarrow>=14.0.0",
]
charts = [
"altair>=5.0.0",
Expand Down
82 changes: 31 additions & 51 deletions sidemantic/apps/__init__.py
Original file line number Diff line number Diff line change
@@ -1,60 +1,40 @@
"""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
_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:
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 _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
313 changes: 313 additions & 0 deletions sidemantic/apps/chart.html

Large diffs are not rendered by default.

194 changes: 194 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 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 👍 / 👎.

activeObserver = ro;
Comment on lines +39 to +45
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 Ignore stale embed results when a newer render starts

The embed(...).then(...) callback always installs activeView/ResizeObserver even if another renderChart call has already started, so rapid updates (e.g., back-to-back tool results or display-mode changes) can let an older in-flight render reattach a stale view and extra observer after the newer render has cleared the container. Although cleanup now runs at render start, it does not protect against this out-of-order promise resolution, so stale observers and wrong chart state can still accumulate.

Useful? React with 👍 / 👎.


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 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 = () => {
cleanupChart();
lastSpec = null;
++renderGeneration;
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>
Loading
Loading