|
| 1 | +import { App, applyDocumentTheme, type McpUiHostContext } from "@modelcontextprotocol/ext-apps"; |
| 2 | +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; |
| 3 | +import embed from "vega-embed"; |
| 4 | +import { expressionInterpreter } from "vega-interpreter"; |
| 5 | + |
| 6 | +const container = document.getElementById("chart")!; |
| 7 | + |
| 8 | +function renderChart(vegaSpec: Record<string, unknown>) { |
| 9 | + // Clear chart content but preserve the fullscreen button |
| 10 | + Array.from(container.children).forEach(c => { |
| 11 | + if (c.id !== "fullscreen-btn") c.remove(); |
| 12 | + }); |
| 13 | + const spec = { ...vegaSpec }; |
| 14 | + spec.width = "container"; |
| 15 | + spec.height = 400; |
| 16 | + spec.background = "transparent"; |
| 17 | + |
| 18 | + const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches; |
| 19 | + |
| 20 | + embed(container, spec as any, { |
| 21 | + actions: false, |
| 22 | + theme: prefersDark ? "dark" : undefined, |
| 23 | + // CSP-safe: use AST interpreter instead of eval |
| 24 | + ast: true, |
| 25 | + expr: expressionInterpreter, |
| 26 | + }) |
| 27 | + .then((result) => { |
| 28 | + const ro = new ResizeObserver(() => result.view.resize().run()); |
| 29 | + ro.observe(container); |
| 30 | + // Tell host the actual content height after render |
| 31 | + requestAnimationFrame(() => { |
| 32 | + const h = Math.max(500, document.documentElement.scrollHeight); |
| 33 | + app.sendSizeChanged({ height: h }); |
| 34 | + }); |
| 35 | + }) |
| 36 | + .catch((err) => { |
| 37 | + container.innerHTML = `<div class="error">Chart render error: ${err.message}</div>`; |
| 38 | + }); |
| 39 | +} |
| 40 | + |
| 41 | +function extractVegaSpec(result: CallToolResult): Record<string, unknown> | null { |
| 42 | + // Try structuredContent first |
| 43 | + const sc = result.structuredContent as Record<string, unknown> | undefined; |
| 44 | + if (sc?.vega_spec) return sc.vega_spec as Record<string, unknown>; |
| 45 | + // Then parse from text content |
| 46 | + if (result.content) { |
| 47 | + for (const item of result.content) { |
| 48 | + if (item.type === "text") { |
| 49 | + try { |
| 50 | + const data = JSON.parse((item as { text: string }).text); |
| 51 | + if (data.vega_spec) return data.vega_spec; |
| 52 | + } catch {} |
| 53 | + } |
| 54 | + } |
| 55 | + } |
| 56 | + return null; |
| 57 | +} |
| 58 | + |
| 59 | +// Create app and register handlers before connecting |
| 60 | +const fullscreenBtn = document.getElementById("fullscreen-btn")!; |
| 61 | +let currentDisplayMode: "inline" | "fullscreen" = "inline"; |
| 62 | + |
| 63 | +const app = new App( |
| 64 | + { name: "sidemantic-chart", version: "1.0.0" }, |
| 65 | + { availableDisplayModes: ["inline", "fullscreen"] }, |
| 66 | + { autoResize: false }, |
| 67 | +); |
| 68 | + |
| 69 | +app.ontoolresult = (result: CallToolResult) => { |
| 70 | + const spec = extractVegaSpec(result); |
| 71 | + if (spec) { |
| 72 | + renderChart(spec); |
| 73 | + } else { |
| 74 | + container.innerHTML = '<div class="error">No chart data in tool result</div>'; |
| 75 | + } |
| 76 | +}; |
| 77 | + |
| 78 | +app.ontoolinput = () => { |
| 79 | + container.innerHTML = '<div class="loading">Running query...</div>'; |
| 80 | +}; |
| 81 | + |
| 82 | +app.onhostcontextchanged = (ctx: McpUiHostContext) => { |
| 83 | + if (ctx.theme) applyDocumentTheme(ctx.theme); |
| 84 | + if (ctx.availableDisplayModes) { |
| 85 | + const canFullscreen = ctx.availableDisplayModes.includes("fullscreen"); |
| 86 | + fullscreenBtn.classList.toggle("available", canFullscreen); |
| 87 | + } |
| 88 | + if (ctx.displayMode === "inline" || ctx.displayMode === "fullscreen") { |
| 89 | + currentDisplayMode = ctx.displayMode; |
| 90 | + document.body.classList.toggle("fullscreen", currentDisplayMode === "fullscreen"); |
| 91 | + fullscreenBtn.textContent = currentDisplayMode === "fullscreen" ? "✕" : "⛶"; |
| 92 | + } |
| 93 | +}; |
| 94 | + |
| 95 | +fullscreenBtn.addEventListener("click", async () => { |
| 96 | + const newMode = currentDisplayMode === "fullscreen" ? "inline" : "fullscreen"; |
| 97 | + const ctx = app.getHostContext(); |
| 98 | + if (ctx?.availableDisplayModes?.includes(newMode)) { |
| 99 | + const result = await app.requestDisplayMode({ mode: newMode }); |
| 100 | + currentDisplayMode = result.mode as "inline" | "fullscreen"; |
| 101 | + document.body.classList.toggle("fullscreen", currentDisplayMode === "fullscreen"); |
| 102 | + fullscreenBtn.textContent = currentDisplayMode === "fullscreen" ? "✕" : "⛶"; |
| 103 | + } |
| 104 | +}); |
| 105 | + |
| 106 | +app.connect().then(() => { |
| 107 | + const ctx = app.getHostContext(); |
| 108 | + if (ctx?.theme) applyDocumentTheme(ctx.theme); |
| 109 | + if (ctx?.availableDisplayModes?.includes("fullscreen")) { |
| 110 | + fullscreenBtn.classList.add("available"); |
| 111 | + } |
| 112 | + // Keep fullscreen button, replace only the chart content area |
| 113 | + const loading = container.querySelector(".loading"); |
| 114 | + if (loading) loading.textContent = "Waiting for chart data..."; |
| 115 | + app.sendSizeChanged({ height: 500 }); |
| 116 | +}); |
0 commit comments