-
Notifications
You must be signed in to change notification settings - Fork 4.6k
feat(autovisualiser): Migrate the autovisualiser extension to MCP Apps #7852
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
68092ca
Convert autovisualiser to MCP Apps (SEP-1865) compliance
aharvard fb1ea98
Support dynamic theme changes in autovisualiser templates
aharvard e9267bb
Enable fullscreen display mode for all visualizations
aharvard a0ce966
refactor(autovisualiser): extract shared MCP App bridge/CSS, redesign…
aharvard 5683d7a
refactor(autovisualiser): redesign all remaining templates to use sha…
aharvard e06031a
Improve autovisualiser sizing, dark mode, and data resilience
aharvard 6d2d2eb
Add input validation and error boundaries for all autovisualiser tools
aharvard 803e607
style(templates): update padding for body in donut, radar, and map te…
aharvard File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
103 changes: 103 additions & 0 deletions
103
crates/goose-mcp/src/autovisualiser/templates/assets/mcp-app-base.css
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| /* | ||
| * MCP App Base — shared foundation styles for autovisualiser templates. | ||
| * | ||
| * All color, typography, and spacing values come from the 70+ host theme tokens. | ||
| * Fallback defaults (--color-background-primary: #fff etc.) are defined here | ||
| * for resilience, but are overridden at runtime by the bridge's applyTheme(). | ||
| */ | ||
|
|
||
| /* ── Fallback tokens (overridden by host) ─────────────────────────── */ | ||
| :root { | ||
| --color-background-primary: light-dark(#ffffff, #1a1d21); | ||
| --color-background-secondary: light-dark(#f4f6f7, #22252a); | ||
| --color-background-tertiary: light-dark(#e3e6ea, #2a2e35); | ||
| --color-text-primary: light-dark(#3f434b, #e0e0e0); | ||
| --color-text-secondary: light-dark(#878787, #a0a0a0); | ||
| --color-text-tertiary: light-dark(#a7b0b9, #666); | ||
| --color-border-primary: light-dark(#e3e6ea, #333); | ||
| --color-border-secondary: light-dark(#e3e6ea, #333); | ||
| --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; | ||
| --font-mono: monospace; | ||
| --font-weight-normal: 400; | ||
| --font-weight-medium: 500; | ||
| --font-weight-semibold: 600; | ||
| --font-text-sm-size: 0.875rem; | ||
| --font-text-md-size: 1rem; | ||
| --font-text-xs-size: 0.75rem; | ||
| --font-heading-sm-size: 1.125rem; | ||
| --font-heading-xs-size: 1rem; | ||
| --border-radius-sm: 4px; | ||
| --border-radius-md: 8px; | ||
| --border-radius-lg: 12px; | ||
| --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); | ||
| } | ||
|
|
||
| /* ── Reset ─────────────────────────────────────────────────────────── */ | ||
| *, *::before, *::after { | ||
| box-sizing: border-box; | ||
| margin: 0; | ||
| padding: 0; | ||
| } | ||
|
|
||
| html, body { | ||
| overflow: hidden; | ||
| } | ||
|
|
||
| body { | ||
| font-family: var(--font-sans); | ||
| font-size: var(--font-text-sm-size); | ||
| font-weight: var(--font-weight-normal); | ||
| color: var(--color-text-primary); | ||
| background: linear-gradient( | ||
| light-dark(rgba(0, 0, 0, 0.02), rgba(0, 0, 0, 0.2)), | ||
| light-dark(rgba(0, 0, 0, 0.02), rgba(0, 0, 0, 0.2)) | ||
| ), | ||
| var(--color-background-primary); | ||
| -webkit-font-smoothing: antialiased; | ||
| -moz-osx-font-smoothing: grayscale; | ||
| } | ||
|
|
||
| /* ── Fullscreen / PiP centering ────────────────────────────────────── */ | ||
| :root[data-display-mode="fullscreen"] body, | ||
| :root[data-display-mode="pip"] body { | ||
| height: 100vh; | ||
| display: flex; | ||
| flex-direction: column; | ||
| justify-content: center; | ||
| align-items: center; | ||
| } | ||
|
|
||
| /* ── Loading state ─────────────────────────────────────────────────── */ | ||
| .av-loading { | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| min-height: 200px; | ||
| color: var(--color-text-tertiary); | ||
| font-size: var(--font-text-sm-size); | ||
| letter-spacing: 0.01em; | ||
| } | ||
|
|
||
| .av-loading.hidden { | ||
| display: none; | ||
| } | ||
|
|
||
| /* ── Tooltip ───────────────────────────────────────────────────────── */ | ||
| .av-tooltip { | ||
| position: absolute; | ||
| background: var(--color-background-inverse, rgba(0, 0, 0, 0.85)); | ||
| color: var(--color-text-inverse, #fff); | ||
| padding: 6px 10px; | ||
| border-radius: var(--border-radius-sm); | ||
| font-size: var(--font-text-xs-size); | ||
| line-height: 1.4; | ||
| pointer-events: none; | ||
| opacity: 0; | ||
| transition: opacity 0.15s ease; | ||
| z-index: 1000; | ||
| max-width: 280px; | ||
| } | ||
|
|
||
| .av-tooltip strong { | ||
| font-weight: var(--font-weight-semibold); | ||
| } |
262 changes: 262 additions & 0 deletions
262
crates/goose-mcp/src/autovisualiser/templates/assets/mcp-app-bridge.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,262 @@ | ||
| /** | ||
| * MCP App Bridge — shared protocol layer for autovisualiser templates. | ||
| * | ||
| * Provides: | ||
| * McpAppBridge.init(options) → bootstraps the MCP Apps lifecycle | ||
| * | ||
| * options: | ||
| * appName – string, e.g. "autovisualiser-chart" | ||
| * onData – function(data): called when tool-result or tool-input arrives | ||
| * onTheme – function(): called after theme CSS vars are applied (optional) | ||
| * extractData – function(msg): custom data extractor (optional, has sensible default) | ||
| */ | ||
| var McpAppBridge = (function () { | ||
| "use strict"; | ||
|
|
||
| var _nextId = 1; | ||
| var _currentData = null; | ||
| var _onData = null; | ||
| var _onTheme = null; | ||
| var _extractData = null; | ||
|
|
||
| // ── JSON-RPC helpers ──────────────────────────────────────────────── | ||
|
|
||
| function sendRequest(method, params) { | ||
| return new Promise(function (resolve, reject) { | ||
| var id = _nextId++; | ||
| function handler(event) { | ||
| if (event.data && event.data.id === id) { | ||
| window.removeEventListener("message", handler); | ||
| if (event.data.result) resolve(event.data.result); | ||
| else if (event.data.error) reject(event.data.error); | ||
| } | ||
| } | ||
| window.addEventListener("message", handler); | ||
| window.parent.postMessage( | ||
| { jsonrpc: "2.0", id: id, method: method, params: params }, | ||
| "*" | ||
| ); | ||
| }); | ||
| } | ||
|
|
||
| function sendNotification(method, params) { | ||
| window.parent.postMessage( | ||
| { jsonrpc: "2.0", method: method, params: params }, | ||
| "*" | ||
| ); | ||
| } | ||
|
|
||
| // ── Size reporting ────────────────────────────────────────────────── | ||
|
|
||
| function reportSize() { | ||
| // In fullscreen/pip the host controls sizing — skip size reports | ||
| // to avoid a feedback loop when transitioning back to inline. | ||
| if (_displayMode === "fullscreen" || _displayMode === "pip") return; | ||
|
|
||
| var h = Math.max( | ||
| document.body.scrollHeight, | ||
| document.body.offsetHeight, | ||
| document.documentElement.scrollHeight, | ||
| document.documentElement.offsetHeight | ||
| ); | ||
| sendNotification("ui/notifications/size-changed", { | ||
| width: document.body.scrollWidth, | ||
| height: h, | ||
| }); | ||
| } | ||
|
|
||
| // ── Theming ───────────────────────────────────────────────────────── | ||
|
|
||
| var _displayMode = "inline"; | ||
|
|
||
| function applyTheme(hostContext) { | ||
| if (!hostContext) return; | ||
| // Clear resolved color cache — theme change means light-dark() flips. | ||
| _probeCache = {}; | ||
| if (hostContext.theme) | ||
| document.documentElement.style.colorScheme = hostContext.theme; | ||
| if (hostContext.displayMode) { | ||
| _displayMode = hostContext.displayMode; | ||
| document.documentElement.setAttribute("data-display-mode", _displayMode); | ||
| } | ||
| if (hostContext.styles && hostContext.styles.variables) { | ||
| var vars = hostContext.styles.variables; | ||
| for (var key in vars) { | ||
| if (vars[key]) document.documentElement.style.setProperty(key, vars[key]); | ||
| } | ||
| } | ||
| if (hostContext.styles && hostContext.styles.css && hostContext.styles.css.fonts) { | ||
| if (!document.getElementById("mcp-host-fonts")) { | ||
| var style = document.createElement("style"); | ||
| style.id = "mcp-host-fonts"; | ||
| style.textContent = hostContext.styles.css.fonts; | ||
| document.head.appendChild(style); | ||
| } | ||
| } | ||
| if (_onTheme) _onTheme(); | ||
| } | ||
|
|
||
| // ── Default data extractor ────────────────────────────────────────── | ||
|
|
||
| function defaultExtractData(msg) { | ||
| var sc = msg.params && msg.params.structuredContent; | ||
| if (sc) { | ||
| if (sc.data) return sc.data; | ||
| return sc; | ||
| } | ||
| var args = msg.params && msg.params.arguments; | ||
| if (args) { | ||
| if (args.data) return args.data; | ||
| return args; | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| // ── Read a computed CSS variable ──────────────────────────────────── | ||
|
|
||
| // Host tokens may use light-dark(light, dark) syntax. getComputedStyle | ||
| // returns the raw string for custom properties, so we resolve it by | ||
| // reading the value through a real CSS property on a hidden probe element. | ||
| var _probe = null; | ||
| var _probeCache = {}; | ||
|
|
||
| function resolveColor(raw) { | ||
| if (!raw) return raw; | ||
| // Fast path: no light-dark() wrapper | ||
| if (raw.indexOf("light-dark(") === -1) return raw; | ||
|
|
||
| var cached = _probeCache[raw]; | ||
| if (cached) return cached; | ||
|
|
||
| if (!_probe) { | ||
| _probe = document.createElement("div"); | ||
| _probe.style.cssText = "position:absolute;width:0;height:0;overflow:hidden;pointer-events:none;"; | ||
| document.body.appendChild(_probe); | ||
| } | ||
| _probe.style.color = raw; | ||
| var resolved = getComputedStyle(_probe).color; | ||
| _probeCache[raw] = resolved; | ||
| return resolved; | ||
| } | ||
|
|
||
| function cssVar(name) { | ||
| var raw = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); | ||
| return resolveColor(raw); | ||
| } | ||
|
|
||
| // ── Public API ────────────────────────────────────────────────────── | ||
|
|
||
| function init(options) { | ||
| _onData = options.onData; | ||
| _onTheme = options.onTheme || null; | ||
| _extractData = options.extractData || defaultExtractData; | ||
|
|
||
| // Size observation | ||
| if (typeof ResizeObserver !== "undefined") { | ||
| new ResizeObserver(reportSize).observe(document.body); | ||
| } | ||
| window.addEventListener("resize", reportSize); | ||
|
|
||
| // Message listener | ||
| window.addEventListener("message", function (event) { | ||
| var msg = event.data; | ||
| if (!msg || msg.jsonrpc !== "2.0") return; | ||
|
|
||
| if ( | ||
| msg.method === "ui/notifications/tool-result" || | ||
| msg.method === "ui/notifications/tool-input" | ||
| ) { | ||
| var data = _extractData(msg); | ||
| if (data) { | ||
| _currentData = data; | ||
| _onData(data); | ||
| } | ||
| } | ||
|
|
||
| if (msg.method === "ui/notifications/host-context-changed") { | ||
| applyTheme(msg.params); | ||
| } | ||
|
|
||
| if (msg.method === "ui/resource-teardown" && msg.id) { | ||
| window.parent.postMessage( | ||
| { jsonrpc: "2.0", id: msg.id, result: {} }, | ||
| "*" | ||
| ); | ||
| } | ||
| }); | ||
|
|
||
| // Handshake | ||
| var appIdentity = { | ||
| name: options.appName || "autovisualiser", | ||
| version: "1.0.0", | ||
| }; | ||
| sendRequest("ui/initialize", { | ||
| protocolVersion: "2026-01-26", | ||
| appInfo: appIdentity, | ||
| clientInfo: appIdentity, | ||
| appCapabilities: { | ||
| availableDisplayModes: ["inline", "fullscreen"], | ||
| }, | ||
| capabilities: {}, | ||
| }) | ||
| .then(function (result) { | ||
| applyTheme(result.hostContext || result); | ||
| sendNotification("ui/notifications/initialized", {}); | ||
| reportSize(); | ||
| }) | ||
| .catch(function (err) { | ||
| console.warn("[" + (options.appName || "autovisualiser") + "] init failed:", err); | ||
| reportSize(); | ||
| }); | ||
| } | ||
|
|
||
| function positionTooltip(tooltipEl, event, offsetX, offsetY) { | ||
| var ox = offsetX || 10; | ||
| var oy = offsetY || -10; | ||
| var el = tooltipEl instanceof HTMLElement ? tooltipEl : tooltipEl.node(); | ||
| if (!el) return; | ||
| var rect = el.getBoundingClientRect(); | ||
| var w = rect.width || 150; | ||
| var h = rect.height || 40; | ||
| var x = event.pageX + ox; | ||
| var y = event.pageY + oy; | ||
| if (x + w > window.innerWidth - 8) x = event.pageX - w - ox; | ||
| if (y + h > window.innerHeight - 8) y = event.pageY - h - Math.abs(oy); | ||
| if (x < 8) x = 8; | ||
| if (y < 8) y = 8; | ||
| el.style.left = x + "px"; | ||
| el.style.top = y + "px"; | ||
| } | ||
|
|
||
| /** | ||
| * Display an error message in the page body. | ||
| * Hides the loading indicator and shows a styled error box. | ||
| */ | ||
| function showError(message) { | ||
| var loader = document.getElementById("loadingIndicator"); | ||
| if (loader) loader.classList.add("hidden"); | ||
|
|
||
| var el = document.createElement("div"); | ||
| el.style.cssText = | ||
| "padding:24px;text-align:center;color:" + | ||
| (cssVar("--color-text-secondary") || "#878787") + | ||
| ";font-size:14px;font-family:" + | ||
| (cssVar("--font-sans") || "sans-serif"); | ||
| el.textContent = "Unable to render visualization: " + message; | ||
| document.body.appendChild(el); | ||
| } | ||
|
|
||
| return { | ||
| init: init, | ||
| cssVar: cssVar, | ||
| reportSize: reportSize, | ||
| positionTooltip: positionTooltip, | ||
| showError: showError, | ||
| get currentData() { | ||
| return _currentData; | ||
| }, | ||
| get displayMode() { | ||
| return _displayMode; | ||
| }, | ||
| }; | ||
| })(); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ui/initializeadvertises only['inline','fullscreen'], so the desktop host will treat PiP as unsupported (useDisplayMode.tsreadsappCapabilities.availableDisplayModesand gates controls/requests from that list). As a result, none of these migrated visualizers can enter PiP even though their templates/style logic handledata-display-mode="pip", making the new PiP flow unreachable.Useful? React with 👍 / 👎.