diff --git a/pyproject.toml b/pyproject.toml index c471ebd..ee73203 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ mcp = [ ] apps = [ "mcp[cli]>=1.25.0,<2", + "pyarrow>=14.0.0", ] charts = [ "altair>=5.0.0", diff --git a/sidemantic/apps/__init__.py b/sidemantic/apps/__init__.py index 736df9b..41cd53b 100644 --- a/sidemantic/apps/__init__.py +++ b/sidemantic/apps/__init__.py @@ -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 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 diff --git a/sidemantic/apps/chart.html b/sidemantic/apps/chart.html new file mode 100644 index 0000000..d3c3e78 --- /dev/null +++ b/sidemantic/apps/chart.html @@ -0,0 +1,313 @@ + + + + + + + + + +
+
Loading...
+
+ + diff --git a/sidemantic/apps/explorer.html b/sidemantic/apps/explorer.html new file mode 100644 index 0000000..2469524 --- /dev/null +++ b/sidemantic/apps/explorer.html @@ -0,0 +1,192 @@ + + + + + + + + + + +
+
Loading...
+
+ + diff --git a/sidemantic/apps/web/.gitignore b/sidemantic/apps/web/.gitignore new file mode 100644 index 0000000..d77474a --- /dev/null +++ b/sidemantic/apps/web/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +bun.lock diff --git a/sidemantic/apps/web/chart-app.ts b/sidemantic/apps/web/chart-app.ts new file mode 100644 index 0000000..7bbefe0 --- /dev/null +++ b/sidemantic/apps/web/chart-app.ts @@ -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 | 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) { + 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); + activeObserver = ro; + + 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 = `
Chart render error: ${err.message}
`; + }); +} + +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 | null { + const sc = result.structuredContent as Record | undefined; + if (sc?.vega_spec) return sc.vega_spec as Record; + 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 = '
No chart data in tool result
'; + } +}; + +app.ontoolinput = () => { + cleanupChart(); + lastSpec = null; + ++renderGeneration; + container.innerHTML = '
Running query...
'; +}; + +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 }); +}); diff --git a/sidemantic/apps/web/chart.html b/sidemantic/apps/web/chart.html new file mode 100644 index 0000000..3e1bc11 --- /dev/null +++ b/sidemantic/apps/web/chart.html @@ -0,0 +1,37 @@ + + + + + + + + +
+
Loading...
+
+ + + diff --git a/sidemantic/apps/web/explorer-app.ts b/sidemantic/apps/web/explorer-app.ts new file mode 100644 index 0000000..03e93a2 --- /dev/null +++ b/sidemantic/apps/web/explorer-app.ts @@ -0,0 +1,335 @@ +import { App, applyDocumentTheme, type McpUiHostContext } from "@modelcontextprotocol/ext-apps"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +// @ts-ignore - pre-built anywidget module +import widgetModule from "../../widget/static/widget.js"; + +const container = document.getElementById("explorer")!; +let currentDisplayMode: "inline" | "fullscreen" = "inline"; + +// Resolve render function: default export is { render: md } but bundler +// may expose it as widgetModule.render or widgetModule.default.render. +const renderWidget: (ctx: { model: WidgetModel; el: HTMLElement }) => (() => void) | void = + widgetModule.render || (widgetModule as any).default?.render; + +// The WidgetModel adapter bridges anywidget's model interface to MCP App tool calls. +// When widget.js calls model.set() + model.save_changes(), we determine what data +// needs refreshing and call the widget_query tool on the MCP server. + +class WidgetModel { + private state: Record = {}; + private listeners: Map> = new Map(); + private pendingChanges: Set = new Set(); + private app: App; + + constructor(app: App) { + this.app = app; + } + + get(field: string): any { + return this.state[field]; + } + + set(field: string, value: any): void { + this.state[field] = value; + this.pendingChanges.add(field); + } + + save_changes(): void { + const changed = new Set(this.pendingChanges); + this.pendingChanges.clear(); + + // Determine what to refresh based on what changed. + // These mirror the Python widget's observer logic: + // - filters -> all (or dimensions if active_dimension set) + // - brush_selection -> all + // - selected_metric -> dimensions + // - time_grain -> metrics + // - active_dimension -> special handling + // + // active_dimension is set briefly during filter changes, then cleared + // after 400ms. When it's set, only refresh that dimension. When cleared, + // full refresh. We handle this by checking if active_dimension was just + // set (don't query yet) or if it was just cleared or not involved + // (query based on other changes). + + if (changed.has("active_dimension")) { + const ad = this.state.active_dimension; + if (ad) { + return; + } + this.callRefreshPublic("all"); + return; + } + + if (changed.has("filters")) { + const ad = this.state.active_dimension; + if (ad) { + this.callRefreshPublic("dimensions"); + } else { + this.callRefreshPublic("all"); + } + return; + } + + if (changed.has("brush_selection")) { + this.callRefreshPublic("all"); + return; + } + + if (changed.has("selected_metric")) { + this.callRefreshPublic("dimensions"); + return; + } + + if (changed.has("time_grain")) { + this.callRefreshPublic("metrics"); + return; + } + } + + on(event: string, callback: Function): void { + if (!this.listeners.has(event)) { + this.listeners.set(event, new Set()); + } + this.listeners.get(event)!.add(callback); + } + + off(event: string, callback?: Function): void { + if (!callback) { + this.listeners.delete(event); + } else { + this.listeners.get(event)?.delete(callback); + } + } + + // Fire change event for a field + private fireChange(field: string): void { + const event = `change:${field}`; + this.listeners.get(event)?.forEach((cb) => cb()); + } + + // Apply data from tool result, updating state and firing change events + applyData(data: Record): void { + for (const [key, value] of Object.entries(data)) { + if (value !== undefined) { + this.state[key] = value; + this.fireChange(key); + } + } + } + + // Extract data dict from CallToolResult + extractData(result: CallToolResult): Record | null { + // Check structuredContent first + const sc = result.structuredContent as Record | undefined; + if (sc) return sc; + + // Fall back to text content + if (result.content) { + for (const item of result.content) { + if (item.type === "text") { + try { + return JSON.parse((item as { text: string }).text); + } catch { + // not JSON, skip + } + } + } + } + return null; + } + + // Fetch all data by firing individual per-metric and per-dimension calls + callRefreshPublic(queryType: string): void { + const filtersJson = JSON.stringify(this.state.filters || {}); + const brushJson = JSON.stringify(this.state.brush_selection || []); + const timeGrain = this.state.time_grain || "day"; + const selectedMetric = this.state.selected_metric || ""; + + if (queryType === "all" || queryType === "metrics") { + // Fire one call per metric (series + total) + const metrics = (this.state.metrics_config || []) as Array<{ key: string }>; + for (const mc of metrics) { + this.fetchMetric(mc.key, timeGrain, filtersJson, brushJson); + } + } + + if (queryType === "all" || queryType === "dimensions") { + // Fire one call per dimension + const dims = (this.state.dimensions_config || []) as Array<{ key: string }>; + for (const dc of dims) { + this.fetchDimension(dc.key, selectedMetric, filtersJson, brushJson); + } + } + } + + private async fetchMetric(metricKey: string, timeGrain: string, filtersJson: string, brushJson: string): Promise { + try { + const result = await this.app.callServerTool({ + name: "widget_query", + arguments: { + query_type: "metric", + metric_key: metricKey, + time_grain: timeGrain, + filters_json: filtersJson, + brush_selection_json: brushJson, + }, + }); + const data = this.extractData(result); + if (data && data.status !== "error") { + // Update metric total + if (data.metric_total !== undefined) { + const totals = { ...(this.state.metric_totals || {}) }; + totals[metricKey] = data.metric_total; + this.state.metric_totals = totals; + this.fireChange("metric_totals"); + } + // Update metric series (merge into combined data) + if (data.metric_series_data && data.time_series_column) { + this.state.config = { + ...(this.state.config || {}), + time_series_column: data.time_series_column, + }; + // Store per-metric series data for later merge + if (!this._metricSeriesMap) this._metricSeriesMap = {}; + this._metricSeriesMap[metricKey] = data.metric_series_data; + // Use the first available metric's series as metric_series_data + // (widget.js will parse it and render sparklines) + const firstKey = Object.keys(this._metricSeriesMap)[0]; + if (firstKey) { + this.state.metric_series_data = this._metricSeriesMap[firstKey]; + this.fireChange("metric_series_data"); + this.fireChange("config"); + } + } + // Update status + this.state.status = "ready"; + this.fireChange("status"); + } + } catch { + // Individual metric failure, don't kill the whole widget + } + } + + private async fetchDimension(dimKey: string, selectedMetric: string, filtersJson: string, brushJson: string): Promise { + try { + const result = await this.app.callServerTool({ + name: "widget_query", + arguments: { + query_type: "dimension", + dimension_key: dimKey, + selected_metric: selectedMetric, + filters_json: filtersJson, + brush_selection_json: brushJson, + }, + }); + const data = this.extractData(result); + if (data && data.dimension_data !== undefined) { + const dimData = { ...(this.state.dimension_data || {}) }; + dimData[dimKey] = data.dimension_data; + this.state.dimension_data = dimData; + this.fireChange("dimension_data"); + } + } catch { + // Individual dimension failure + } + } + + // Storage for per-metric series Arrow IPC data + private _metricSeriesMap: Record | null = null; +} + +// --- App setup --- + +const app = new App( + { name: "sidemantic-explorer", version: "1.0.0" }, + {}, + { autoResize: false }, +); + +const model = new WidgetModel(app); +let cleanup: (() => void) | null = null; + +function renderExplorer(): void { + if (cleanup) { + cleanup(); + cleanup = null; + } + container.innerHTML = ""; + + const isFullscreen = currentDisplayMode === "fullscreen"; + document.documentElement.classList.toggle("fullscreen", isFullscreen); + + // Add expand button in inline mode (before widget so it layers on top) + if (!isFullscreen) { + const btn = document.createElement("div"); + btn.className = "expand-btn"; + btn.title = "Expand to fullscreen"; + btn.textContent = "Expand \u2197"; + btn.addEventListener("click", async () => { + try { + const result = await app.requestDisplayMode({ mode: "fullscreen" }); + currentDisplayMode = result.mode as "inline" | "fullscreen"; + renderExplorer(); + } catch { + // host doesn't support fullscreen + } + }); + container.appendChild(btn); + } + + // Create widget container + const widgetEl = document.createElement("div"); + container.appendChild(widgetEl); + + // Render the anywidget + const result = renderWidget({ model, el: widgetEl }); + if (typeof result === "function") { + cleanup = result; + } + + // Report size + requestAnimationFrame(() => { + if (isFullscreen) { + app.sendSizeChanged({ height: window.innerHeight - 150 }); + } else { + const h = Math.max(605, document.documentElement.scrollHeight + 5); + app.sendSizeChanged({ height: h }); + } + }); +} + +// Handle initial tool result (from explore_metrics - config only, no data) +app.ontoolresult = (result: CallToolResult) => { + const data = model.extractData(result); + if (data) { + model.applyData(data); + renderExplorer(); + // Immediately fetch actual data via widget_query + model.callRefreshPublic("all"); + } else { + container.innerHTML = '
No explorer data in tool result
'; + } +}; + +app.ontoolinput = () => { + container.innerHTML = '
Loading explorer...
'; +}; + +app.onhostcontextchanged = (ctx: McpUiHostContext) => { + if (ctx.theme) applyDocumentTheme(ctx.theme); + if (ctx.displayMode === "inline" || ctx.displayMode === "fullscreen") { + currentDisplayMode = ctx.displayMode; + if (model.get("status") === "ready") { + renderExplorer(); + } + } +}; + +app.connect().then(() => { + const ctx = app.getHostContext(); + if (ctx?.theme) applyDocumentTheme(ctx.theme); + const loading = container.querySelector(".loading"); + if (loading) loading.textContent = "Waiting for data..."; + app.sendSizeChanged({ height: 600 }); +}); diff --git a/sidemantic/apps/web/explorer.html b/sidemantic/apps/web/explorer.html new file mode 100644 index 0000000..fea906b --- /dev/null +++ b/sidemantic/apps/web/explorer.html @@ -0,0 +1,33 @@ + + + + + + + + + +
+
Loading...
+
+ + + diff --git a/sidemantic/apps/web/package.json b/sidemantic/apps/web/package.json new file mode 100644 index 0000000..5599690 --- /dev/null +++ b/sidemantic/apps/web/package.json @@ -0,0 +1,22 @@ +{ + "name": "sidemantic-chart-widget", + "private": true, + "type": "module", + "scripts": { + "build": "ENTRY=chart vite build && ENTRY=explorer vite build", + "build:chart": "ENTRY=chart vite build", + "build:explorer": "ENTRY=explorer 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" + } +} diff --git a/sidemantic/apps/web/vite.config.ts b/sidemantic/apps/web/vite.config.ts new file mode 100644 index 0000000..e5d0600 --- /dev/null +++ b/sidemantic/apps/web/vite.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "vite"; +import { viteSingleFile } from "vite-plugin-singlefile"; +import path from "path"; + +const entry = process.env.ENTRY || "chart"; + +export default defineConfig({ + plugins: [viteSingleFile()], + build: { + rollupOptions: { input: `${entry}.html` }, + outDir: "../", + emptyOutDir: false, + }, + resolve: { + alias: { + // Use CSP-safe expression interpreter (chart widget) + "vega-functions/codegenExpression": "vega-interpreter", + }, + }, +}); diff --git a/sidemantic/cli.py b/sidemantic/cli.py index 31a58f7..f7b0f66 100644 --- a/sidemantic/cli.py +++ b/sidemantic/cli.py @@ -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: diff --git a/sidemantic/mcp_server.py b/sidemantic/mcp_server.py index 56b366f..8854142 100644 --- a/sidemantic/mcp_server.py +++ b/sidemantic/mcp_server.py @@ -1,6 +1,7 @@ """MCP server for Sidemantic semantic layer.""" import json +import sys from datetime import date, datetime, time from decimal import Decimal from pathlib import Path @@ -11,9 +12,14 @@ from sidemantic.core.semantic_layer import SemanticLayer from sidemantic.loaders import load_from_directory + +def _log(msg: str) -> None: + """Log to stderr (visible in Claude Desktop MCP logs).""" + print(f"[sidemantic] {msg}", file=sys.stderr, flush=True) + + # Global semantic layer instance _layer: SemanticLayer | None = None -_apps_enabled: bool = False def initialize_layer( @@ -391,7 +397,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] = [], @@ -433,7 +450,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() @@ -478,25 +495,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.""" @@ -699,7 +706,499 @@ def get_semantic_graph() -> dict[str, Any]: return result -# --- MCP Resource: Catalog Metadata --- +# --- Explorer state --- + +_explorer_state: dict | None = None + + +def _table_to_ipc_base64(table, *, decimal_mode: str = "float") -> str: + """Serialize a PyArrow table to base64-encoded Arrow IPC bytes.""" + import base64 + import io + + import pyarrow as pa + import pyarrow.compute as pc + + if any(pa.types.is_decimal(field.type) for field in table.schema): + arrays = [] + fields = [] + for field in table.schema: + column = table[field.name] + if pa.types.is_decimal(field.type): + cast_type = pa.string() if decimal_mode == "string" else pa.float64() + arrays.append(pc.cast(column, cast_type)) + fields.append(pa.field(field.name, cast_type)) + else: + arrays.append(column) + fields.append(field) + table = pa.table(arrays, schema=pa.schema(fields)) + + sink = io.BytesIO() + with pa.ipc.new_file(sink, table.schema) as writer: + writer.write_table(table) + return base64.b64encode(sink.getvalue()).decode("ascii") + + +def _execute_arrow(layer, sql): + """Execute SQL via the layer adapter and return a PyArrow Table.""" + result = layer.adapter.execute(sql) + reader = result.fetch_record_batch() + return reader.read_all() + + +def _escape_sql_literal(value: str) -> str: + """Escape a string for use inside a SQL single-quoted literal.""" + return value.replace("'", "''") + + +def _build_explorer_filters(state: dict, *, exclude_dimension: str | None = None) -> list[str]: + """Build SQL filter expressions from explorer state. + + State dict keys: model_name, time_dimension, filters, date_range, brush_selection. + """ + filter_exprs: list[str] = [] + model_name = state["model_name"] + time_dim = state.get("time_dimension") + + # Determine effective date range (brush overrides date_range) + brush = state.get("brush_selection", []) + date_range = brush if len(brush) == 2 else state.get("date_range", []) + + if time_dim and len(date_range) == 2: + start_str = str(date_range[0]) + end_str = str(date_range[1]) + start_literal = _format_date_literal(start_str) + end_literal = _format_date_literal(end_str) + filter_exprs.append(f"{model_name}.{time_dim} >= {start_literal} AND {model_name}.{time_dim} <= {end_literal}") + + # Dimension filters + for dim_key, values in state.get("filters", {}).items(): + if exclude_dimension and dim_key == exclude_dimension: + continue + if not values: + continue + if len(values) == 1: + if values[0] is None: + filter_exprs.append(f"{model_name}.{dim_key} IS NULL") + else: + safe = _escape_sql_literal(str(values[0])) + filter_exprs.append(f"{model_name}.{dim_key} = '{safe}'") + else: + clauses: list[str] = [] + for v in values: + if v is None: + clauses.append(f"{model_name}.{dim_key} IS NULL") + else: + safe = _escape_sql_literal(str(v)) + clauses.append(f"{model_name}.{dim_key} = '{safe}'") + filter_exprs.append(f"({' OR '.join(clauses)})") + + return filter_exprs + + +def _format_date_literal(value: str) -> str: + """Format a date/datetime string as a SQL CAST expression.""" + # Date-only: exactly 10 chars like "2024-01-01" + if len(value) == 10 and value[4] == "-" and value[7] == "-": + return f"CAST('{value}' AS DATE)" + return f"CAST('{value}' AS TIMESTAMP)" + + +def _merge_metric_tables(tables: list, time_column: str) -> str: + """Merge per-metric Arrow tables into one table joined on the time column.""" + import pyarrow as pa + + if not tables: + return "" + if len(tables) == 1: + return _table_to_ipc_base64(tables[0], decimal_mode="float") + + # Start with the first table's time column + merged = tables[0] + for t in tables[1:]: + # Each table has time_column + one metric column. Extract the metric column. + metric_cols = [f.name for f in t.schema if f.name != time_column] + for col_name in metric_cols: + if col_name not in [f.name for f in merged.schema]: + merged = merged.append_column(col_name, pa.nulls(merged.num_rows, type=pa.float64())) + # Build a lookup from time values to metric values + time_vals = t.column(time_column).to_pylist() + metric_vals = t.column(col_name).to_pylist() + lookup = dict(zip(time_vals, metric_vals)) + # Fill in the values + merged_time = merged.column(time_column).to_pylist() + filled = [lookup.get(tv) for tv in merged_time] + idx = merged.schema.get_field_index(col_name) + merged = merged.set_column(idx, col_name, pa.array(filled, type=pa.float64())) + + return _table_to_ipc_base64(merged, decimal_mode="float") + + +def _detect_time_series_column(table, *, grain: str, time_dimension: str = "") -> str | None: + """Find the time-series column name from an Arrow schema.""" + import pyarrow as pa + + schema = getattr(table, "schema", None) + if schema is None: + return None + + # Prefer the grain-suffixed column (e.g. timestamp__day) + suffix = f"__{grain}" + for field in schema: + if suffix in field.name: + return field.name + + # Then try Arrow date/timestamp types + for field in schema: + if pa.types.is_date(field.type) or pa.types.is_timestamp(field.type): + return field.name + + # Then match on time_dimension name + if time_dimension: + for field in schema: + if time_dimension in field.name: + return field.name + + # Fall back to first column + if len(schema) > 0: + return schema[0].name + + return None + + +@mcp.tool( + structured_output=False, + meta={ + "ui": { + "resourceUri": "ui://sidemantic/explorer", + "csp": {"connectDomains": [], "resourceDomains": []}, + }, + }, +) +def explore_metrics( + model_name: str = "", + metrics: list[str] = [], + dimensions: list[str] = [], + time_dimension: str = "", + start_date: str = "", + end_date: str = "", +) -> dict[str, Any]: + """Launch an interactive metrics explorer for a semantic model. + + Opens a dashboard showing metric time series, totals, and dimension + leaderboards. All parameters are optional: without arguments, uses the + first model and auto-discovers metrics, dimensions, and time dimension. + + Args: + model_name: Model to explore (defaults to first model) + metrics: Metric refs to show (e.g., ["orders.revenue"]). Defaults to all. + dimensions: Dimension refs for leaderboards. Defaults to categorical/boolean dims. + time_dimension: Time dimension name for series. Defaults to model default. + start_date: Start date filter (e.g., "2026-01-01"). Defaults to min date in data. + end_date: End date filter (e.g., "2026-03-30"). Defaults to max date in data. + + Returns: + Explorer configuration and initial data for the interactive widget. + """ + global _explorer_state + + _log(f"explore_metrics called: model={model_name}, start={start_date}, end={end_date}") + + layer = get_layer() + graph = layer.graph + model_names = list(graph.models.keys()) + if not model_names: + raise ValueError("SemanticLayer has no models") + + # Resolve model + if not model_name: + model_name = model_names[0] + model = graph.models.get(model_name) + if model is None: + raise ValueError(f"Model '{model_name}' not found. Available: {model_names}") + + # Resolve metrics (cap at 8 to avoid timeout from too many individual queries) + if not metrics: + metrics = [f"{model_name}.{m.name}" for m in (model.metrics or [])][:8] + + # Resolve dimensions (categorical/boolean only, cap at 8) + if not dimensions: + dimensions = [ + f"{model_name}.{d.name}" for d in (model.dimensions or []) if d.type in ("categorical", "boolean") + ][:8] + + # Resolve time dimension + if not time_dimension: + if model.default_time_dimension: + time_dimension = model.default_time_dimension + else: + time_dims = [d for d in (model.dimensions or []) if d.type == "time"] + if time_dims: + time_dimension = time_dims[0].name + + # Build config + config = { + "model_name": model_name, + "time_dimension": time_dimension, + "time_dimension_ref": f"{model_name}.{time_dimension}" if time_dimension else None, + } + + # Build metrics config + metrics_config = [] + for metric_ref in metrics: + metric_name = metric_ref.split(".")[-1] + metrics_config.append( + { + "key": metric_name, + "ref": metric_ref, + "label": metric_name.replace("_", " ").title(), + "format": "number", + } + ) + + # Build dimensions config + dimensions_config = [] + for dim_ref in dimensions: + dim_name = dim_ref.split(".")[-1] + dimensions_config.append( + { + "key": dim_name, + "ref": dim_ref, + "label": dim_name.replace("_", " ").title(), + } + ) + + # Time grain options + time_dim_obj = model.get_dimension(time_dimension) if time_dimension else None + time_grain_options = ( + time_dim_obj.supported_granularities + if time_dim_obj and time_dim_obj.supported_granularities + else ["day", "week", "month", "quarter", "year"] + ) + default_grain = model.default_grain or (time_dim_obj.granularity if time_dim_obj else None) or "day" + if default_grain not in time_grain_options: + time_grain_options = [default_grain] + [g for g in time_grain_options if g != default_grain] + + # Default selected metric + selected_metric = metrics_config[0]["key"] if metrics_config else "" + + # Compute date range: use provided dates or query the table + date_range: list[str] = [] + if start_date and end_date: + date_range = [start_date, end_date] + elif time_dimension and model.table: + try: + from sidemantic.widget._widget import _quote_qualified_name + + dialect = layer.dialect or "duckdb" + table_ref = _quote_qualified_name(model.table, dialect=dialect) + time_col = _quote_qualified_name(time_dimension, dialect=dialect) + range_sql = f"SELECT MIN({time_col}) as min_date, MAX({time_col}) as max_date FROM {table_ref}" + range_result = layer.adapter.execute(range_sql).fetchone() + if range_result and range_result[0] is not None and range_result[1] is not None: + min_val, max_val = range_result[0], range_result[1] + for val in (min_val, max_val): + if isinstance(val, datetime): + date_range.append(val.isoformat(sep=" ")) + elif isinstance(val, date): + date_range.append(val.isoformat()) + else: + date_range.append(str(val)) + except Exception: + pass + # Apply partial overrides + if start_date and len(date_range) == 2: + date_range[0] = start_date + if end_date and len(date_range) == 2: + date_range[1] = end_date + + # Store state for widget_query (data is fetched lazily via widget_query) + _explorer_state = { + "model_name": model_name, + "time_dimension": time_dimension, + "metrics_config": metrics_config, + "dimensions_config": dimensions_config, + "date_range": date_range, + "filters": {}, + "brush_selection": [], + } + + _log("explore_metrics: returning config (data will be fetched via widget_query)") + + # Return config only - widget renders skeletons immediately, then calls widget_query for data + return { + "config": config, + "metrics_config": metrics_config, + "dimensions_config": dimensions_config, + "date_range": date_range, + "time_grain": default_grain, + "time_grain_options": time_grain_options, + "selected_metric": selected_metric, + "metric_series_data": "", + "metric_totals": {}, + "dimension_data": {}, + "status": "loading", + } + + +@mcp.tool( + structured_output=False, + meta={"ui": {"visibility": ["app"]}}, +) +def widget_query( + query_type: str = "metric", + metric_key: str = "", + selected_metric: str = "", + time_grain: str = "day", + dimension_key: str = "", + filters_json: str = "{}", + brush_selection_json: str = "[]", +) -> dict[str, Any]: + """Fetch a single metric or dimension for the explorer widget (app-only). + + query_type controls what to fetch: + - "metric": series + total for one metric (metric_key required) + - "dimension": leaderboard for one dimension (dimension_key + selected_metric required) + + Args: + query_type: "metric" or "dimension" + metric_key: Metric key to query (for query_type="metric") + selected_metric: Active metric key (for query_type="dimension" leaderboards) + time_grain: Time granularity for metric series + dimension_key: Dimension key to query (for query_type="dimension") + filters_json: JSON-encoded dimension filters + brush_selection_json: JSON-encoded brush selection + """ + global _explorer_state + + if _explorer_state is None: + return {"status": "error", "error": "Explorer not initialized. Use explore_metrics first."} + + layer = get_layer() + model_name = _explorer_state["model_name"] + time_dimension = _explorer_state["time_dimension"] + metrics_config = _explorer_state["metrics_config"] + dimensions_config = _explorer_state["dimensions_config"] + + filters = json.loads(filters_json) if filters_json else {} + brush_selection = json.loads(brush_selection_json) if brush_selection_json else [] + _explorer_state["filters"] = filters + _explorer_state["brush_selection"] = brush_selection + + state = { + "model_name": model_name, + "time_dimension": time_dimension, + "filters": filters, + "date_range": _explorer_state.get("date_range", []), + "brush_selection": brush_selection, + } + date_filters = _build_explorer_filters(state) + + if query_type == "metric" and metric_key: + # Find the metric config + mc = next((m for m in metrics_config if m["key"] == metric_key), None) + if not mc: + return {"status": "error", "error": f"Unknown metric: {metric_key}"} + + metric_ref = mc["ref"] + time_dim_ref_grain = f"{model_name}.{time_dimension}__{time_grain}" if time_dimension else None + result: dict[str, Any] = {"metric_key": metric_key} + + # Series + if time_dim_ref_grain: + try: + series_sql = layer.compile( + metrics=[metric_ref], + dimensions=[time_dim_ref_grain], + filters=date_filters or None, + order_by=[time_dim_ref_grain], + limit=500, + ) + series_table = _execute_arrow(layer, series_sql) + ts_col = _detect_time_series_column(series_table, grain=time_grain, time_dimension=time_dimension) + result["metric_series_data"] = _table_to_ipc_base64(series_table, decimal_mode="float") + result["time_series_column"] = ts_col + except Exception as e: + _log(f" metric series FAIL {metric_key}: {e}") + + # Total + try: + totals_sql = layer.compile(metrics=[metric_ref], dimensions=[], filters=date_filters or None) + totals_row = layer.adapter.execute(totals_sql).fetchone() + if totals_row: + value = _convert_to_json_compatible(totals_row[0]) + if isinstance(value, Decimal): + value = str(value) + result["metric_total"] = value + except Exception: + pass + + result["status"] = "ready" + return result + + if query_type == "dimension" and dimension_key: + dc = next((d for d in dimensions_config if d["key"] == dimension_key), None) + if not dc: + return {"status": "error", "error": f"Unknown dimension: {dimension_key}"} + + selected_metric_ref = f"{model_name}.{selected_metric}" if selected_metric else "" + dim_filters = _build_explorer_filters(state, exclude_dimension=dimension_key) + result = {"dimension_key": dimension_key} + + try: + dim_sql = layer.compile( + metrics=[selected_metric_ref], + dimensions=[dc["ref"]], + filters=dim_filters or None, + order_by=[f"{selected_metric_ref} DESC"], + limit=6, + ) + dim_table = _execute_arrow(layer, dim_sql) + result["dimension_data"] = _table_to_ipc_base64(dim_table, decimal_mode="string") + except Exception: + result["dimension_data"] = "" + + result["status"] = "ready" + return result + + return {"status": "error", "error": f"Invalid query_type: {query_type}"} + + +# --- 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( + "ui://sidemantic/explorer", + mime_type="text/html;profile=mcp-app", + meta={ + "ui": { + "csp": {"connectDomains": [], "resourceDomains": []}, + }, + "mcpui.dev/ui-preferred-frame-size": ["100%", "600px"], + }, +) +def explorer_widget_resource() -> str: + """Interactive metrics explorer widget for MCP Apps-compatible hosts.""" + from sidemantic.apps import _get_explorer_template + + return _get_explorer_template() @mcp.resource("semantic://catalog") diff --git a/tests/test_mcp_apps.py b/tests/test_mcp_apps.py index ca75c71..13586c1 100644 --- a/tests/test_mcp_apps.py +++ b/tests/test_mcp_apps.py @@ -4,7 +4,7 @@ pytest.importorskip("mcp") # Skip if mcp extra not installed -from sidemantic.apps import build_chart_html, create_chart_resource +from sidemantic.apps import _get_widget_template from sidemantic.mcp_server import create_chart, initialize_layer @@ -40,96 +40,35 @@ def demo_layer(tmp_path): yield layer -def test_build_chart_html(): - """Test that build_chart_html embeds the Vega spec.""" - spec = {"$schema": "https://vega.github.io/schema/vega-lite/v5.json", "mark": "bar"} - html = build_chart_html(spec) - - assert "{{VEGA_SPEC}}" not in html - assert '"$schema"' in html - assert '"mark"' in html - assert "vega-embed" in html - - -def test_build_chart_html_escapes_json(): - """Test that JSON with special chars is properly embedded.""" - spec = {"title": "Revenue <&> Costs", "description": 'Test\'s "spec"'} - html = build_chart_html(spec) - - # < in the JSON data should be escaped to \u003c - assert "\\u003c" in html - # The raw < from user input should not appear in the JSON block - assert "Revenue <&>" not in html - assert "Revenue \\u003c&>" in html - - -def test_build_chart_html_prevents_script_injection(): - """Test that in user input cannot break out of the JSON block.""" - spec = {"title": ''} - html = build_chart_html(spec) - - assert "