From dc3ba0ec61fb0d74ce0fccd2d936a1870ad1cac6 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Mon, 30 Mar 2026 18:35:38 -0700 Subject: [PATCH 1/9] MCP Apps interactive Vega-Lite charts with CSP-safe rendering - Vite-built widget bundles ext-apps SDK + Vega-Lite + vega-interpreter into a single HTML file (no CDN deps, no eval, CSP-safe) - Widget receives tool result via MCP Apps protocol (ontoolresult) - Supports fullscreen toggle when host advertises it - create_chart returns vega_spec only (no PNG, widget renders interactively) - Register ui://sidemantic/chart resource with proper MCP Apps metadata - Remove _apps_enabled flag (MCP Apps works via protocol, not a runtime flag) - Remove vendored mcp-ui-server (widget is self-contained) - Remove --apps CLI flag dependency on mcp-ui-server import - structured_output=False on all tools (fixes Claude Desktop tool visibility) - Replace | None params with falsy defaults (removes anyOf from schemas) --- sidemantic/apps/__init__.py | 67 ++---- sidemantic/apps/chart.html | 318 +++++++++++++++++++++++++++++ sidemantic/apps/web/.gitignore | 2 + sidemantic/apps/web/chart-app.ts | 116 +++++++++++ sidemantic/apps/web/chart.html | 42 ++++ sidemantic/apps/web/package.json | 20 ++ sidemantic/apps/web/vite.config.ts | 21 ++ sidemantic/cli.py | 7 - sidemantic/mcp_server.py | 49 +++-- tests/test_mcp_apps.py | 127 +++--------- tests/test_mcp_server.py | 4 - 11 files changed, 601 insertions(+), 172 deletions(-) create mode 100644 sidemantic/apps/chart.html create mode 100644 sidemantic/apps/web/.gitignore create mode 100644 sidemantic/apps/web/chart-app.ts create mode 100644 sidemantic/apps/web/chart.html create mode 100644 sidemantic/apps/web/package.json create mode 100644 sidemantic/apps/web/vite.config.ts diff --git a/sidemantic/apps/__init__.py b/sidemantic/apps/__init__.py index 736df9b..476c3f1 100644 --- a/sidemantic/apps/__init__.py +++ b/sidemantic/apps/__init__.py @@ -1,60 +1,25 @@ """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 +_WIDGET_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 _WIDGET_HTML + if _WIDGET_HTML is None: + built = Path(__file__).parent / "chart.html" + if built.exists(): + _WIDGET_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 _WIDGET_HTML diff --git a/sidemantic/apps/chart.html b/sidemantic/apps/chart.html new file mode 100644 index 0000000..3e7c903 --- /dev/null +++ b/sidemantic/apps/chart.html @@ -0,0 +1,318 @@ + + + + + + + + + +
+ +
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..dab9259 --- /dev/null +++ b/sidemantic/apps/web/chart-app.ts @@ -0,0 +1,116 @@ +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")!; + +function renderChart(vegaSpec: Record) { + // Clear chart content but preserve the fullscreen button + Array.from(container.children).forEach(c => { + if (c.id !== "fullscreen-btn") c.remove(); + }); + const spec = { ...vegaSpec }; + spec.width = "container"; + spec.height = 500; + spec.background = "transparent"; + + const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches; + + embed(container, spec as any, { + actions: false, + theme: prefersDark ? "dark" : undefined, + // CSP-safe: use AST interpreter instead of eval + ast: true, + expr: expressionInterpreter, + }) + .then((result) => { + const ro = new ResizeObserver(() => result.view.resize().run()); + ro.observe(container); + // Tell host the actual content height after render + requestAnimationFrame(() => { + const h = Math.max(500, document.documentElement.scrollHeight); + app.sendSizeChanged({ height: h }); + }); + }) + .catch((err) => { + container.innerHTML = `
Chart render error: ${err.message}
`; + }); +} + +function extractVegaSpec(result: CallToolResult): Record | null { + // Try structuredContent first + const sc = result.structuredContent as Record | undefined; + if (sc?.vega_spec) return sc.vega_spec as Record; + // Then parse from text content + 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; +} + +// Create app and register handlers before connecting +const fullscreenBtn = document.getElementById("fullscreen-btn")!; +let currentDisplayMode: "inline" | "fullscreen" = "inline"; + +const app = new App( + { name: "sidemantic-chart", version: "1.0.0" }, + { availableDisplayModes: ["inline", "fullscreen"] }, + { autoResize: false }, +); + +app.ontoolresult = (result: CallToolResult) => { + const spec = extractVegaSpec(result); + if (spec) { + renderChart(spec); + } else { + container.innerHTML = '
No chart data in tool result
'; + } +}; + +app.ontoolinput = () => { + container.innerHTML = '
Running query...
'; +}; + +app.onhostcontextchanged = (ctx: McpUiHostContext) => { + if (ctx.theme) applyDocumentTheme(ctx.theme); + if (ctx.availableDisplayModes) { + const canFullscreen = ctx.availableDisplayModes.includes("fullscreen"); + fullscreenBtn.classList.toggle("available", canFullscreen); + } + if (ctx.displayMode === "inline" || ctx.displayMode === "fullscreen") { + currentDisplayMode = ctx.displayMode; + document.body.classList.toggle("fullscreen", currentDisplayMode === "fullscreen"); + fullscreenBtn.style.display = currentDisplayMode === "fullscreen" ? "none" : ""; + } +}; + +fullscreenBtn.addEventListener("click", async () => { + const newMode = currentDisplayMode === "fullscreen" ? "inline" : "fullscreen"; + const ctx = app.getHostContext(); + if (ctx?.availableDisplayModes?.includes(newMode)) { + const result = await app.requestDisplayMode({ mode: newMode }); + currentDisplayMode = result.mode as "inline" | "fullscreen"; + document.body.classList.toggle("fullscreen", currentDisplayMode === "fullscreen"); + fullscreenBtn.style.display = currentDisplayMode === "fullscreen" ? "none" : ""; + } +}); + +app.connect().then(() => { + const ctx = app.getHostContext(); + if (ctx?.theme) applyDocumentTheme(ctx.theme); + if (ctx?.availableDisplayModes?.includes("fullscreen")) { + fullscreenBtn.classList.add("available"); + } + // Keep fullscreen button, replace only the chart content area + 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..d600fff --- /dev/null +++ b/sidemantic/apps/web/chart.html @@ -0,0 +1,42 @@ + + + + + + + + +
+ +
Loading...
+
+ + + diff --git a/sidemantic/apps/web/package.json b/sidemantic/apps/web/package.json new file mode 100644 index 0000000..e3745bd --- /dev/null +++ b/sidemantic/apps/web/package.json @@ -0,0 +1,20 @@ +{ + "name": "sidemantic-chart-widget", + "private": true, + "type": "module", + "scripts": { + "build": "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..e8b27df --- /dev/null +++ b/sidemantic/apps/web/vite.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "vite"; +import { viteSingleFile } from "vite-plugin-singlefile"; + +export default defineConfig({ + plugins: [viteSingleFile()], + build: { + rollupOptions: { input: "chart.html" }, + outDir: "../", + emptyOutDir: false, + }, + define: { + // Replace new Function calls with a safe fallback at build time + // This prevents CSP violations in MCP Apps sandboxes + }, + resolve: { + alias: { + // Use CSP-safe expression interpreter + "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..c464c84 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,24 @@ def get_semantic_graph() -> dict[str, Any]: return result -# --- MCP Resource: Catalog Metadata --- +# --- 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("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 " +`,K_e="chart-wrapper";function Q_e(e){return typeof e=="function"}function WR(e,t,n,i){const r=`${t}
`,o=`
${n}`,s=window.open("");s.document.write(r+e+o),s.document.title=`${df[i]} JSON Source`}function ewe(e,t,n){if(e.$schema){const i=F9(e.$schema);n&&n!==i.library&&t.warn(`The given visualization spec is written in ${df[i.library]}, but mode argument sets ${df[n]??n}.`);const r=i.library;return q9(um[r],`^${i.version.slice(1)}`)||t.warn(`The input spec uses ${df[r]} ${i.version}, but the current version of ${df[r]} is v${um[r]}.`),r}return"mark"in e||"encoding"in e||"layer"in e||"hconcat"in e||"vconcat"in e||"facet"in e||"repeat"in e?"vega-lite":"marks"in e||"signals"in e||"scales"in e||"axes"in e?"vega":n??"vega"}function H9(e){return!!(e&&"load"in e)}function HR(e){return H9(e)?e:xr.loader(e)}function twe(e){var n;const t=((n=e.usermeta)==null?void 0:n.embedOptions)??{};return te(t.defaultStyle)&&(t.defaultStyle=!1),t}async function nwe(e,t,n={}){let i,r;te(t)?(r=HR(n.loader),i=JSON.parse(await r.load(t))):i=t;const o=twe(i),s=o.loader;(!r||s)&&(r=HR(n.loader??s));const a=await GR(o,r),u=await GR(n,r),l={...W9(u,a),config:Hl(u.config??{},a.config??{})};return await rwe(e,i,l,r)}async function GR(e,t){const n=te(e.config)?JSON.parse(await t.load(e.config)):e.config??{},i=te(e.patch)?JSON.parse(await t.load(e.patch)):e.patch;return{...e,...i?{patch:i}:{},...n?{config:n}:{}}}function iwe(e){const t=e.getRootNode?e.getRootNode():document;return t instanceof ShadowRoot?{root:t,rootContainer:t}:{root:document,rootContainer:document.head??document.body}}async function rwe(e,t,n={},i){const r=n.theme?Hl(s_e[n.theme],n.config??{}):n.config,o=tu(n.actions)?n.actions:W9({},V_e,n.actions??{}),s={...Y_e,...n.i18n},a=n.renderer??"svg",u=n.logger??bm(xr.Warn);n.logLevel!==void 0&&u.level(n.logLevel);const l=n.downloadFileName??"visualization",c=typeof e=="string"?document.querySelector(e):e;if(!c)throw new Error(`${e} does not exist`);if(n.defaultStyle!==!1){const w="vega-embed-style",{root:S,rootContainer:_}=iwe(c);if(!S.getElementById(w)){const E=document.createElement("style");E.id=w,E.innerHTML=n.defaultStyle===void 0||n.defaultStyle===!0?G_e.toString():n.defaultStyle,_.appendChild(E)}}const f=ewe(t,u,n.mode);let d=X_e[f](t,u,r);if(f==="vega-lite"&&d.$schema){const w=F9(d.$schema);q9(um.vega,`^${w.version.slice(1)}`)||u.warn(`The compiled spec uses Vega ${w.version}, but current version is v${um.vega}.`)}c.classList.add("vega-embed"),o&&c.classList.add("has-actions"),c.innerHTML="";let h=c;if(o){const w=document.createElement("div");w.classList.add(K_e),c.appendChild(w),h=w}const p=n.patch;if(p&&(d=p instanceof Function?p(d):_y(d,p,!0,!1).newDocument),n.formatLocale&&xr.formatLocale(n.formatLocale),n.timeFormatLocale&&xr.timeFormatLocale(n.timeFormatLocale),n.expressionFunctions)for(const w in n.expressionFunctions){const S=n.expressionFunctions[w];"fn"in S?xr.expressionFunction(w,S.fn,S.visitor):S instanceof Function&&xr.expressionFunction(w,S)}const{ast:g}=n,m=xr.parse(d,f==="vega-lite"?{}:r,{ast:g}),y=new(n.viewClass||xr.View)(m,{loader:i,logger:u,renderer:a,...g?{expr:xr.expressionInterpreter??n.expr??rL}:{}});if(y.addSignalListener("autosize",(w,S)=>{const{type:_}=S;_=="fit-x"?(h.classList.add("fit-x"),h.classList.remove("fit-y")):_=="fit-y"?(h.classList.remove("fit-x"),h.classList.add("fit-y")):_=="fit"?h.classList.add("fit-x","fit-y"):h.classList.remove("fit-x","fit-y")}),n.tooltip!==!1){const{loader:w,tooltip:S}=n,_=w&&!H9(w)?w==null?void 0:w.baseURL:void 0,E=Q_e(S)?S:new m_e({baseURL:_,...S===!0?{}:S}).call;y.tooltip(E)}let{hover:b}=n;if(b===void 0&&(b=f==="vega"),b){const{hoverSet:w,updateSet:S}=typeof b=="boolean"?{}:b;y.hover(w,S)}n&&(n.width!=null&&y.width(n.width),n.height!=null&&y.height(n.height),n.padding!=null&&y.padding(n.padding)),await y.initialize(h,n.bind).runAsync();let v;if(o!==!1){let w=c;if(n.defaultStyle!==!1||n.forceActionsMenu){const _=document.createElement("details");_.title=s.CLICK_TO_VIEW_ACTIONS,c.append(_),w=_;const E=document.createElement("summary");E.innerHTML=J_e,_.append(E),v=k=>{_.contains(k.target)||_.removeAttribute("open")},document.addEventListener("click",v)}const S=document.createElement("div");if(w.append(S),S.classList.add("vega-actions"),o===!0||o.export!==!1){for(const _ of["svg","png"])if(o===!0||o.export===!0||o.export[_]){const E=s[`${_.toUpperCase()}_ACTION`],k=document.createElement("a"),A=ee(n.scaleFactor)?n.scaleFactor[_]:n.scaleFactor;k.text=E,k.href="#",k.target="_blank",k.download=`${l}.${_}`,k.addEventListener("mousedown",async function(T){T.preventDefault();const R=await y.toImageURL(_,A);this.href=R}),S.append(k)}}if(o===!0||o.source!==!1){const _=document.createElement("a");_.text=s.SOURCE_ACTION,_.href="#",_.addEventListener("click",function(E){WR($y(t),n.sourceHeader??"",n.sourceFooter??"",f),E.preventDefault()}),S.append(_)}if(f==="vega-lite"&&(o===!0||o.compiled!==!1)){const _=document.createElement("a");_.text=s.COMPILED_ACTION,_.href="#",_.addEventListener("click",function(E){WR($y(d),n.sourceHeader??"",n.sourceFooter??"","vega"),E.preventDefault()}),S.append(_)}if(o===!0||o.editor!==!1){const _=n.editorUrl??"https://vega.github.io/editor/",E=document.createElement("a");E.text=s.EDITOR_ACTION,E.href="#",E.addEventListener("click",function(k){H_e(window,_,{config:r,mode:p?"vega":f,renderer:a,spec:$y(p?d:t)}),k.preventDefault()}),S.append(E)}}function x(){v&&document.removeEventListener("click",v),y.finalize()}return{view:y,spec:t,vgSpec:d,finalize:x,embedOptions:n}}const us=document.getElementById("chart");let lm="inline",sd=null;function Ik(e){var i;us.innerHTML="";const t={...e};t.width="container",t.height=lm==="fullscreen"?"container":500,t.background="transparent";const n=(i=window.matchMedia)==null?void 0:i.call(window,"(prefers-color-scheme: dark)").matches;nwe(us,t,{actions:!1,theme:n?"dark":void 0,ast:!0,expr:rL}).then(r=>{new ResizeObserver(()=>r.view.resize().run()).observe(us),lm==="inline"&&owe(),requestAnimationFrame(()=>{const s=Math.max(500,document.documentElement.scrollHeight);gs.sendSizeChanged({height:s})})}).catch(r=>{us.innerHTML=`
Chart render error: ${r.message}
`})}function owe(){const e=document.createElement("div");e.className="expand-bar",e.innerHTML="↗ Expand",e.addEventListener("click",swe),us.appendChild(e)}async function swe(){try{lm=(await gs.requestDisplayMode({mode:"fullscreen"})).mode,sd&&Ik(sd)}catch{}}function awe(e){const t=e.structuredContent;if(t!=null&&t.vega_spec)return t.vega_spec;if(e.content){for(const n of e.content)if(n.type==="text")try{const i=JSON.parse(n.text);if(i.vega_spec)return i.vega_spec}catch{}}return null}const gs=new YW({name:"sidemantic-chart",version:"1.0.0"},{},{autoResize:!1});gs.ontoolresult=e=>{const t=awe(e);t?(sd=t,Ik(t)):us.innerHTML='
No chart data in tool result
'};gs.ontoolinput=()=>{us.innerHTML='
Running query...
'};gs.onhostcontextchanged=e=>{e.theme&&XF(e.theme),(e.displayMode==="inline"||e.displayMode==="fullscreen")&&(lm=e.displayMode,sd&&Ik(sd))};gs.connect().then(()=>{const e=gs.getHostContext();e!=null&&e.theme&&XF(e.theme);const t=us.querySelector(".loading");t&&(t.textContent="Waiting for chart data..."),gs.sendSizeChanged({height:500})});
-
Loading...
diff --git a/sidemantic/apps/web/chart-app.ts b/sidemantic/apps/web/chart-app.ts index dab9259..613c4d8 100644 --- a/sidemantic/apps/web/chart-app.ts +++ b/sidemantic/apps/web/chart-app.ts @@ -4,15 +4,14 @@ 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; function renderChart(vegaSpec: Record) { - // Clear chart content but preserve the fullscreen button - Array.from(container.children).forEach(c => { - if (c.id !== "fullscreen-btn") c.remove(); - }); + container.innerHTML = ""; const spec = { ...vegaSpec }; spec.width = "container"; - spec.height = 500; + spec.height = currentDisplayMode === "fullscreen" ? "container" : 500; spec.background = "transparent"; const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches; @@ -20,14 +19,18 @@ function renderChart(vegaSpec: Record) { embed(container, spec as any, { actions: false, theme: prefersDark ? "dark" : undefined, - // CSP-safe: use AST interpreter instead of eval ast: true, expr: expressionInterpreter, }) .then((result) => { const ro = new ResizeObserver(() => result.view.resize().run()); ro.observe(container); - // Tell host the actual content height after render + + // Add expand bar in inline mode + if (currentDisplayMode === "inline") { + addExpandBar(); + } + requestAnimationFrame(() => { const h = Math.max(500, document.documentElement.scrollHeight); app.sendSizeChanged({ height: h }); @@ -38,11 +41,27 @@ function renderChart(vegaSpec: Record) { }); } +function addExpandBar() { + const bar = document.createElement("div"); + bar.className = "expand-bar"; + bar.innerHTML = '↗ Expand'; + bar.addEventListener("click", goFullscreen); + container.appendChild(bar); +} + +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 { - // Try structuredContent first const sc = result.structuredContent as Record | undefined; if (sc?.vega_spec) return sc.vega_spec as Record; - // Then parse from text content if (result.content) { for (const item of result.content) { if (item.type === "text") { @@ -56,19 +75,16 @@ function extractVegaSpec(result: CallToolResult): Record | null return null; } -// Create app and register handlers before connecting -const fullscreenBtn = document.getElementById("fullscreen-btn")!; -let currentDisplayMode: "inline" | "fullscreen" = "inline"; - const app = new App( { name: "sidemantic-chart", version: "1.0.0" }, - { availableDisplayModes: ["inline", "fullscreen"] }, + {}, { autoResize: false }, ); app.ontoolresult = (result: CallToolResult) => { const spec = extractVegaSpec(result); if (spec) { + lastSpec = spec; renderChart(spec); } else { container.innerHTML = '
No chart data in tool result
'; @@ -81,35 +97,15 @@ app.ontoolinput = () => { app.onhostcontextchanged = (ctx: McpUiHostContext) => { if (ctx.theme) applyDocumentTheme(ctx.theme); - if (ctx.availableDisplayModes) { - const canFullscreen = ctx.availableDisplayModes.includes("fullscreen"); - fullscreenBtn.classList.toggle("available", canFullscreen); - } if (ctx.displayMode === "inline" || ctx.displayMode === "fullscreen") { currentDisplayMode = ctx.displayMode; - document.body.classList.toggle("fullscreen", currentDisplayMode === "fullscreen"); - fullscreenBtn.style.display = currentDisplayMode === "fullscreen" ? "none" : ""; + if (lastSpec) renderChart(lastSpec); } }; -fullscreenBtn.addEventListener("click", async () => { - const newMode = currentDisplayMode === "fullscreen" ? "inline" : "fullscreen"; - const ctx = app.getHostContext(); - if (ctx?.availableDisplayModes?.includes(newMode)) { - const result = await app.requestDisplayMode({ mode: newMode }); - currentDisplayMode = result.mode as "inline" | "fullscreen"; - document.body.classList.toggle("fullscreen", currentDisplayMode === "fullscreen"); - fullscreenBtn.style.display = currentDisplayMode === "fullscreen" ? "none" : ""; - } -}); - app.connect().then(() => { const ctx = app.getHostContext(); if (ctx?.theme) applyDocumentTheme(ctx.theme); - if (ctx?.availableDisplayModes?.includes("fullscreen")) { - fullscreenBtn.classList.add("available"); - } - // Keep fullscreen button, replace only the chart content area 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 index d600fff..85aa50f 100644 --- a/sidemantic/apps/web/chart.html +++ b/sidemantic/apps/web/chart.html @@ -4,37 +4,23 @@
-
Loading...
From 606d450029935534617543d520081e6992529ef4 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Mon, 30 Mar 2026 19:28:23 -0700 Subject: [PATCH 3/9] Polish chart widget: expand icon, fullscreen sizing, padding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move expand button to top-right inline with title ("Expand ↗") - Fix fullscreen height to fill viewport minus 150px for prompt box - Add padding in fullscreen mode (16px top, 24px sides) - Add 5px height buffer to prevent inline scroll - Dark mode support for expand button --- sidemantic/apps/chart.html | 63 ++++++++++++++++++-------------- sidemantic/apps/web/chart-app.ts | 31 ++++++++++------ sidemantic/apps/web/chart.html | 19 +++++++--- 3 files changed, 69 insertions(+), 44 deletions(-) diff --git a/sidemantic/apps/chart.html b/sidemantic/apps/chart.html index 36e0d33..e0603c4 100644 --- a/sidemantic/apps/chart.html +++ b/sidemantic/apps/chart.html @@ -6,21 +6,30 @@ - +`,K_e="chart-wrapper";function Q_e(e){return typeof e=="function"}function WR(e,t,n,i){const r=`${t}
`,o=`
${n}`,s=window.open("");s.document.write(r+e+o),s.document.title=`${df[i]} JSON Source`}function ewe(e,t,n){if(e.$schema){const i=F9(e.$schema);n&&n!==i.library&&t.warn(`The given visualization spec is written in ${df[i.library]}, but mode argument sets ${df[n]??n}.`);const r=i.library;return q9(um[r],`^${i.version.slice(1)}`)||t.warn(`The input spec uses ${df[r]} ${i.version}, but the current version of ${df[r]} is v${um[r]}.`),r}return"mark"in e||"encoding"in e||"layer"in e||"hconcat"in e||"vconcat"in e||"facet"in e||"repeat"in e?"vega-lite":"marks"in e||"signals"in e||"scales"in e||"axes"in e?"vega":n??"vega"}function H9(e){return!!(e&&"load"in e)}function HR(e){return H9(e)?e:xr.loader(e)}function twe(e){var n;const t=((n=e.usermeta)==null?void 0:n.embedOptions)??{};return te(t.defaultStyle)&&(t.defaultStyle=!1),t}async function nwe(e,t,n={}){let i,r;te(t)?(r=HR(n.loader),i=JSON.parse(await r.load(t))):i=t;const o=twe(i),s=o.loader;(!r||s)&&(r=HR(n.loader??s));const a=await GR(o,r),u=await GR(n,r),l={...W9(u,a),config:Hl(u.config??{},a.config??{})};return await rwe(e,i,l,r)}async function GR(e,t){const n=te(e.config)?JSON.parse(await t.load(e.config)):e.config??{},i=te(e.patch)?JSON.parse(await t.load(e.patch)):e.patch;return{...e,...i?{patch:i}:{},...n?{config:n}:{}}}function iwe(e){const t=e.getRootNode?e.getRootNode():document;return t instanceof ShadowRoot?{root:t,rootContainer:t}:{root:document,rootContainer:document.head??document.body}}async function rwe(e,t,n={},i){const r=n.theme?Hl(s_e[n.theme],n.config??{}):n.config,o=tu(n.actions)?n.actions:W9({},V_e,n.actions??{}),s={...Y_e,...n.i18n},a=n.renderer??"svg",u=n.logger??ym(xr.Warn);n.logLevel!==void 0&&u.level(n.logLevel);const l=n.downloadFileName??"visualization",c=typeof e=="string"?document.querySelector(e):e;if(!c)throw new Error(`${e} does not exist`);if(n.defaultStyle!==!1){const w="vega-embed-style",{root:S,rootContainer:_}=iwe(c);if(!S.getElementById(w)){const E=document.createElement("style");E.id=w,E.innerHTML=n.defaultStyle===void 0||n.defaultStyle===!0?G_e.toString():n.defaultStyle,_.appendChild(E)}}const f=ewe(t,u,n.mode);let d=X_e[f](t,u,r);if(f==="vega-lite"&&d.$schema){const w=F9(d.$schema);q9(um.vega,`^${w.version.slice(1)}`)||u.warn(`The compiled spec uses Vega ${w.version}, but current version is v${um.vega}.`)}c.classList.add("vega-embed"),o&&c.classList.add("has-actions"),c.innerHTML="";let h=c;if(o){const w=document.createElement("div");w.classList.add(K_e),c.appendChild(w),h=w}const p=n.patch;if(p&&(d=p instanceof Function?p(d):xy(d,p,!0,!1).newDocument),n.formatLocale&&xr.formatLocale(n.formatLocale),n.timeFormatLocale&&xr.timeFormatLocale(n.timeFormatLocale),n.expressionFunctions)for(const w in n.expressionFunctions){const S=n.expressionFunctions[w];"fn"in S?xr.expressionFunction(w,S.fn,S.visitor):S instanceof Function&&xr.expressionFunction(w,S)}const{ast:g}=n,m=xr.parse(d,f==="vega-lite"?{}:r,{ast:g}),y=new(n.viewClass||xr.View)(m,{loader:i,logger:u,renderer:a,...g?{expr:xr.expressionInterpreter??n.expr??rL}:{}});if(y.addSignalListener("autosize",(w,S)=>{const{type:_}=S;_=="fit-x"?(h.classList.add("fit-x"),h.classList.remove("fit-y")):_=="fit-y"?(h.classList.remove("fit-x"),h.classList.add("fit-y")):_=="fit"?h.classList.add("fit-x","fit-y"):h.classList.remove("fit-x","fit-y")}),n.tooltip!==!1){const{loader:w,tooltip:S}=n,_=w&&!H9(w)?w==null?void 0:w.baseURL:void 0,E=Q_e(S)?S:new m_e({baseURL:_,...S===!0?{}:S}).call;y.tooltip(E)}let{hover:b}=n;if(b===void 0&&(b=f==="vega"),b){const{hoverSet:w,updateSet:S}=typeof b=="boolean"?{}:b;y.hover(w,S)}n&&(n.width!=null&&y.width(n.width),n.height!=null&&y.height(n.height),n.padding!=null&&y.padding(n.padding)),await y.initialize(h,n.bind).runAsync();let v;if(o!==!1){let w=c;if(n.defaultStyle!==!1||n.forceActionsMenu){const _=document.createElement("details");_.title=s.CLICK_TO_VIEW_ACTIONS,c.append(_),w=_;const E=document.createElement("summary");E.innerHTML=J_e,_.append(E),v=k=>{_.contains(k.target)||_.removeAttribute("open")},document.addEventListener("click",v)}const S=document.createElement("div");if(w.append(S),S.classList.add("vega-actions"),o===!0||o.export!==!1){for(const _ of["svg","png"])if(o===!0||o.export===!0||o.export[_]){const E=s[`${_.toUpperCase()}_ACTION`],k=document.createElement("a"),A=ee(n.scaleFactor)?n.scaleFactor[_]:n.scaleFactor;k.text=E,k.href="#",k.target="_blank",k.download=`${l}.${_}`,k.addEventListener("mousedown",async function(T){T.preventDefault();const R=await y.toImageURL(_,A);this.href=R}),S.append(k)}}if(o===!0||o.source!==!1){const _=document.createElement("a");_.text=s.SOURCE_ACTION,_.href="#",_.addEventListener("click",function(E){WR(ky(t),n.sourceHeader??"",n.sourceFooter??"",f),E.preventDefault()}),S.append(_)}if(f==="vega-lite"&&(o===!0||o.compiled!==!1)){const _=document.createElement("a");_.text=s.COMPILED_ACTION,_.href="#",_.addEventListener("click",function(E){WR(ky(d),n.sourceHeader??"",n.sourceFooter??"","vega"),E.preventDefault()}),S.append(_)}if(o===!0||o.editor!==!1){const _=n.editorUrl??"https://vega.github.io/editor/",E=document.createElement("a");E.text=s.EDITOR_ACTION,E.href="#",E.addEventListener("click",function(k){H_e(window,_,{config:r,mode:p?"vega":f,renderer:a,spec:ky(p?d:t)}),k.preventDefault()}),S.append(E)}}function x(){v&&document.removeEventListener("click",v),y.finalize()}return{view:y,spec:t,vgSpec:d,finalize:x,embedOptions:n}}const ls=document.getElementById("chart");let zk="inline",sd=null;function Ik(e){var r;ls.innerHTML="";const t=zk==="fullscreen";document.documentElement.classList.toggle("fullscreen",t);const n={...e};n.width="container",n.height=t?"container":500,n.background="transparent";const i=(r=window.matchMedia)==null?void 0:r.call(window,"(prefers-color-scheme: dark)").matches;nwe(ls,n,{actions:!1,theme:i?"dark":void 0,ast:!0,expr:rL}).then(o=>{new ResizeObserver(()=>o.view.resize().run()).observe(ls),t||owe(),requestAnimationFrame(()=>{if(t)Ao.sendSizeChanged({height:window.innerHeight-150});else{const a=Math.max(505,document.documentElement.scrollHeight+5);Ao.sendSizeChanged({height:a})}})}).catch(o=>{ls.innerHTML=`
Chart render error: ${o.message}
`})}function owe(){const e=document.createElement("div");e.className="expand-btn",e.title="Expand to fullscreen",e.textContent="Expand ↗",e.addEventListener("click",swe),ls.appendChild(e)}async function swe(){try{zk=(await Ao.requestDisplayMode({mode:"fullscreen"})).mode,sd&&Ik(sd)}catch{}}function awe(e){const t=e.structuredContent;if(t!=null&&t.vega_spec)return t.vega_spec;if(e.content){for(const n of e.content)if(n.type==="text")try{const i=JSON.parse(n.text);if(i.vega_spec)return i.vega_spec}catch{}}return null}const Ao=new YW({name:"sidemantic-chart",version:"1.0.0"},{},{autoResize:!1});Ao.ontoolresult=e=>{const t=awe(e);t?(sd=t,Ik(t)):ls.innerHTML='
No chart data in tool result
'};Ao.ontoolinput=()=>{ls.innerHTML='
Running query...
'};Ao.onhostcontextchanged=e=>{e.theme&&XF(e.theme),(e.displayMode==="inline"||e.displayMode==="fullscreen")&&(zk=e.displayMode,sd&&Ik(sd))};Ao.connect().then(()=>{const e=Ao.getHostContext();e!=null&&e.theme&&XF(e.theme);const t=ls.querySelector(".loading");t&&(t.textContent="Waiting for chart data..."),Ao.sendSizeChanged({height:500})});
diff --git a/sidemantic/apps/web/chart-app.ts b/sidemantic/apps/web/chart-app.ts index 613c4d8..6bd1833 100644 --- a/sidemantic/apps/web/chart-app.ts +++ b/sidemantic/apps/web/chart-app.ts @@ -9,9 +9,12 @@ let lastSpec: Record | null = null; function renderChart(vegaSpec: Record) { container.innerHTML = ""; + const isFullscreen = currentDisplayMode === "fullscreen"; + document.documentElement.classList.toggle("fullscreen", isFullscreen); + const spec = { ...vegaSpec }; spec.width = "container"; - spec.height = currentDisplayMode === "fullscreen" ? "container" : 500; + spec.height = isFullscreen ? "container" : 500; spec.background = "transparent"; const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches; @@ -26,14 +29,17 @@ function renderChart(vegaSpec: Record) { const ro = new ResizeObserver(() => result.view.resize().run()); ro.observe(container); - // Add expand bar in inline mode - if (currentDisplayMode === "inline") { - addExpandBar(); + if (!isFullscreen) { + addExpandButton(); } requestAnimationFrame(() => { - const h = Math.max(500, document.documentElement.scrollHeight); - app.sendSizeChanged({ height: h }); + if (isFullscreen) { + app.sendSizeChanged({ height: window.innerHeight - 150 }); + } else { + const h = Math.max(505, document.documentElement.scrollHeight + 5); + app.sendSizeChanged({ height: h }); + } }); }) .catch((err) => { @@ -41,12 +47,13 @@ function renderChart(vegaSpec: Record) { }); } -function addExpandBar() { - const bar = document.createElement("div"); - bar.className = "expand-bar"; - bar.innerHTML = '↗ Expand'; - bar.addEventListener("click", goFullscreen); - container.appendChild(bar); +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() { diff --git a/sidemantic/apps/web/chart.html b/sidemantic/apps/web/chart.html index 85aa50f..3e1bc11 100644 --- a/sidemantic/apps/web/chart.html +++ b/sidemantic/apps/web/chart.html @@ -6,17 +6,26 @@ From f894c5edc7dcad66f46cedc60fd577d02a705453 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Mon, 30 Mar 2026 19:48:59 -0700 Subject: [PATCH 4/9] Dispose old chart observer and view before re-rendering --- sidemantic/apps/chart.html | 50 ++++++++++++++++---------------- sidemantic/apps/web/chart-app.ts | 7 +++++ 2 files changed, 32 insertions(+), 25 deletions(-) diff --git a/sidemantic/apps/chart.html b/sidemantic/apps/chart.html index e0603c4..1567e69 100644 --- a/sidemantic/apps/chart.html +++ b/sidemantic/apps/chart.html @@ -27,9 +27,9 @@ .expand-btn:hover { color: #ddd; background: rgba(255,255,255,0.1); } } - +`,ewe="chart-wrapper";function twe(e){return typeof e=="function"}function GR(e,t,n,i){const r=`${t}
`,o=`
${n}`,s=window.open("");s.document.write(r+e+o),s.document.title=`${df[i]} JSON Source`}function nwe(e,t,n){if(e.$schema){const i=O9(e.$schema);n&&n!==i.library&&t.warn(`The given visualization spec is written in ${df[i.library]}, but mode argument sets ${df[n]??n}.`);const r=i.library;return H9(cm[r],`^${i.version.slice(1)}`)||t.warn(`The input spec uses ${df[r]} ${i.version}, but the current version of ${df[r]} is v${cm[r]}.`),r}return"mark"in e||"encoding"in e||"layer"in e||"hconcat"in e||"vconcat"in e||"facet"in e||"repeat"in e?"vega-lite":"marks"in e||"signals"in e||"scales"in e||"axes"in e?"vega":n??"vega"}function Z9(e){return!!(e&&"load"in e)}function ZR(e){return Z9(e)?e:xr.loader(e)}function iwe(e){var n;const t=((n=e.usermeta)==null?void 0:n.embedOptions)??{};return te(t.defaultStyle)&&(t.defaultStyle=!1),t}async function rwe(e,t,n={}){let i,r;te(t)?(r=ZR(n.loader),i=JSON.parse(await r.load(t))):i=t;const o=iwe(i),s=o.loader;(!r||s)&&(r=ZR(n.loader??s));const a=await VR(o,r),u=await VR(n,r),l={...G9(u,a),config:Hl(u.config??{},a.config??{})};return await swe(e,i,l,r)}async function VR(e,t){const n=te(e.config)?JSON.parse(await t.load(e.config)):e.config??{},i=te(e.patch)?JSON.parse(await t.load(e.patch)):e.patch;return{...e,...i?{patch:i}:{},...n?{config:n}:{}}}function owe(e){const t=e.getRootNode?e.getRootNode():document;return t instanceof ShadowRoot?{root:t,rootContainer:t}:{root:document,rootContainer:document.head??document.body}}async function swe(e,t,n={},i){const r=n.theme?Hl(u_e[n.theme],n.config??{}):n.config,o=tu(n.actions)?n.actions:G9({},X_e,n.actions??{}),s={...J_e,...n.i18n},a=n.renderer??"svg",u=n.logger??vm(xr.Warn);n.logLevel!==void 0&&u.level(n.logLevel);const l=n.downloadFileName??"visualization",c=typeof e=="string"?document.querySelector(e):e;if(!c)throw new Error(`${e} does not exist`);if(n.defaultStyle!==!1){const w="vega-embed-style",{root:S,rootContainer:_}=owe(c);if(!S.getElementById(w)){const E=document.createElement("style");E.id=w,E.innerHTML=n.defaultStyle===void 0||n.defaultStyle===!0?V_e.toString():n.defaultStyle,_.appendChild(E)}}const f=nwe(t,u,n.mode);let d=K_e[f](t,u,r);if(f==="vega-lite"&&d.$schema){const w=O9(d.$schema);H9(cm.vega,`^${w.version.slice(1)}`)||u.warn(`The compiled spec uses Vega ${w.version}, but current version is v${cm.vega}.`)}c.classList.add("vega-embed"),o&&c.classList.add("has-actions"),c.innerHTML="";let h=c;if(o){const w=document.createElement("div");w.classList.add(ewe),c.appendChild(w),h=w}const p=n.patch;if(p&&(d=p instanceof Function?p(d):wy(d,p,!0,!1).newDocument),n.formatLocale&&xr.formatLocale(n.formatLocale),n.timeFormatLocale&&xr.timeFormatLocale(n.timeFormatLocale),n.expressionFunctions)for(const w in n.expressionFunctions){const S=n.expressionFunctions[w];"fn"in S?xr.expressionFunction(w,S.fn,S.visitor):S instanceof Function&&xr.expressionFunction(w,S)}const{ast:g}=n,m=xr.parse(d,f==="vega-lite"?{}:r,{ast:g}),y=new(n.viewClass||xr.View)(m,{loader:i,logger:u,renderer:a,...g?{expr:xr.expressionInterpreter??n.expr??sL}:{}});if(y.addSignalListener("autosize",(w,S)=>{const{type:_}=S;_=="fit-x"?(h.classList.add("fit-x"),h.classList.remove("fit-y")):_=="fit-y"?(h.classList.remove("fit-x"),h.classList.add("fit-y")):_=="fit"?h.classList.add("fit-x","fit-y"):h.classList.remove("fit-x","fit-y")}),n.tooltip!==!1){const{loader:w,tooltip:S}=n,_=w&&!Z9(w)?w==null?void 0:w.baseURL:void 0,E=twe(S)?S:new b_e({baseURL:_,...S===!0?{}:S}).call;y.tooltip(E)}let{hover:b}=n;if(b===void 0&&(b=f==="vega"),b){const{hoverSet:w,updateSet:S}=typeof b=="boolean"?{}:b;y.hover(w,S)}n&&(n.width!=null&&y.width(n.width),n.height!=null&&y.height(n.height),n.padding!=null&&y.padding(n.padding)),await y.initialize(h,n.bind).runAsync();let v;if(o!==!1){let w=c;if(n.defaultStyle!==!1||n.forceActionsMenu){const _=document.createElement("details");_.title=s.CLICK_TO_VIEW_ACTIONS,c.append(_),w=_;const E=document.createElement("summary");E.innerHTML=Q_e,_.append(E),v=k=>{_.contains(k.target)||_.removeAttribute("open")},document.addEventListener("click",v)}const S=document.createElement("div");if(w.append(S),S.classList.add("vega-actions"),o===!0||o.export!==!1){for(const _ of["svg","png"])if(o===!0||o.export===!0||o.export[_]){const E=s[`${_.toUpperCase()}_ACTION`],k=document.createElement("a"),A=ee(n.scaleFactor)?n.scaleFactor[_]:n.scaleFactor;k.text=E,k.href="#",k.target="_blank",k.download=`${l}.${_}`,k.addEventListener("mousedown",async function(T){T.preventDefault();const R=await y.toImageURL(_,A);this.href=R}),S.append(k)}}if(o===!0||o.source!==!1){const _=document.createElement("a");_.text=s.SOURCE_ACTION,_.href="#",_.addEventListener("click",function(E){GR(Ay(t),n.sourceHeader??"",n.sourceFooter??"",f),E.preventDefault()}),S.append(_)}if(f==="vega-lite"&&(o===!0||o.compiled!==!1)){const _=document.createElement("a");_.text=s.COMPILED_ACTION,_.href="#",_.addEventListener("click",function(E){GR(Ay(d),n.sourceHeader??"",n.sourceFooter??"","vega"),E.preventDefault()}),S.append(_)}if(o===!0||o.editor!==!1){const _=n.editorUrl??"https://vega.github.io/editor/",E=document.createElement("a");E.text=s.EDITOR_ACTION,E.href="#",E.addEventListener("click",function(k){Z_e(window,_,{config:r,mode:p?"vega":f,renderer:a,spec:Ay(p?d:t)}),k.preventDefault()}),S.append(E)}}function x(){v&&document.removeEventListener("click",v),y.finalize()}return{view:y,spec:t,vgSpec:d,finalize:x,embedOptions:n}}const ls=document.getElementById("chart");let Pk="inline",sd=null,Kh=null,Qh=null;function Lk(e){var r;Kh&&(Kh.disconnect(),Kh=null),Qh&&(Qh.finalize(),Qh=null),ls.innerHTML="";const t=Pk==="fullscreen";document.documentElement.classList.toggle("fullscreen",t);const n={...e};n.width="container",n.height=t?"container":500,n.background="transparent";const i=(r=window.matchMedia)==null?void 0:r.call(window,"(prefers-color-scheme: dark)").matches;rwe(ls,n,{actions:!1,theme:i?"dark":void 0,ast:!0,expr:sL}).then(o=>{Qh=o;const s=new ResizeObserver(()=>o.view.resize().run());s.observe(ls),Kh=s,t||awe(),requestAnimationFrame(()=>{if(t)Ao.sendSizeChanged({height:window.innerHeight-150});else{const a=Math.max(505,document.documentElement.scrollHeight+5);Ao.sendSizeChanged({height:a})}})}).catch(o=>{ls.innerHTML=`
Chart render error: ${o.message}
`})}function awe(){const e=document.createElement("div");e.className="expand-btn",e.title="Expand to fullscreen",e.textContent="Expand ↗",e.addEventListener("click",uwe),ls.appendChild(e)}async function uwe(){try{Pk=(await Ao.requestDisplayMode({mode:"fullscreen"})).mode,sd&&Lk(sd)}catch{}}function lwe(e){const t=e.structuredContent;if(t!=null&&t.vega_spec)return t.vega_spec;if(e.content){for(const n of e.content)if(n.type==="text")try{const i=JSON.parse(n.text);if(i.vega_spec)return i.vega_spec}catch{}}return null}const Ao=new JW({name:"sidemantic-chart",version:"1.0.0"},{},{autoResize:!1});Ao.ontoolresult=e=>{const t=lwe(e);t?(sd=t,Lk(t)):ls.innerHTML='
No chart data in tool result
'};Ao.ontoolinput=()=>{ls.innerHTML='
Running query...
'};Ao.onhostcontextchanged=e=>{e.theme&&KF(e.theme),(e.displayMode==="inline"||e.displayMode==="fullscreen")&&(Pk=e.displayMode,sd&&Lk(sd))};Ao.connect().then(()=>{const e=Ao.getHostContext();e!=null&&e.theme&&KF(e.theme);const t=ls.querySelector(".loading");t&&(t.textContent="Waiting for chart data..."),Ao.sendSizeChanged({height:500})});
diff --git a/sidemantic/apps/web/chart-app.ts b/sidemantic/apps/web/chart-app.ts index 6bd1833..5ef86c9 100644 --- a/sidemantic/apps/web/chart-app.ts +++ b/sidemantic/apps/web/chart-app.ts @@ -6,8 +6,13 @@ 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; function renderChart(vegaSpec: Record) { + if (activeObserver) { activeObserver.disconnect(); activeObserver = null; } + if (activeView) { activeView.finalize(); activeView = null; } + container.innerHTML = ""; const isFullscreen = currentDisplayMode === "fullscreen"; document.documentElement.classList.toggle("fullscreen", isFullscreen); @@ -26,8 +31,10 @@ function renderChart(vegaSpec: Record) { expr: expressionInterpreter, }) .then((result) => { + activeView = result; const ro = new ResizeObserver(() => result.view.resize().run()); ro.observe(container); + activeObserver = ro; if (!isFullscreen) { addExpandButton(); From 9d4786aca103a97461315a9600b6187fe2c521a6 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Mon, 30 Mar 2026 19:54:59 -0700 Subject: [PATCH 5/9] Guard against stale renders and clean up chart on non-chart content - Generation counter ensures out-of-order embed promises discard stale results - cleanupChart() called before error/loading content to prevent leaked observers --- sidemantic/apps/chart.html | 50 ++++++++++++++++---------------- sidemantic/apps/web/chart-app.ts | 14 ++++++++- 2 files changed, 38 insertions(+), 26 deletions(-) diff --git a/sidemantic/apps/chart.html b/sidemantic/apps/chart.html index 1567e69..6b7749e 100644 --- a/sidemantic/apps/chart.html +++ b/sidemantic/apps/chart.html @@ -27,9 +27,9 @@ .expand-btn:hover { color: #ddd; background: rgba(255,255,255,0.1); } } - +`,nwe="chart-wrapper";function iwe(e){return typeof e=="function"}function VR(e,t,n,i){const r=`${t}
`,o=`
${n}`,s=window.open("");s.document.write(r+e+o),s.document.title=`${df[i]} JSON Source`}function rwe(e,t,n){if(e.$schema){const i=z9(e.$schema);n&&n!==i.library&&t.warn(`The given visualization spec is written in ${df[i.library]}, but mode argument sets ${df[n]??n}.`);const r=i.library;return Z9(fm[r],`^${i.version.slice(1)}`)||t.warn(`The input spec uses ${df[r]} ${i.version}, but the current version of ${df[r]} is v${fm[r]}.`),r}return"mark"in e||"encoding"in e||"layer"in e||"hconcat"in e||"vconcat"in e||"facet"in e||"repeat"in e?"vega-lite":"marks"in e||"signals"in e||"scales"in e||"axes"in e?"vega":n??"vega"}function Y9(e){return!!(e&&"load"in e)}function YR(e){return Y9(e)?e:xr.loader(e)}function owe(e){var n;const t=((n=e.usermeta)==null?void 0:n.embedOptions)??{};return te(t.defaultStyle)&&(t.defaultStyle=!1),t}async function swe(e,t,n={}){let i,r;te(t)?(r=YR(n.loader),i=JSON.parse(await r.load(t))):i=t;const o=owe(i),s=o.loader;(!r||s)&&(r=YR(n.loader??s));const a=await XR(o,r),u=await XR(n,r),l={...V9(u,a),config:Hl(u.config??{},a.config??{})};return await uwe(e,i,l,r)}async function XR(e,t){const n=te(e.config)?JSON.parse(await t.load(e.config)):e.config??{},i=te(e.patch)?JSON.parse(await t.load(e.patch)):e.patch;return{...e,...i?{patch:i}:{},...n?{config:n}:{}}}function awe(e){const t=e.getRootNode?e.getRootNode():document;return t instanceof ShadowRoot?{root:t,rootContainer:t}:{root:document,rootContainer:document.head??document.body}}async function uwe(e,t,n={},i){const r=n.theme?Hl(c_e[n.theme],n.config??{}):n.config,o=tu(n.actions)?n.actions:V9({},K_e,n.actions??{}),s={...Q_e,...n.i18n},a=n.renderer??"svg",u=n.logger??xm(xr.Warn);n.logLevel!==void 0&&u.level(n.logLevel);const l=n.downloadFileName??"visualization",c=typeof e=="string"?document.querySelector(e):e;if(!c)throw new Error(`${e} does not exist`);if(n.defaultStyle!==!1){const w="vega-embed-style",{root:S,rootContainer:_}=awe(c);if(!S.getElementById(w)){const E=document.createElement("style");E.id=w,E.innerHTML=n.defaultStyle===void 0||n.defaultStyle===!0?X_e.toString():n.defaultStyle,_.appendChild(E)}}const f=rwe(t,u,n.mode);let d=ewe[f](t,u,r);if(f==="vega-lite"&&d.$schema){const w=z9(d.$schema);Z9(fm.vega,`^${w.version.slice(1)}`)||u.warn(`The compiled spec uses Vega ${w.version}, but current version is v${fm.vega}.`)}c.classList.add("vega-embed"),o&&c.classList.add("has-actions"),c.innerHTML="";let h=c;if(o){const w=document.createElement("div");w.classList.add(nwe),c.appendChild(w),h=w}const p=n.patch;if(p&&(d=p instanceof Function?p(d):Sy(d,p,!0,!1).newDocument),n.formatLocale&&xr.formatLocale(n.formatLocale),n.timeFormatLocale&&xr.timeFormatLocale(n.timeFormatLocale),n.expressionFunctions)for(const w in n.expressionFunctions){const S=n.expressionFunctions[w];"fn"in S?xr.expressionFunction(w,S.fn,S.visitor):S instanceof Function&&xr.expressionFunction(w,S)}const{ast:g}=n,m=xr.parse(d,f==="vega-lite"?{}:r,{ast:g}),y=new(n.viewClass||xr.View)(m,{loader:i,logger:u,renderer:a,...g?{expr:xr.expressionInterpreter??n.expr??uL}:{}});if(y.addSignalListener("autosize",(w,S)=>{const{type:_}=S;_=="fit-x"?(h.classList.add("fit-x"),h.classList.remove("fit-y")):_=="fit-y"?(h.classList.remove("fit-x"),h.classList.add("fit-y")):_=="fit"?h.classList.add("fit-x","fit-y"):h.classList.remove("fit-x","fit-y")}),n.tooltip!==!1){const{loader:w,tooltip:S}=n,_=w&&!Y9(w)?w==null?void 0:w.baseURL:void 0,E=iwe(S)?S:new x_e({baseURL:_,...S===!0?{}:S}).call;y.tooltip(E)}let{hover:b}=n;if(b===void 0&&(b=f==="vega"),b){const{hoverSet:w,updateSet:S}=typeof b=="boolean"?{}:b;y.hover(w,S)}n&&(n.width!=null&&y.width(n.width),n.height!=null&&y.height(n.height),n.padding!=null&&y.padding(n.padding)),await y.initialize(h,n.bind).runAsync();let v;if(o!==!1){let w=c;if(n.defaultStyle!==!1||n.forceActionsMenu){const _=document.createElement("details");_.title=s.CLICK_TO_VIEW_ACTIONS,c.append(_),w=_;const E=document.createElement("summary");E.innerHTML=twe,_.append(E),v=k=>{_.contains(k.target)||_.removeAttribute("open")},document.addEventListener("click",v)}const S=document.createElement("div");if(w.append(S),S.classList.add("vega-actions"),o===!0||o.export!==!1){for(const _ of["svg","png"])if(o===!0||o.export===!0||o.export[_]){const E=s[`${_.toUpperCase()}_ACTION`],k=document.createElement("a"),A=ee(n.scaleFactor)?n.scaleFactor[_]:n.scaleFactor;k.text=E,k.href="#",k.target="_blank",k.download=`${l}.${_}`,k.addEventListener("mousedown",async function(T){T.preventDefault();const R=await y.toImageURL(_,A);this.href=R}),S.append(k)}}if(o===!0||o.source!==!1){const _=document.createElement("a");_.text=s.SOURCE_ACTION,_.href="#",_.addEventListener("click",function(E){VR(Cy(t),n.sourceHeader??"",n.sourceFooter??"",f),E.preventDefault()}),S.append(_)}if(f==="vega-lite"&&(o===!0||o.compiled!==!1)){const _=document.createElement("a");_.text=s.COMPILED_ACTION,_.href="#",_.addEventListener("click",function(E){VR(Cy(d),n.sourceHeader??"",n.sourceFooter??"","vega"),E.preventDefault()}),S.append(_)}if(o===!0||o.editor!==!1){const _=n.editorUrl??"https://vega.github.io/editor/",E=document.createElement("a");E.text=s.EDITOR_ACTION,E.href="#",E.addEventListener("click",function(k){Y_e(window,_,{config:r,mode:p?"vega":f,renderer:a,spec:Cy(p?d:t)}),k.preventDefault()}),S.append(E)}}function x(){v&&document.removeEventListener("click",v),y.finalize()}return{view:y,spec:t,vgSpec:d,finalize:x,embedOptions:n}}const ls=document.getElementById("chart");let Lk="inline",sd=null,kp=null,$p=null,Kh=0;function Bk(){kp&&(kp.disconnect(),kp=null),$p&&($p.finalize(),$p=null)}function Uk(e){var o;Bk();const t=++Kh;ls.innerHTML="";const n=Lk==="fullscreen";document.documentElement.classList.toggle("fullscreen",n);const i={...e};i.width="container",i.height=n?"container":500,i.background="transparent";const r=(o=window.matchMedia)==null?void 0:o.call(window,"(prefers-color-scheme: dark)").matches;swe(ls,i,{actions:!1,theme:r?"dark":void 0,ast:!0,expr:uL}).then(s=>{if(t!==Kh){s.finalize();return}$p=s;const a=new ResizeObserver(()=>s.view.resize().run());a.observe(ls),kp=a,n||lwe(),requestAnimationFrame(()=>{if(t===Kh)if(n)Ao.sendSizeChanged({height:window.innerHeight-150});else{const u=Math.max(505,document.documentElement.scrollHeight+5);Ao.sendSizeChanged({height:u})}})}).catch(s=>{t===Kh&&(ls.innerHTML=`
Chart render error: ${s.message}
`)})}function lwe(){const e=document.createElement("div");e.className="expand-btn",e.title="Expand to fullscreen",e.textContent="Expand ↗",e.addEventListener("click",cwe),ls.appendChild(e)}async function cwe(){try{Lk=(await Ao.requestDisplayMode({mode:"fullscreen"})).mode,sd&&Uk(sd)}catch{}}function fwe(e){const t=e.structuredContent;if(t!=null&&t.vega_spec)return t.vega_spec;if(e.content){for(const n of e.content)if(n.type==="text")try{const i=JSON.parse(n.text);if(i.vega_spec)return i.vega_spec}catch{}}return null}const Ao=new QW({name:"sidemantic-chart",version:"1.0.0"},{},{autoResize:!1});Ao.ontoolresult=e=>{const t=fwe(e);t?(sd=t,Uk(t)):(Bk(),ls.innerHTML='
No chart data in tool result
')};Ao.ontoolinput=()=>{Bk(),ls.innerHTML='
Running query...
'};Ao.onhostcontextchanged=e=>{e.theme&&eN(e.theme),(e.displayMode==="inline"||e.displayMode==="fullscreen")&&(Lk=e.displayMode,sd&&Uk(sd))};Ao.connect().then(()=>{const e=Ao.getHostContext();e!=null&&e.theme&&eN(e.theme);const t=ls.querySelector(".loading");t&&(t.textContent="Waiting for chart data..."),Ao.sendSizeChanged({height:500})});
diff --git a/sidemantic/apps/web/chart-app.ts b/sidemantic/apps/web/chart-app.ts index 5ef86c9..99e102d 100644 --- a/sidemantic/apps/web/chart-app.ts +++ b/sidemantic/apps/web/chart-app.ts @@ -8,10 +8,16 @@ 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 renderChart(vegaSpec: Record) { +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"; @@ -31,6 +37,8 @@ function renderChart(vegaSpec: Record) { expr: expressionInterpreter, }) .then((result) => { + if (generation !== renderGeneration) { result.finalize(); return; } + activeView = result; const ro = new ResizeObserver(() => result.view.resize().run()); ro.observe(container); @@ -41,6 +49,7 @@ function renderChart(vegaSpec: Record) { } requestAnimationFrame(() => { + if (generation !== renderGeneration) return; if (isFullscreen) { app.sendSizeChanged({ height: window.innerHeight - 150 }); } else { @@ -50,6 +59,7 @@ function renderChart(vegaSpec: Record) { }); }) .catch((err) => { + if (generation !== renderGeneration) return; container.innerHTML = `
Chart render error: ${err.message}
`; }); } @@ -101,11 +111,13 @@ app.ontoolresult = (result: CallToolResult) => { lastSpec = spec; renderChart(spec); } else { + cleanupChart(); container.innerHTML = '
No chart data in tool result
'; } }; app.ontoolinput = () => { + cleanupChart(); container.innerHTML = '
Running query...
'; }; From 4d8fe76d1d023c5edbea4497f64df42ac68fb3cc Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Mon, 30 Mar 2026 20:00:41 -0700 Subject: [PATCH 6/9] Invalidate in-flight embeds on tool input, clear stale spec on error --- sidemantic/apps/chart.html | 42 ++++++++++++++++---------------- sidemantic/apps/web/chart-app.ts | 2 ++ 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/sidemantic/apps/chart.html b/sidemantic/apps/chart.html index 6b7749e..7591444 100644 --- a/sidemantic/apps/chart.html +++ b/sidemantic/apps/chart.html @@ -27,9 +27,9 @@ .expand-btn:hover { color: #ddd; background: rgba(255,255,255,0.1); } } - +`,nwe="chart-wrapper";function iwe(e){return typeof e=="function"}function VR(e,t,n,i){const r=`${t}
`,o=`
${n}`,s=window.open("");s.document.write(r+e+o),s.document.title=`${hf[i]} JSON Source`}function rwe(e,t,n){if(e.$schema){const i=z9(e.$schema);n&&n!==i.library&&t.warn(`The given visualization spec is written in ${hf[i.library]}, but mode argument sets ${hf[n]??n}.`);const r=i.library;return Z9(fm[r],`^${i.version.slice(1)}`)||t.warn(`The input spec uses ${hf[r]} ${i.version}, but the current version of ${hf[r]} is v${fm[r]}.`),r}return"mark"in e||"encoding"in e||"layer"in e||"hconcat"in e||"vconcat"in e||"facet"in e||"repeat"in e?"vega-lite":"marks"in e||"signals"in e||"scales"in e||"axes"in e?"vega":n??"vega"}function Y9(e){return!!(e&&"load"in e)}function YR(e){return Y9(e)?e:xr.loader(e)}function owe(e){var n;const t=((n=e.usermeta)==null?void 0:n.embedOptions)??{};return te(t.defaultStyle)&&(t.defaultStyle=!1),t}async function swe(e,t,n={}){let i,r;te(t)?(r=YR(n.loader),i=JSON.parse(await r.load(t))):i=t;const o=owe(i),s=o.loader;(!r||s)&&(r=YR(n.loader??s));const a=await XR(o,r),u=await XR(n,r),l={...V9(u,a),config:Gl(u.config??{},a.config??{})};return await uwe(e,i,l,r)}async function XR(e,t){const n=te(e.config)?JSON.parse(await t.load(e.config)):e.config??{},i=te(e.patch)?JSON.parse(await t.load(e.patch)):e.patch;return{...e,...i?{patch:i}:{},...n?{config:n}:{}}}function awe(e){const t=e.getRootNode?e.getRootNode():document;return t instanceof ShadowRoot?{root:t,rootContainer:t}:{root:document,rootContainer:document.head??document.body}}async function uwe(e,t,n={},i){const r=n.theme?Gl(c_e[n.theme],n.config??{}):n.config,o=tu(n.actions)?n.actions:V9({},K_e,n.actions??{}),s={...Q_e,...n.i18n},a=n.renderer??"svg",u=n.logger??xm(xr.Warn);n.logLevel!==void 0&&u.level(n.logLevel);const l=n.downloadFileName??"visualization",c=typeof e=="string"?document.querySelector(e):e;if(!c)throw new Error(`${e} does not exist`);if(n.defaultStyle!==!1){const w="vega-embed-style",{root:S,rootContainer:_}=awe(c);if(!S.getElementById(w)){const E=document.createElement("style");E.id=w,E.innerHTML=n.defaultStyle===void 0||n.defaultStyle===!0?X_e.toString():n.defaultStyle,_.appendChild(E)}}const f=rwe(t,u,n.mode);let d=ewe[f](t,u,r);if(f==="vega-lite"&&d.$schema){const w=z9(d.$schema);Z9(fm.vega,`^${w.version.slice(1)}`)||u.warn(`The compiled spec uses Vega ${w.version}, but current version is v${fm.vega}.`)}c.classList.add("vega-embed"),o&&c.classList.add("has-actions"),c.innerHTML="";let h=c;if(o){const w=document.createElement("div");w.classList.add(nwe),c.appendChild(w),h=w}const p=n.patch;if(p&&(d=p instanceof Function?p(d):Sy(d,p,!0,!1).newDocument),n.formatLocale&&xr.formatLocale(n.formatLocale),n.timeFormatLocale&&xr.timeFormatLocale(n.timeFormatLocale),n.expressionFunctions)for(const w in n.expressionFunctions){const S=n.expressionFunctions[w];"fn"in S?xr.expressionFunction(w,S.fn,S.visitor):S instanceof Function&&xr.expressionFunction(w,S)}const{ast:g}=n,m=xr.parse(d,f==="vega-lite"?{}:r,{ast:g}),y=new(n.viewClass||xr.View)(m,{loader:i,logger:u,renderer:a,...g?{expr:xr.expressionInterpreter??n.expr??uL}:{}});if(y.addSignalListener("autosize",(w,S)=>{const{type:_}=S;_=="fit-x"?(h.classList.add("fit-x"),h.classList.remove("fit-y")):_=="fit-y"?(h.classList.remove("fit-x"),h.classList.add("fit-y")):_=="fit"?h.classList.add("fit-x","fit-y"):h.classList.remove("fit-x","fit-y")}),n.tooltip!==!1){const{loader:w,tooltip:S}=n,_=w&&!Y9(w)?w==null?void 0:w.baseURL:void 0,E=iwe(S)?S:new x_e({baseURL:_,...S===!0?{}:S}).call;y.tooltip(E)}let{hover:b}=n;if(b===void 0&&(b=f==="vega"),b){const{hoverSet:w,updateSet:S}=typeof b=="boolean"?{}:b;y.hover(w,S)}n&&(n.width!=null&&y.width(n.width),n.height!=null&&y.height(n.height),n.padding!=null&&y.padding(n.padding)),await y.initialize(h,n.bind).runAsync();let v;if(o!==!1){let w=c;if(n.defaultStyle!==!1||n.forceActionsMenu){const _=document.createElement("details");_.title=s.CLICK_TO_VIEW_ACTIONS,c.append(_),w=_;const E=document.createElement("summary");E.innerHTML=twe,_.append(E),v=k=>{_.contains(k.target)||_.removeAttribute("open")},document.addEventListener("click",v)}const S=document.createElement("div");if(w.append(S),S.classList.add("vega-actions"),o===!0||o.export!==!1){for(const _ of["svg","png"])if(o===!0||o.export===!0||o.export[_]){const E=s[`${_.toUpperCase()}_ACTION`],k=document.createElement("a"),A=ee(n.scaleFactor)?n.scaleFactor[_]:n.scaleFactor;k.text=E,k.href="#",k.target="_blank",k.download=`${l}.${_}`,k.addEventListener("mousedown",async function(T){T.preventDefault();const R=await y.toImageURL(_,A);this.href=R}),S.append(k)}}if(o===!0||o.source!==!1){const _=document.createElement("a");_.text=s.SOURCE_ACTION,_.href="#",_.addEventListener("click",function(E){VR(Cy(t),n.sourceHeader??"",n.sourceFooter??"",f),E.preventDefault()}),S.append(_)}if(f==="vega-lite"&&(o===!0||o.compiled!==!1)){const _=document.createElement("a");_.text=s.COMPILED_ACTION,_.href="#",_.addEventListener("click",function(E){VR(Cy(d),n.sourceHeader??"",n.sourceFooter??"","vega"),E.preventDefault()}),S.append(_)}if(o===!0||o.editor!==!1){const _=n.editorUrl??"https://vega.github.io/editor/",E=document.createElement("a");E.text=s.EDITOR_ACTION,E.href="#",E.addEventListener("click",function(k){Y_e(window,_,{config:r,mode:p?"vega":f,renderer:a,spec:Cy(p?d:t)}),k.preventDefault()}),S.append(E)}}function x(){v&&document.removeEventListener("click",v),y.finalize()}return{view:y,spec:t,vgSpec:d,finalize:x,embedOptions:n}}const ls=document.getElementById("chart");let Lk="inline",jl=null,kp=null,$p=null,pf=0;function Bk(){kp&&(kp.disconnect(),kp=null),$p&&($p.finalize(),$p=null)}function Uk(e){var o;Bk();const t=++pf;ls.innerHTML="";const n=Lk==="fullscreen";document.documentElement.classList.toggle("fullscreen",n);const i={...e};i.width="container",i.height=n?"container":500,i.background="transparent";const r=(o=window.matchMedia)==null?void 0:o.call(window,"(prefers-color-scheme: dark)").matches;swe(ls,i,{actions:!1,theme:r?"dark":void 0,ast:!0,expr:uL}).then(s=>{if(t!==pf){s.finalize();return}$p=s;const a=new ResizeObserver(()=>s.view.resize().run());a.observe(ls),kp=a,n||lwe(),requestAnimationFrame(()=>{if(t===pf)if(n)Ao.sendSizeChanged({height:window.innerHeight-150});else{const u=Math.max(505,document.documentElement.scrollHeight+5);Ao.sendSizeChanged({height:u})}})}).catch(s=>{t===pf&&(ls.innerHTML=`
Chart render error: ${s.message}
`)})}function lwe(){const e=document.createElement("div");e.className="expand-btn",e.title="Expand to fullscreen",e.textContent="Expand ↗",e.addEventListener("click",cwe),ls.appendChild(e)}async function cwe(){try{Lk=(await Ao.requestDisplayMode({mode:"fullscreen"})).mode,jl&&Uk(jl)}catch{}}function fwe(e){const t=e.structuredContent;if(t!=null&&t.vega_spec)return t.vega_spec;if(e.content){for(const n of e.content)if(n.type==="text")try{const i=JSON.parse(n.text);if(i.vega_spec)return i.vega_spec}catch{}}return null}const Ao=new QW({name:"sidemantic-chart",version:"1.0.0"},{},{autoResize:!1});Ao.ontoolresult=e=>{const t=fwe(e);t?(jl=t,Uk(t)):(Bk(),jl=null,ls.innerHTML='
No chart data in tool result
')};Ao.ontoolinput=()=>{Bk(),++pf,ls.innerHTML='
Running query...
'};Ao.onhostcontextchanged=e=>{e.theme&&eN(e.theme),(e.displayMode==="inline"||e.displayMode==="fullscreen")&&(Lk=e.displayMode,jl&&Uk(jl))};Ao.connect().then(()=>{const e=Ao.getHostContext();e!=null&&e.theme&&eN(e.theme);const t=ls.querySelector(".loading");t&&(t.textContent="Waiting for chart data..."),Ao.sendSizeChanged({height:500})});
diff --git a/sidemantic/apps/web/chart-app.ts b/sidemantic/apps/web/chart-app.ts index 99e102d..18d9d0b 100644 --- a/sidemantic/apps/web/chart-app.ts +++ b/sidemantic/apps/web/chart-app.ts @@ -112,12 +112,14 @@ app.ontoolresult = (result: CallToolResult) => { renderChart(spec); } else { cleanupChart(); + lastSpec = null; container.innerHTML = '
No chart data in tool result
'; } }; app.ontoolinput = () => { cleanupChart(); + ++renderGeneration; container.innerHTML = '
Running query...
'; }; From aa70f56d983937344582a54e07a02c8d73cb1b6b Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Mon, 30 Mar 2026 20:23:51 -0700 Subject: [PATCH 7/9] Clear cached spec on new tool input to prevent stale chart resurrection --- sidemantic/apps/chart.html | 26 +++++++++++++------------- sidemantic/apps/web/chart-app.ts | 1 + 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/sidemantic/apps/chart.html b/sidemantic/apps/chart.html index 7591444..d3c3e78 100644 --- a/sidemantic/apps/chart.html +++ b/sidemantic/apps/chart.html @@ -27,9 +27,9 @@ .expand-btn:hover { color: #ddd; background: rgba(255,255,255,0.1); } } - +`,nwe="chart-wrapper";function iwe(e){return typeof e=="function"}function VR(e,t,n,i){const r=`${t}
`,o=`
${n}`,s=window.open("");s.document.write(r+e+o),s.document.title=`${hf[i]} JSON Source`}function rwe(e,t,n){if(e.$schema){const i=z9(e.$schema);n&&n!==i.library&&t.warn(`The given visualization spec is written in ${hf[i.library]}, but mode argument sets ${hf[n]??n}.`);const r=i.library;return Z9(fm[r],`^${i.version.slice(1)}`)||t.warn(`The input spec uses ${hf[r]} ${i.version}, but the current version of ${hf[r]} is v${fm[r]}.`),r}return"mark"in e||"encoding"in e||"layer"in e||"hconcat"in e||"vconcat"in e||"facet"in e||"repeat"in e?"vega-lite":"marks"in e||"signals"in e||"scales"in e||"axes"in e?"vega":n??"vega"}function Y9(e){return!!(e&&"load"in e)}function YR(e){return Y9(e)?e:xr.loader(e)}function owe(e){var n;const t=((n=e.usermeta)==null?void 0:n.embedOptions)??{};return te(t.defaultStyle)&&(t.defaultStyle=!1),t}async function swe(e,t,n={}){let i,r;te(t)?(r=YR(n.loader),i=JSON.parse(await r.load(t))):i=t;const o=owe(i),s=o.loader;(!r||s)&&(r=YR(n.loader??s));const a=await XR(o,r),u=await XR(n,r),l={...V9(u,a),config:Gl(u.config??{},a.config??{})};return await uwe(e,i,l,r)}async function XR(e,t){const n=te(e.config)?JSON.parse(await t.load(e.config)):e.config??{},i=te(e.patch)?JSON.parse(await t.load(e.patch)):e.patch;return{...e,...i?{patch:i}:{},...n?{config:n}:{}}}function awe(e){const t=e.getRootNode?e.getRootNode():document;return t instanceof ShadowRoot?{root:t,rootContainer:t}:{root:document,rootContainer:document.head??document.body}}async function uwe(e,t,n={},i){const r=n.theme?Gl(c_e[n.theme],n.config??{}):n.config,o=nu(n.actions)?n.actions:V9({},K_e,n.actions??{}),s={...Q_e,...n.i18n},a=n.renderer??"svg",u=n.logger??xm(xr.Warn);n.logLevel!==void 0&&u.level(n.logLevel);const l=n.downloadFileName??"visualization",c=typeof e=="string"?document.querySelector(e):e;if(!c)throw new Error(`${e} does not exist`);if(n.defaultStyle!==!1){const w="vega-embed-style",{root:S,rootContainer:_}=awe(c);if(!S.getElementById(w)){const E=document.createElement("style");E.id=w,E.innerHTML=n.defaultStyle===void 0||n.defaultStyle===!0?X_e.toString():n.defaultStyle,_.appendChild(E)}}const f=rwe(t,u,n.mode);let d=ewe[f](t,u,r);if(f==="vega-lite"&&d.$schema){const w=z9(d.$schema);Z9(fm.vega,`^${w.version.slice(1)}`)||u.warn(`The compiled spec uses Vega ${w.version}, but current version is v${fm.vega}.`)}c.classList.add("vega-embed"),o&&c.classList.add("has-actions"),c.innerHTML="";let h=c;if(o){const w=document.createElement("div");w.classList.add(nwe),c.appendChild(w),h=w}const p=n.patch;if(p&&(d=p instanceof Function?p(d):Sy(d,p,!0,!1).newDocument),n.formatLocale&&xr.formatLocale(n.formatLocale),n.timeFormatLocale&&xr.timeFormatLocale(n.timeFormatLocale),n.expressionFunctions)for(const w in n.expressionFunctions){const S=n.expressionFunctions[w];"fn"in S?xr.expressionFunction(w,S.fn,S.visitor):S instanceof Function&&xr.expressionFunction(w,S)}const{ast:g}=n,m=xr.parse(d,f==="vega-lite"?{}:r,{ast:g}),y=new(n.viewClass||xr.View)(m,{loader:i,logger:u,renderer:a,...g?{expr:xr.expressionInterpreter??n.expr??uL}:{}});if(y.addSignalListener("autosize",(w,S)=>{const{type:_}=S;_=="fit-x"?(h.classList.add("fit-x"),h.classList.remove("fit-y")):_=="fit-y"?(h.classList.remove("fit-x"),h.classList.add("fit-y")):_=="fit"?h.classList.add("fit-x","fit-y"):h.classList.remove("fit-x","fit-y")}),n.tooltip!==!1){const{loader:w,tooltip:S}=n,_=w&&!Y9(w)?w==null?void 0:w.baseURL:void 0,E=iwe(S)?S:new x_e({baseURL:_,...S===!0?{}:S}).call;y.tooltip(E)}let{hover:b}=n;if(b===void 0&&(b=f==="vega"),b){const{hoverSet:w,updateSet:S}=typeof b=="boolean"?{}:b;y.hover(w,S)}n&&(n.width!=null&&y.width(n.width),n.height!=null&&y.height(n.height),n.padding!=null&&y.padding(n.padding)),await y.initialize(h,n.bind).runAsync();let v;if(o!==!1){let w=c;if(n.defaultStyle!==!1||n.forceActionsMenu){const _=document.createElement("details");_.title=s.CLICK_TO_VIEW_ACTIONS,c.append(_),w=_;const E=document.createElement("summary");E.innerHTML=twe,_.append(E),v=k=>{_.contains(k.target)||_.removeAttribute("open")},document.addEventListener("click",v)}const S=document.createElement("div");if(w.append(S),S.classList.add("vega-actions"),o===!0||o.export!==!1){for(const _ of["svg","png"])if(o===!0||o.export===!0||o.export[_]){const E=s[`${_.toUpperCase()}_ACTION`],k=document.createElement("a"),A=ee(n.scaleFactor)?n.scaleFactor[_]:n.scaleFactor;k.text=E,k.href="#",k.target="_blank",k.download=`${l}.${_}`,k.addEventListener("mousedown",async function(T){T.preventDefault();const R=await y.toImageURL(_,A);this.href=R}),S.append(k)}}if(o===!0||o.source!==!1){const _=document.createElement("a");_.text=s.SOURCE_ACTION,_.href="#",_.addEventListener("click",function(E){VR(Cy(t),n.sourceHeader??"",n.sourceFooter??"",f),E.preventDefault()}),S.append(_)}if(f==="vega-lite"&&(o===!0||o.compiled!==!1)){const _=document.createElement("a");_.text=s.COMPILED_ACTION,_.href="#",_.addEventListener("click",function(E){VR(Cy(d),n.sourceHeader??"",n.sourceFooter??"","vega"),E.preventDefault()}),S.append(_)}if(o===!0||o.editor!==!1){const _=n.editorUrl??"https://vega.github.io/editor/",E=document.createElement("a");E.text=s.EDITOR_ACTION,E.href="#",E.addEventListener("click",function(k){Y_e(window,_,{config:r,mode:p?"vega":f,renderer:a,spec:Cy(p?d:t)}),k.preventDefault()}),S.append(E)}}function x(){v&&document.removeEventListener("click",v),y.finalize()}return{view:y,spec:t,vgSpec:d,finalize:x,embedOptions:n}}const ls=document.getElementById("chart");let Lk="inline",eu=null,kp=null,$p=null,pf=0;function Bk(){kp&&(kp.disconnect(),kp=null),$p&&($p.finalize(),$p=null)}function Uk(e){var o;Bk();const t=++pf;ls.innerHTML="";const n=Lk==="fullscreen";document.documentElement.classList.toggle("fullscreen",n);const i={...e};i.width="container",i.height=n?"container":500,i.background="transparent";const r=(o=window.matchMedia)==null?void 0:o.call(window,"(prefers-color-scheme: dark)").matches;swe(ls,i,{actions:!1,theme:r?"dark":void 0,ast:!0,expr:uL}).then(s=>{if(t!==pf){s.finalize();return}$p=s;const a=new ResizeObserver(()=>s.view.resize().run());a.observe(ls),kp=a,n||lwe(),requestAnimationFrame(()=>{if(t===pf)if(n)Ao.sendSizeChanged({height:window.innerHeight-150});else{const u=Math.max(505,document.documentElement.scrollHeight+5);Ao.sendSizeChanged({height:u})}})}).catch(s=>{t===pf&&(ls.innerHTML=`
Chart render error: ${s.message}
`)})}function lwe(){const e=document.createElement("div");e.className="expand-btn",e.title="Expand to fullscreen",e.textContent="Expand ↗",e.addEventListener("click",cwe),ls.appendChild(e)}async function cwe(){try{Lk=(await Ao.requestDisplayMode({mode:"fullscreen"})).mode,eu&&Uk(eu)}catch{}}function fwe(e){const t=e.structuredContent;if(t!=null&&t.vega_spec)return t.vega_spec;if(e.content){for(const n of e.content)if(n.type==="text")try{const i=JSON.parse(n.text);if(i.vega_spec)return i.vega_spec}catch{}}return null}const Ao=new QW({name:"sidemantic-chart",version:"1.0.0"},{},{autoResize:!1});Ao.ontoolresult=e=>{const t=fwe(e);t?(eu=t,Uk(t)):(Bk(),eu=null,ls.innerHTML='
No chart data in tool result
')};Ao.ontoolinput=()=>{Bk(),eu=null,++pf,ls.innerHTML='
Running query...
'};Ao.onhostcontextchanged=e=>{e.theme&&eN(e.theme),(e.displayMode==="inline"||e.displayMode==="fullscreen")&&(Lk=e.displayMode,eu&&Uk(eu))};Ao.connect().then(()=>{const e=Ao.getHostContext();e!=null&&e.theme&&eN(e.theme);const t=ls.querySelector(".loading");t&&(t.textContent="Waiting for chart data..."),Ao.sendSizeChanged({height:500})});
diff --git a/sidemantic/apps/web/chart-app.ts b/sidemantic/apps/web/chart-app.ts index 18d9d0b..7bbefe0 100644 --- a/sidemantic/apps/web/chart-app.ts +++ b/sidemantic/apps/web/chart-app.ts @@ -119,6 +119,7 @@ app.ontoolresult = (result: CallToolResult) => { app.ontoolinput = () => { cleanupChart(); + lastSpec = null; ++renderGeneration; container.innerHTML = '
Running query...
'; }; From 8e2ce34f37413a632c12b4611419da9c873c0689 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Mon, 30 Mar 2026 20:25:50 -0700 Subject: [PATCH 8/9] Add interactive metrics explorer as MCP App - New explore_metrics tool launches dashboard with metric series, totals, and dimension leaderboards via ui://sidemantic/explorer - App-only widget_query tool (visibility: ["app"]) handles refresh calls from the widget without LLM round-trips - WidgetModel adapter bridges anywidget's model interface to ext-apps SDK, translating set/save_changes into callServerTool requests - Data transported as base64 Arrow IPC for efficient typed transfer - Multi-entry Vite build for chart (960KB) and explorer (336KB) widgets - Add pyarrow to apps optional dependency --- pyproject.toml | 1 + sidemantic/apps/__init__.py | 25 +- sidemantic/apps/explorer.html | 194 +++++++++++ sidemantic/apps/web/explorer-app.ts | 268 +++++++++++++++ sidemantic/apps/web/explorer.html | 35 ++ sidemantic/apps/web/package.json | 4 +- sidemantic/apps/web/vite.config.ts | 11 +- sidemantic/mcp_server.py | 504 ++++++++++++++++++++++++++++ uv.lock | 2 + 9 files changed, 1032 insertions(+), 12 deletions(-) create mode 100644 sidemantic/apps/explorer.html create mode 100644 sidemantic/apps/web/explorer-app.ts create mode 100644 sidemantic/apps/web/explorer.html 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 476c3f1..41cd53b 100644 --- a/sidemantic/apps/__init__.py +++ b/sidemantic/apps/__init__.py @@ -8,18 +8,33 @@ from pathlib import Path -_WIDGET_HTML: str | None = None +_CHART_HTML: str | None = None +_EXPLORER_HTML: str | None = None def _get_widget_template() -> str: """Load the built chart widget HTML for the MCP Apps resource handler.""" - global _WIDGET_HTML - if _WIDGET_HTML is None: + global _CHART_HTML + if _CHART_HTML is None: built = Path(__file__).parent / "chart.html" if built.exists(): - _WIDGET_HTML = built.read_text() + _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 _WIDGET_HTML + 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/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/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 index e3745bd..5599690 100644 --- a/sidemantic/apps/web/package.json +++ b/sidemantic/apps/web/package.json @@ -3,7 +3,9 @@ "private": true, "type": "module", "scripts": { - "build": "vite build" + "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", diff --git a/sidemantic/apps/web/vite.config.ts b/sidemantic/apps/web/vite.config.ts index e8b27df..e5d0600 100644 --- a/sidemantic/apps/web/vite.config.ts +++ b/sidemantic/apps/web/vite.config.ts @@ -1,20 +1,19 @@ 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: "chart.html" }, + rollupOptions: { input: `${entry}.html` }, outDir: "../", emptyOutDir: false, }, - define: { - // Replace new Function calls with a safe fallback at build time - // This prevents CSP violations in MCP Apps sandboxes - }, resolve: { alias: { - // Use CSP-safe expression interpreter + // Use CSP-safe expression interpreter (chart widget) "vega-functions/codegenExpression": "vega-interpreter", }, }, diff --git a/sidemantic/mcp_server.py b/sidemantic/mcp_server.py index c464c84..5e512f4 100644 --- a/sidemantic/mcp_server.py +++ b/sidemantic/mcp_server.py @@ -699,6 +699,493 @@ def get_semantic_graph() -> dict[str, Any]: return result +# --- 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 = "", +) -> 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. + + 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 + + # 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 --- @@ -719,6 +1206,23 @@ def chart_widget_resource() -> str: 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") def catalog_resource() -> str: """Postgres-compatible catalog metadata for the semantic layer. diff --git a/uv.lock b/uv.lock index fe26cc4..8e916e6 100644 --- a/uv.lock +++ b/uv.lock @@ -3293,6 +3293,7 @@ api = [ ] apps = [ { name = "mcp", extra = ["cli"] }, + { name = "pyarrow" }, ] bigquery = [ { name = "google-cloud-bigquery" }, @@ -3438,6 +3439,7 @@ requires-dist = [ { name = "pure-sasl", marker = "extra == 'spark'", specifier = ">=0.6.2" }, { name = "pyarrow", marker = "extra == 'adbc'", specifier = ">=14.0.0" }, { name = "pyarrow", marker = "extra == 'api'", specifier = ">=14.0.0" }, + { name = "pyarrow", marker = "extra == 'apps'", specifier = ">=14.0.0" }, { name = "pyarrow", marker = "extra == 'bigquery'", specifier = ">=14.0.0" }, { name = "pyarrow", marker = "extra == 'clickhouse'", specifier = ">=14.0.0" }, { name = "pyarrow", marker = "extra == 'databricks'", specifier = ">=14.0.0" }, From 6e28033d1b2528dfc09232c9e9fd75701d8ca95d Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Mon, 30 Mar 2026 20:30:27 -0700 Subject: [PATCH 9/9] Add start_date/end_date params to explore_metrics for time filtering --- sidemantic/mcp_server.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/sidemantic/mcp_server.py b/sidemantic/mcp_server.py index 5e512f4..9dd1a30 100644 --- a/sidemantic/mcp_server.py +++ b/sidemantic/mcp_server.py @@ -828,6 +828,8 @@ def explore_metrics( 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. @@ -840,6 +842,8 @@ def explore_metrics( 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. @@ -948,6 +952,15 @@ def explore_metrics( 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: