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..3600d97 --- /dev/null +++ b/sidemantic/apps/explorer.html @@ -0,0 +1,194 @@ + + + + + + + + + + +
+
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..cb9b6cd --- /dev/null +++ b/sidemantic/apps/web/explorer-app.ts @@ -0,0 +1,268 @@ +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) { + // Just set: don't query yet, widget will clear it in 400ms + return; + } + // Was cleared: do a full refresh + this.callRefresh("all"); + return; + } + + if (changed.has("filters")) { + const ad = this.state.active_dimension; + if (ad) { + this.callRefresh("dimensions"); + } else { + this.callRefresh("all"); + } + return; + } + + if (changed.has("brush_selection")) { + this.callRefresh("all"); + return; + } + + if (changed.has("selected_metric")) { + this.callRefresh("dimensions"); + return; + } + + if (changed.has("time_grain")) { + this.callRefresh("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; + } + + // Call the widget_query tool on the MCP server + private async callRefresh(queryType: string): Promise { + // Show loading state + this.state.status = "loading"; + this.fireChange("status"); + + try { + const result = await this.app.callServerTool({ + name: "widget_query", + arguments: { + query_type: queryType, + selected_metric: this.state.selected_metric || "", + time_grain: this.state.time_grain || "day", + filters_json: JSON.stringify(this.state.filters || {}), + brush_selection_json: JSON.stringify(this.state.brush_selection || []), + active_dimension: this.state.active_dimension || "", + }, + }); + + // Extract data from tool result + const data = this.extractData(result); + if (data) { + this.applyData(data); + } + } catch (err) { + this.state.status = "error"; + this.state.error = err instanceof Error ? err.message : String(err); + this.fireChange("status"); + this.fireChange("error"); + } + } +} + +// --- 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); + + // Create widget container + const widgetEl = document.createElement("div"); + container.appendChild(widgetEl); + + // Add expand button in inline mode + 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); + } + + // 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) +app.ontoolresult = (result: CallToolResult) => { + const data = model.extractData(result); + if (data) { + model.applyData(data); + renderExplorer(); + } 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..4d52a60 --- /dev/null +++ b/sidemantic/apps/web/explorer.html @@ -0,0 +1,35 @@ + + + + + + + + + +
+
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..9dd1a30 100644 --- a/sidemantic/mcp_server.py +++ b/sidemantic/mcp_server.py @@ -13,7 +13,6 @@ # Global semantic layer instance _layer: SemanticLayer | None = None -_apps_enabled: bool = False def initialize_layer( @@ -391,7 +390,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 +443,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 +488,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 +699,541 @@ 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 _detect_time_series_column(table, *, grain: 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 + for field in schema: + if pa.types.is_date(field.type) or pa.types.is_timestamp(field.type): + return field.name + suffix = f"__{grain}" + for field in schema: + if suffix in field.name: + return field.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 + + 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 + if not metrics: + metrics = [f"{model_name}.{m.name}" for m in (model.metrics or [])] + + # Resolve dimensions (categorical/boolean only for leaderboards) + if not dimensions: + dimensions = [ + f"{model_name}.{d.name}" for d in (model.dimensions or []) if d.type in ("categorical", "boolean") + ] + + # 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 from the underlying table + date_range: list[str] = [] + if 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] + # Stringify + 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 + + # Override date range if start_date/end_date provided + if start_date or end_date: + if start_date and end_date: + date_range = [start_date, end_date] + elif start_date and len(date_range) == 2: + date_range = [start_date, date_range[1]] + elif end_date and len(date_range) == 2: + date_range = [date_range[0], end_date] + + # Build date filters for initial queries + date_filters: list[str] = [] + if time_dimension and len(date_range) == 2: + start_literal = _format_date_literal(date_range[0]) + end_literal = _format_date_literal(date_range[1]) + date_filters.append( + f"{model_name}.{time_dimension} >= {start_literal} AND {model_name}.{time_dimension} <= {end_literal}" + ) + + # Metric series query + metric_refs = [m["ref"] for m in metrics_config] + time_dim_ref_grain = f"{model_name}.{time_dimension}__{default_grain}" if time_dimension else None + time_dim_ref = f"{model_name}.{time_dimension}" if time_dimension else None + + metric_series_data = "" + time_series_column: str | None = None + if time_dim_ref_grain and metric_refs: + try: + series_sql = layer.compile( + metrics=metric_refs, + dimensions=[time_dim_ref_grain], + filters=date_filters or None, + order_by=[time_dim_ref] if time_dim_ref else None, + limit=500, + ) + series_result = _execute_arrow(layer, series_sql) + time_series_column = _detect_time_series_column(series_result, grain=default_grain) + metric_series_data = _table_to_ipc_base64(series_result, decimal_mode="float") + except Exception: + pass + + config["time_series_column"] = time_series_column + + # Metric totals query + metric_totals: dict[str, Any] = {} + if metric_refs: + try: + totals_sql = layer.compile( + metrics=metric_refs, + dimensions=[], + filters=date_filters or None, + ) + totals_row = layer.adapter.execute(totals_sql).fetchone() + if totals_row: + for i, metric_ref in enumerate(metric_refs): + value = _convert_to_json_compatible(totals_row[i]) + if isinstance(value, Decimal): + value = str(value) + metric_totals[metric_ref.split(".")[-1]] = value + except Exception: + pass + + # Dimension leaderboards + selected_metric_ref = f"{model_name}.{selected_metric}" if selected_metric else "" + dimension_data: dict[str, str] = {} + if selected_metric_ref and dimensions_config: + for dim_config in dimensions_config: + dim_key = dim_config["key"] + dim_ref = dim_config["ref"] + try: + dim_sql = layer.compile( + metrics=[selected_metric_ref], + dimensions=[dim_ref], + filters=date_filters or None, + order_by=[f"{selected_metric_ref} DESC"], + limit=6, + ) + dim_result = _execute_arrow(layer, dim_sql) + dimension_data[dim_key] = _table_to_ipc_base64(dim_result, decimal_mode="string") + except Exception: + dimension_data[dim_key] = "" + + # Store state for 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": [], + } + + 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_series_data, + "metric_totals": metric_totals, + "dimension_data": dimension_data, + "status": "ready", + } + + +@mcp.tool( + structured_output=False, + meta={"ui": {"visibility": ["app"]}}, +) +def widget_query( + query_type: str = "all", + selected_metric: str = "", + time_grain: str = "day", + filters_json: str = "{}", + brush_selection_json: str = "[]", + active_dimension: str = "", +) -> dict[str, Any]: + """Refresh explorer widget data (app-only, not visible to LLM). + + Called by the explorer widget to fetch updated metric series, totals, + and/or dimension leaderboard data after user interactions. + + Args: + query_type: What to refresh: "metrics", "dimensions", or "all" + selected_metric: Active metric key for dimension leaderboards + time_grain: Time granularity for metric series + filters_json: JSON-encoded dimension filters ({dim_key: [values]}) + brush_selection_json: JSON-encoded brush selection ([start, end] or []) + active_dimension: If set, only refresh this single dimension leaderboard + + Returns: + Updated data matching the requested query_type. + """ + global _explorer_state + + if _explorer_state is None: + raise RuntimeError("Explorer not initialized. Call explore_metrics first.") + + try: + 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 [] + + # Update stored state + _explorer_state["filters"] = filters + _explorer_state["brush_selection"] = brush_selection + + # Build state dict for filter builder + state = { + "model_name": model_name, + "time_dimension": time_dimension, + "filters": filters, + "date_range": _explorer_state.get("date_range", []), + "brush_selection": brush_selection, + } + + result: dict[str, Any] = {"status": "ready"} + + # --- Metrics --- + if query_type in ("metrics", "all"): + metric_refs = [m["ref"] for m in metrics_config] + time_dim_ref_grain = f"{model_name}.{time_dimension}__{time_grain}" if time_dimension else None + time_dim_ref = f"{model_name}.{time_dimension}" if time_dimension else None + date_filters = _build_explorer_filters(state) + + # Metric series + metric_series_data = "" + time_series_column: str | None = None + if time_dim_ref_grain and metric_refs: + series_sql = layer.compile( + metrics=metric_refs, + dimensions=[time_dim_ref_grain], + filters=date_filters or None, + order_by=[time_dim_ref] if time_dim_ref else None, + limit=500, + ) + series_table = _execute_arrow(layer, series_sql) + time_series_column = _detect_time_series_column(series_table, grain=time_grain) + metric_series_data = _table_to_ipc_base64(series_table, decimal_mode="float") + + result["metric_series_data"] = metric_series_data + result["config"] = { + "model_name": model_name, + "time_dimension": time_dimension, + "time_dimension_ref": f"{model_name}.{time_dimension}" if time_dimension else None, + "time_series_column": time_series_column, + } + + # Metric totals + metric_totals: dict[str, Any] = {} + if metric_refs: + totals_sql = layer.compile( + metrics=metric_refs, + dimensions=[], + filters=date_filters or None, + ) + totals_row = layer.adapter.execute(totals_sql).fetchone() + if totals_row: + for i, metric_ref in enumerate(metric_refs): + value = _convert_to_json_compatible(totals_row[i]) + if isinstance(value, Decimal): + value = str(value) + metric_totals[metric_ref.split(".")[-1]] = value + result["metric_totals"] = metric_totals + + # --- Dimensions --- + if query_type in ("dimensions", "all"): + selected_metric_ref = f"{model_name}.{selected_metric}" if selected_metric else "" + dimension_data: dict[str, str] = {} + + dims_to_query = dimensions_config + if active_dimension: + dims_to_query = [d for d in dimensions_config if d["key"] == active_dimension] + + if selected_metric_ref and dims_to_query: + for dim_config in dims_to_query: + dim_key = dim_config["key"] + dim_ref = dim_config["ref"] + dim_filters = _build_explorer_filters(state, exclude_dimension=dim_key) + try: + dim_sql = layer.compile( + metrics=[selected_metric_ref], + dimensions=[dim_ref], + filters=dim_filters or None, + order_by=[f"{selected_metric_ref} DESC"], + limit=6, + ) + dim_table = _execute_arrow(layer, dim_sql) + dimension_data[dim_key] = _table_to_ipc_base64(dim_table, decimal_mode="string") + except Exception: + dimension_data[dim_key] = "" + + result["dimension_data"] = dimension_data + + return result + + except Exception as e: + return {"status": "error", "error": str(e)} + + +# --- 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 "