diff --git a/crates/goose-mcp/src/autovisualiser/mod.rs b/crates/goose-mcp/src/autovisualiser/mod.rs index 7d5e2f1d1f..2427cb5f55 100644 --- a/crates/goose-mcp/src/autovisualiser/mod.rs +++ b/crates/goose-mcp/src/autovisualiser/mod.rs @@ -1,18 +1,80 @@ -use base64::{engine::general_purpose::STANDARD, Engine as _}; use etcetera::{choose_app_strategy, AppStrategy}; use indoc::formatdoc; use rmcp::{ handler::server::{router::tool::ToolRouter, wrapper::Parameters}, model::{ CallToolResult, Content, ErrorCode, ErrorData, Implementation, InitializeResult, - ResourceContents, Role, ServerCapabilities, ServerInfo, + ListResourcesResult, Meta, PaginatedRequestParams, RawResource, ReadResourceRequestParams, + ReadResourceResult, Resource, ResourceContents, ServerCapabilities, ServerInfo, }, - tool, tool_handler, tool_router, ServerHandler, + service::RequestContext, + tool, tool_handler, tool_router, RoleServer, ServerHandler, }; use serde::{Deserialize, Serialize}; -use serde_json::Value; +use serde_json::{json, Value}; use std::path::PathBuf; +/// MIME type for MCP Apps (SEP-1865) +const MCP_APPS_MIME_TYPE: &str = "text/html;profile=mcp-app"; + +/// Build a Meta object with `_meta.ui.resourceUri` for linking a tool to a UI resource. +fn ui_resource_meta(uri: &str) -> Meta { + let mut meta = Meta::new(); + meta.0 + .insert("ui".to_string(), json!({ "resourceUri": uri })); + meta +} + +/// Struct representing the UI resource definitions for autovisualiser chart types. +struct UIResourceDef { + uri: &'static str, + name: &'static str, + description: &'static str, +} + +const UI_RESOURCES: &[UIResourceDef] = &[ + UIResourceDef { + uri: "ui://autovisualiser/chart", + name: "Chart", + description: "Interactive line, bar, and scatter chart visualization", + }, + UIResourceDef { + uri: "ui://autovisualiser/sankey", + name: "Sankey Diagram", + description: "Flow diagram showing relationships between nodes", + }, + UIResourceDef { + uri: "ui://autovisualiser/radar", + name: "Radar Chart", + description: "Multi-dimensional data comparison spider chart", + }, + UIResourceDef { + uri: "ui://autovisualiser/donut", + name: "Donut/Pie Chart", + description: "Categorical data visualization as donut or pie chart", + }, + UIResourceDef { + uri: "ui://autovisualiser/treemap", + name: "Treemap", + description: "Hierarchical data visualization with proportional areas", + }, + UIResourceDef { + uri: "ui://autovisualiser/chord", + name: "Chord Diagram", + description: "Relationship and flow visualization between entities", + }, + UIResourceDef { + uri: "ui://autovisualiser/map", + name: "Interactive Map", + description: "Geographic data visualization with location markers", + }, + UIResourceDef { + uri: "ui://autovisualiser/mermaid", + name: "Mermaid Diagram", + description: "Diagram visualization from Mermaid syntax", + }, +]; + /// Validates that the data parameter is a proper JSON value and not a string fn validate_data_param(params: &Value, allow_array: bool) -> Result { let data_value = params.get("data").ok_or_else(|| { @@ -50,6 +112,10 @@ fn validate_data_param(params: &Value, allow_array: bool) -> Result) -> ErrorData { + ErrorData::new(ErrorCode::INVALID_PARAMS, msg.into(), None) +} + /// Sankey node structure #[derive(Debug, Serialize, Deserialize, rmcp::schemars::JsonSchema)] pub struct SankeyNode { @@ -80,6 +146,40 @@ pub struct SankeyData { pub links: Vec, } +impl SankeyData { + fn validate(&self) -> Result<(), ErrorData> { + if self.nodes.is_empty() { + return Err(validation_err("nodes array must not be empty")); + } + if self.links.is_empty() { + return Err(validation_err("links array must not be empty")); + } + let names: std::collections::HashSet<&str> = + self.nodes.iter().map(|n| n.name.as_str()).collect(); + for link in &self.links { + if !names.contains(link.source.as_str()) { + return Err(validation_err(format!( + "link source '{}' not found in nodes", + link.source + ))); + } + if !names.contains(link.target.as_str()) { + return Err(validation_err(format!( + "link target '{}' not found in nodes", + link.target + ))); + } + if link.value <= 0.0 { + return Err(validation_err(format!( + "link value must be positive, got {} for '{}' → '{}'", + link.value, link.source, link.target + ))); + } + } + Ok(()) + } +} + /// Parameters for render_sankey tool #[derive(Debug, Serialize, Deserialize, rmcp::schemars::JsonSchema)] pub struct RenderSankeyParams { @@ -105,6 +205,29 @@ pub struct RadarData { pub datasets: Vec, } +impl RadarData { + fn validate(&self) -> Result<(), ErrorData> { + if self.labels.is_empty() { + return Err(validation_err("labels array must not be empty")); + } + if self.datasets.is_empty() { + return Err(validation_err("datasets array must not be empty")); + } + let expected = self.labels.len(); + for ds in &self.datasets { + if ds.data.len() != expected { + return Err(validation_err(format!( + "dataset '{}' has {} values but there are {} labels", + ds.label, + ds.data.len(), + expected + ))); + } + } + Ok(()) + } +} + /// Parameters for render_radar tool #[derive(Debug, Serialize, Deserialize, rmcp::schemars::JsonSchema)] pub struct RenderRadarParams { @@ -140,8 +263,8 @@ pub enum DonutChartType { /// Single donut/pie chart data #[derive(Debug, Serialize, Deserialize, rmcp::schemars::JsonSchema)] pub struct SingleDonutChart { - /// Data values - can be numbers or objects with label and value - pub data: Vec, + /// Array of values — numbers (e.g. [10, 20]) or objects (e.g. [{"label": "A", "value": 10}]) + pub values: Vec, /// Optional chart title #[serde(skip_serializing_if = "Option::is_none")] pub title: Option, @@ -149,12 +272,12 @@ pub struct SingleDonutChart { #[serde(skip_serializing_if = "Option::is_none")] #[serde(rename = "type")] pub chart_type: Option, - /// Optional labels array (used when data is just numbers) + /// Optional labels array (used when values are just numbers) #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, } -/// Donut chart data wrapper - matches the old schema structure +/// Donut chart data — a single chart object or an array of chart objects #[derive(Debug, Serialize, Deserialize, rmcp::schemars::JsonSchema)] #[serde(untagged)] pub enum DonutChartData { @@ -164,19 +287,46 @@ pub enum DonutChartData { Multiple(Vec), } -/// Root structure for donut chart data - matches old schema -#[derive(Debug, Serialize, Deserialize, rmcp::schemars::JsonSchema)] -pub struct DonutData { - /// The chart data (single or multiple charts) - pub data: DonutChartData, +impl SingleDonutChart { + fn validate(&self) -> Result<(), ErrorData> { + if self.values.is_empty() { + return Err(validation_err("values array must not be empty")); + } + if let Some(labels) = &self.labels { + if labels.len() != self.values.len() { + return Err(validation_err(format!( + "labels array length ({}) must match values array length ({})", + labels.len(), + self.values.len() + ))); + } + } + Ok(()) + } +} + +impl DonutChartData { + fn validate(&self) -> Result<(), ErrorData> { + match self { + DonutChartData::Single(chart) => chart.validate(), + DonutChartData::Multiple(charts) => { + if charts.is_empty() { + return Err(validation_err("charts array must not be empty")); + } + for chart in charts { + chart.validate()?; + } + Ok(()) + } + } + } } /// Parameters for render_donut tool #[derive(Debug, Serialize, Deserialize, rmcp::schemars::JsonSchema)] pub struct RenderDonutParams { - /// The data for the donut/pie chart(s) - wrapped in data property - #[serde(flatten)] - pub data: DonutData, + /// The chart data (single chart object or array of chart objects) + pub data: DonutChartData, } /// Treemap node structure @@ -195,6 +345,24 @@ pub struct TreemapNode { pub children: Option>, } +impl TreemapNode { + fn validate(&self) -> Result<(), ErrorData> { + // Must have either a value or children + if self.value.is_none() && self.children.as_ref().is_none_or(|c| c.is_empty()) { + return Err(validation_err(format!( + "node '{}' must have either a value or non-empty children", + self.name + ))); + } + if let Some(children) = &self.children { + for child in children { + child.validate()?; + } + } + Ok(()) + } +} + /// Parameters for render_treemap tool #[derive(Debug, Serialize, Deserialize, rmcp::schemars::JsonSchema)] pub struct RenderTreemapParams { @@ -211,6 +379,33 @@ pub struct ChordData { pub matrix: Vec>, } +impl ChordData { + fn validate(&self) -> Result<(), ErrorData> { + if self.labels.is_empty() { + return Err(validation_err("labels array must not be empty")); + } + let n = self.labels.len(); + if self.matrix.len() != n { + return Err(validation_err(format!( + "matrix has {} rows but there are {} labels", + self.matrix.len(), + n + ))); + } + for (i, row) in self.matrix.iter().enumerate() { + if row.len() != n { + return Err(validation_err(format!( + "matrix row {} has {} columns but expected {}", + i, + row.len(), + n + ))); + } + } + Ok(()) + } +} + /// Parameters for render_chord tool #[derive(Debug, Serialize, Deserialize, rmcp::schemars::JsonSchema)] pub struct RenderChordParams { @@ -288,6 +483,29 @@ pub struct MapData { pub auto_fit: Option, } +impl MapData { + fn validate(&self) -> Result<(), ErrorData> { + if self.markers.is_empty() { + return Err(validation_err("markers array must not be empty")); + } + for (i, m) in self.markers.iter().enumerate() { + if !(-90.0..=90.0).contains(&m.lat) { + return Err(validation_err(format!( + "marker {} has invalid latitude {} (must be -90 to 90)", + i, m.lat + ))); + } + if !(-180.0..=180.0).contains(&m.lng) { + return Err(validation_err(format!( + "marker {} has invalid longitude {} (must be -180 to 180)", + i, m.lng + ))); + } + } + Ok(()) + } +} + /// Parameters for render_map tool #[derive(Debug, Serialize, Deserialize, rmcp::schemars::JsonSchema)] pub struct RenderMapParams { @@ -380,6 +598,29 @@ pub struct ChartData { pub y_axis_label: Option, } +impl ChartData { + fn validate(&self) -> Result<(), ErrorData> { + if self.datasets.is_empty() { + return Err(validation_err("datasets array must not be empty")); + } + if let Some(labels) = &self.labels { + for ds in &self.datasets { + if let ChartDataValues::Numbers(nums) = &ds.data { + if nums.len() != labels.len() { + return Err(validation_err(format!( + "dataset '{}' has {} values but there are {} labels", + ds.label, + nums.len(), + labels.len() + ))); + } + } + } + } + Ok(()) + } +} + /// Parameters for show_chart tool #[derive(Debug, Serialize, Deserialize, rmcp::schemars::JsonSchema)] pub struct ShowChartParams { @@ -412,12 +653,67 @@ impl Default for AutoVisualiserRouter { #[tool_handler(router = self.tool_router)] impl ServerHandler for AutoVisualiserRouter { fn get_info(&self) -> ServerInfo { - InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) - .with_server_info(Implementation::new( - "goose-autovisualiser", - env!("CARGO_PKG_VERSION"), - )) - .with_instructions(self.instructions.clone()) + InitializeResult::new( + ServerCapabilities::builder() + .enable_tools() + .enable_resources() + .build(), + ) + .with_server_info(Implementation::new( + "goose-autovisualiser", + env!("CARGO_PKG_VERSION"), + )) + .with_instructions(self.instructions.clone()) + } + + async fn list_resources( + &self, + _pagination: Option, + _context: RequestContext, + ) -> Result { + let resources = UI_RESOURCES + .iter() + .map(|def| Resource { + raw: RawResource { + uri: def.uri.to_string(), + name: def.name.to_string(), + title: Some(def.name.to_string()), + description: Some(def.description.to_string()), + mime_type: Some(MCP_APPS_MIME_TYPE.to_string()), + size: None, + icons: None, + meta: None, + }, + annotations: None, + }) + .collect(); + + Ok(ListResourcesResult { + resources, + next_cursor: None, + meta: None, + }) + } + + async fn read_resource( + &self, + params: ReadResourceRequestParams, + _context: RequestContext, + ) -> Result { + let html = self.get_template_html(¶ms.uri)?; + + let mut meta = Meta::new(); + meta.0 + .insert("ui".to_string(), json!({ "prefersBorder": true })); + + let resource_contents = ResourceContents::TextResourceContents { + uri: params.uri, + mime_type: Some(MCP_APPS_MIME_TYPE.to_string()), + text: html, + meta: Some(meta), + }; + + Ok(ReadResourceResult::new(vec![resource_contents])) } } @@ -460,6 +756,105 @@ impl AutoVisualiserRouter { } } + /// Get the static HTML template for a given `ui://` resource URI. + /// Templates have JS libs inlined but NO data baked in — data arrives via postMessage. + fn get_template_html(&self, uri: &str) -> Result { + match uri { + "ui://autovisualiser/chart" => { + const TEMPLATE: &str = include_str!("templates/chart_template.html"); + const CHART_MIN: &str = include_str!("templates/assets/chart.min.js"); + const BASE_CSS: &str = include_str!("templates/assets/mcp-app-base.css"); + const BRIDGE_JS: &str = include_str!("templates/assets/mcp-app-bridge.js"); + Ok(TEMPLATE + .replace("{{CHART_MIN}}", CHART_MIN) + .replace("{{MCP_APP_BASE_CSS}}", BASE_CSS) + .replace("{{MCP_APP_BRIDGE}}", BRIDGE_JS)) + } + "ui://autovisualiser/sankey" => { + const TEMPLATE: &str = include_str!("templates/sankey_template.html"); + const D3_MIN: &str = include_str!("templates/assets/d3.min.js"); + const D3_SANKEY: &str = include_str!("templates/assets/d3.sankey.min.js"); + const BASE_CSS: &str = include_str!("templates/assets/mcp-app-base.css"); + const BRIDGE_JS: &str = include_str!("templates/assets/mcp-app-bridge.js"); + Ok(TEMPLATE + .replace("{{D3_MIN}}", D3_MIN) + .replace("{{D3_SANKY}}", D3_SANKEY) + .replace("{{MCP_APP_BASE_CSS}}", BASE_CSS) + .replace("{{MCP_APP_BRIDGE}}", BRIDGE_JS)) + } + "ui://autovisualiser/radar" => { + const TEMPLATE: &str = include_str!("templates/radar_template.html"); + const CHART_MIN: &str = include_str!("templates/assets/chart.min.js"); + const BASE_CSS: &str = include_str!("templates/assets/mcp-app-base.css"); + const BRIDGE_JS: &str = include_str!("templates/assets/mcp-app-bridge.js"); + Ok(TEMPLATE + .replace("{{CHART_MIN}}", CHART_MIN) + .replace("{{MCP_APP_BASE_CSS}}", BASE_CSS) + .replace("{{MCP_APP_BRIDGE}}", BRIDGE_JS)) + } + "ui://autovisualiser/donut" => { + const TEMPLATE: &str = include_str!("templates/donut_template.html"); + const CHART_MIN: &str = include_str!("templates/assets/chart.min.js"); + const BASE_CSS: &str = include_str!("templates/assets/mcp-app-base.css"); + const BRIDGE_JS: &str = include_str!("templates/assets/mcp-app-bridge.js"); + Ok(TEMPLATE + .replace("{{CHART_MIN}}", CHART_MIN) + .replace("{{MCP_APP_BASE_CSS}}", BASE_CSS) + .replace("{{MCP_APP_BRIDGE}}", BRIDGE_JS)) + } + "ui://autovisualiser/treemap" => { + const TEMPLATE: &str = include_str!("templates/treemap_template.html"); + const D3_MIN: &str = include_str!("templates/assets/d3.min.js"); + const BASE_CSS: &str = include_str!("templates/assets/mcp-app-base.css"); + const BRIDGE_JS: &str = include_str!("templates/assets/mcp-app-bridge.js"); + Ok(TEMPLATE + .replace("{{D3_MIN}}", D3_MIN) + .replace("{{MCP_APP_BASE_CSS}}", BASE_CSS) + .replace("{{MCP_APP_BRIDGE}}", BRIDGE_JS)) + } + "ui://autovisualiser/chord" => { + const TEMPLATE: &str = include_str!("templates/chord_template.html"); + const D3_MIN: &str = include_str!("templates/assets/d3.min.js"); + const BASE_CSS: &str = include_str!("templates/assets/mcp-app-base.css"); + const BRIDGE_JS: &str = include_str!("templates/assets/mcp-app-bridge.js"); + Ok(TEMPLATE + .replace("{{D3_MIN}}", D3_MIN) + .replace("{{MCP_APP_BASE_CSS}}", BASE_CSS) + .replace("{{MCP_APP_BRIDGE}}", BRIDGE_JS)) + } + "ui://autovisualiser/map" => { + const TEMPLATE: &str = include_str!("templates/map_template.html"); + const LEAFLET_JS: &str = include_str!("templates/assets/leaflet.min.js"); + const LEAFLET_CSS: &str = include_str!("templates/assets/leaflet.min.css"); + const MARKERCLUSTER_JS: &str = + include_str!("templates/assets/leaflet.markercluster.min.js"); + const BASE_CSS: &str = include_str!("templates/assets/mcp-app-base.css"); + const BRIDGE_JS: &str = include_str!("templates/assets/mcp-app-bridge.js"); + Ok(TEMPLATE + .replace("{{LEAFLET_JS}}", LEAFLET_JS) + .replace("{{LEAFLET_CSS}}", LEAFLET_CSS) + .replace("{{MARKERCLUSTER_JS}}", MARKERCLUSTER_JS) + .replace("{{MCP_APP_BASE_CSS}}", BASE_CSS) + .replace("{{MCP_APP_BRIDGE}}", BRIDGE_JS)) + } + "ui://autovisualiser/mermaid" => { + const TEMPLATE: &str = include_str!("templates/mermaid_template.html"); + const MERMAID_MIN: &str = include_str!("templates/assets/mermaid.min.js"); + const BASE_CSS: &str = include_str!("templates/assets/mcp-app-base.css"); + const BRIDGE_JS: &str = include_str!("templates/assets/mcp-app-bridge.js"); + Ok(TEMPLATE + .replace("{{MERMAID_MIN}}", MERMAID_MIN) + .replace("{{MCP_APP_BASE_CSS}}", BASE_CSS) + .replace("{{MCP_APP_BRIDGE}}", BRIDGE_JS)) + } + _ => Err(ErrorData::new( + ErrorCode::INVALID_REQUEST, + format!("Unknown resource URI: {}", uri), + None, + )), + } + } + /// show a Sankey diagram from flow data #[tool( name = "render_sankey", @@ -468,6 +863,11 @@ The data must contain: - nodes: Array of objects with 'name' and optional 'category' properties - links: Array of objects with 'source', 'target', and 'value' properties +IMPORTANT: Links must NOT form cycles (e.g. A→B and B→A). Sankey diagrams are +directional acyclic flows. If the data has circular relationships, restructure +them so flow moves in one direction (e.g. add a separate node like "Re-signed" +instead of linking back to an earlier node). + Example: { "nodes": [ @@ -477,14 +877,17 @@ Example: "links": [ {"source": "Source A", "target": "Target B", "value": 100} ] -}"# +}"#, + meta = ui_resource_meta("ui://autovisualiser/sankey") )] pub async fn render_sankey( &self, params: Parameters, ) -> Result { + let inner = params.0; + inner.data.validate()?; let data = validate_data_param( - &serde_json::to_value(params.0).map_err(|e| { + &serde_json::to_value(inner).map_err(|e| { ErrorData::new( ErrorCode::INVALID_PARAMS, format!("Invalid parameters: {}", e), @@ -494,49 +897,26 @@ Example: false, )?; - // Convert the data to JSON string - let data_json = serde_json::to_string(&data).map_err(|e| { - ErrorData::new( - ErrorCode::INVALID_PARAMS, - format!("Invalid JSON data: {}", e), - None, - ) - })?; + let node_count = data + .get("nodes") + .and_then(|v| v.as_array()) + .map(|a| a.len()) + .unwrap_or(0); + let link_count = data + .get("links") + .and_then(|v| v.as_array()) + .map(|a| a.len()) + .unwrap_or(0); + let text_fallback = format!( + "sankey diagram: {} node(s), {} link(s)", + node_count, link_count + ); - // Load all resources at compile time using include_str! - const TEMPLATE: &str = include_str!("templates/sankey_template.html"); - const D3_MIN: &str = include_str!("templates/assets/d3.min.js"); - const D3_SANKEY: &str = include_str!("templates/assets/d3.sankey.min.js"); + let mut result = CallToolResult::structured(data); + result.content = vec![Content::text(text_fallback)]; + result = result.with_meta(Some(ui_resource_meta("ui://autovisualiser/sankey"))); - // Replace all placeholders with actual content - let html_content = TEMPLATE - .replace("{{D3_MIN}}", D3_MIN) - .replace("{{D3_SANKY}}", D3_SANKEY) // Note: keeping the typo to match template - .replace("{{SANKEY_DATA}}", &data_json); - - // Save to /tmp/vis.html for debugging - let debug_path = std::path::Path::new("/tmp/vis.html"); - if let Err(e) = std::fs::write(debug_path, &html_content) { - tracing::warn!("Failed to write debug HTML to /tmp/vis.html: {}", e); - } else { - tracing::info!("Debug HTML saved to /tmp/vis.html"); - } - - // Use BlobResourceContents with base64 encoding to avoid JSON string escaping issues - let html_bytes = html_content.as_bytes(); - let base64_encoded = STANDARD.encode(html_bytes); - - let resource_contents = ResourceContents::BlobResourceContents { - uri: "ui://sankey/diagram".to_string(), - mime_type: Some("text/html".to_string()), - blob: base64_encoded, - meta: None, - }; - - Ok(CallToolResult::success(vec![Content::resource( - resource_contents, - ) - .with_audience(vec![Role::User])])) + Ok(result) } /// show a radar chart (spider chart) for multi-dimensional data comparison @@ -561,14 +941,17 @@ Example: "data": [75, 85, 80, 90, 70] } ] -}"# +}"#, + meta = ui_resource_meta("ui://autovisualiser/radar") )] pub async fn render_radar( &self, params: Parameters, ) -> Result { + let inner = params.0; + inner.data.validate()?; let data = validate_data_param( - &serde_json::to_value(params.0).map_err(|e| { + &serde_json::to_value(inner).map_err(|e| { ErrorData::new( ErrorCode::INVALID_PARAMS, format!("Invalid parameters: {}", e), @@ -578,84 +961,71 @@ Example: false, )?; - // Convert the data to JSON string - let data_json = serde_json::to_string(&data).map_err(|e| { - ErrorData::new( - ErrorCode::INVALID_PARAMS, - format!("Invalid JSON data: {}", e), - None, - ) - })?; + let label_count = data + .get("labels") + .and_then(|v| v.as_array()) + .map(|a| a.len()) + .unwrap_or(0); + let dataset_count = data + .get("datasets") + .and_then(|v| v.as_array()) + .map(|a| a.len()) + .unwrap_or(0); + let text_fallback = format!( + "radar chart: {} dimension(s), {} dataset(s)", + label_count, dataset_count + ); - // Load all resources at compile time using include_str! - const TEMPLATE: &str = include_str!("templates/radar_template.html"); - const CHART_MIN: &str = include_str!("templates/assets/chart.min.js"); + let mut result = CallToolResult::structured(data); + result.content = vec![Content::text(text_fallback)]; + result = result.with_meta(Some(ui_resource_meta("ui://autovisualiser/radar"))); - // Replace all placeholders with actual content - let html_content = TEMPLATE - .replace("{{CHART_MIN}}", CHART_MIN) - .replace("{{RADAR_DATA}}", &data_json); - - // Save to /tmp/radar.html for debugging - let debug_path = std::path::Path::new("/tmp/radar.html"); - if let Err(e) = std::fs::write(debug_path, &html_content) { - tracing::warn!("Failed to write debug HTML to /tmp/radar.html: {}", e); - } else { - tracing::info!("Debug HTML saved to /tmp/radar.html"); - } - - // Use BlobResourceContents with base64 encoding to avoid JSON string escaping issues - let html_bytes = html_content.as_bytes(); - let base64_encoded = STANDARD.encode(html_bytes); - - let resource_contents = ResourceContents::BlobResourceContents { - uri: "ui://radar/chart".to_string(), - mime_type: Some("text/html".to_string()), - blob: base64_encoded, - meta: None, - }; - - Ok(CallToolResult::success(vec![Content::resource( - resource_contents, - ) - .with_audience(vec![Role::User])])) + Ok(result) } /// show pie or donut charts for categorical data visualization #[tool( name = "render_donut", - description = r#"show pie or donut charts for categorical data visualization + description = r#"show pie or donut charts for categorical data visualization. Supports single or multiple charts in a grid layout. -Each chart should contain: -- data: Array of values or objects with 'label' and 'value' +Each chart object must contain: +- values: Array of numbers OR objects with 'label' and 'value' - type: Optional 'doughnut' (default) or 'pie' - title: Optional chart title -- labels: Optional array of labels (if data is just numbers) +- labels: Optional array of labels (required when values are plain numbers) -Example single chart: +Example single chart (labeled values): { - "title": "Budget", - "type": "doughnut", - "data": [ + "values": [ {"label": "Marketing", "value": 25000}, {"label": "Development", "value": 35000} - ] + ], + "title": "Budget" } -Example multiple charts: -[{ - "title": "Q1 Sales", +Example single chart (parallel arrays): +{ + "values": [45000, 38000], "labels": ["Product A", "Product B"], - "data": [45000, 38000] -}]"# + "type": "pie" +} + +Example multiple charts (array of chart objects): +[ + {"values": [60, 40], "labels": ["Yes", "No"], "title": "Q1"}, + {"values": [75, 25], "labels": ["Yes", "No"], "title": "Q2"} +]"#, + meta = ui_resource_meta("ui://autovisualiser/donut") )] pub async fn render_donut( &self, params: Parameters, ) -> Result { + let inner = params.0; + inner.data.validate()?; let data = validate_data_param( - &serde_json::to_value(params.0).map_err(|e| { + &serde_json::to_value(inner).map_err(|e| { ErrorData::new( ErrorCode::INVALID_PARAMS, format!("Invalid parameters: {}", e), @@ -663,49 +1033,24 @@ Example multiple charts: ) })?, true, - )?; // true because donut accepts arrays + )?; - // Convert the data to JSON string - let data_json = serde_json::to_string(&data).map_err(|e| { - ErrorData::new( - ErrorCode::INVALID_PARAMS, - format!("Invalid JSON data: {}", e), - None, - ) - })?; - - // Load all resources at compile time using include_str! - const TEMPLATE: &str = include_str!("templates/donut_template.html"); - const CHART_MIN: &str = include_str!("templates/assets/chart.min.js"); - - // Replace all placeholders with actual content - let html_content = TEMPLATE - .replace("{{CHART_MIN}}", CHART_MIN) - .replace("{{CHARTS_DATA}}", &data_json); - - // Save to /tmp/donut.html for debugging - let debug_path = std::path::Path::new("/tmp/donut.html"); - if let Err(e) = std::fs::write(debug_path, &html_content) { - tracing::warn!("Failed to write debug HTML to /tmp/donut.html: {}", e); + let text_fallback = if data.is_array() { + let count = data.as_array().map(|a| a.len()).unwrap_or(0); + format!("donut/pie chart: {} chart(s)", count) } else { - tracing::info!("Debug HTML saved to /tmp/donut.html"); - } - - // Use BlobResourceContents with base64 encoding to avoid JSON string escaping issues - let html_bytes = html_content.as_bytes(); - let base64_encoded = STANDARD.encode(html_bytes); - - let resource_contents = ResourceContents::BlobResourceContents { - uri: "ui://donut/chart".to_string(), - mime_type: Some("text/html".to_string()), - blob: base64_encoded, - meta: None, + let title = data + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("Untitled"); + format!("donut/pie chart: \"{}\"", title) }; - Ok(CallToolResult::success(vec![Content::resource( - resource_contents, - ) - .with_audience(vec![Role::User])])) + let mut result = CallToolResult::structured(data); + result.content = vec![Content::text(text_fallback)]; + result = result.with_meta(Some(ui_resource_meta("ui://autovisualiser/donut"))); + + Ok(result) } /// show a treemap visualization for hierarchical data @@ -732,14 +1077,17 @@ Example: }, {"name": "Item 3", "value": 150, "category": "Type1"} ] -}"# +}"#, + meta = ui_resource_meta("ui://autovisualiser/treemap") )] pub async fn render_treemap( &self, params: Parameters, ) -> Result { + let inner = params.0; + inner.data.validate()?; let data = validate_data_param( - &serde_json::to_value(params.0).map_err(|e| { + &serde_json::to_value(inner).map_err(|e| { ErrorData::new( ErrorCode::INVALID_PARAMS, format!("Invalid parameters: {}", e), @@ -749,47 +1097,22 @@ Example: false, )?; - // Convert the data to JSON string - let data_json = serde_json::to_string(&data).map_err(|e| { - ErrorData::new( - ErrorCode::INVALID_PARAMS, - format!("Invalid JSON data: {}", e), - None, - ) - })?; + let root_name = data.get("name").and_then(|v| v.as_str()).unwrap_or("Root"); + let child_count = data + .get("children") + .and_then(|v| v.as_array()) + .map(|a| a.len()) + .unwrap_or(0); + let text_fallback = format!( + "treemap: \"{}\" with {} top-level children", + root_name, child_count + ); - // Load all resources at compile time using include_str! - const TEMPLATE: &str = include_str!("templates/treemap_template.html"); - const D3_MIN: &str = include_str!("templates/assets/d3.min.js"); + let mut result = CallToolResult::structured(data); + result.content = vec![Content::text(text_fallback)]; + result = result.with_meta(Some(ui_resource_meta("ui://autovisualiser/treemap"))); - // Replace all placeholders with actual content - let html_content = TEMPLATE - .replace("{{D3_MIN}}", D3_MIN) - .replace("{{TREEMAP_DATA}}", &data_json); - - // Save to /tmp/treemap.html for debugging - let debug_path = std::path::Path::new("/tmp/treemap.html"); - if let Err(e) = std::fs::write(debug_path, &html_content) { - tracing::warn!("Failed to write debug HTML to /tmp/treemap.html: {}", e); - } else { - tracing::info!("Debug HTML saved to /tmp/treemap.html"); - } - - // Use BlobResourceContents with base64 encoding to avoid JSON string escaping issues - let html_bytes = html_content.as_bytes(); - let base64_encoded = STANDARD.encode(html_bytes); - - let resource_contents = ResourceContents::BlobResourceContents { - uri: "ui://treemap/visualization".to_string(), - mime_type: Some("text/html".to_string()), - blob: base64_encoded, - meta: None, - }; - - Ok(CallToolResult::success(vec![Content::resource( - resource_contents, - ) - .with_audience(vec![Role::User])])) + Ok(result) } /// Show a chord diagram visualization for relationships and flows @@ -810,14 +1133,17 @@ Example: [22, 18, 0, 15], [5, 10, 18, 0] ] -}"# +}"#, + meta = ui_resource_meta("ui://autovisualiser/chord") )] pub async fn render_chord( &self, params: Parameters, ) -> Result { + let inner = params.0; + inner.data.validate()?; let data = validate_data_param( - &serde_json::to_value(params.0).map_err(|e| { + &serde_json::to_value(inner).map_err(|e| { ErrorData::new( ErrorCode::INVALID_PARAMS, format!("Invalid parameters: {}", e), @@ -827,47 +1153,18 @@ Example: false, )?; - // Convert the data to JSON string - let data_json = serde_json::to_string(&data).map_err(|e| { - ErrorData::new( - ErrorCode::INVALID_PARAMS, - format!("Invalid JSON data: {}", e), - None, - ) - })?; + let entity_count = data + .get("labels") + .and_then(|v| v.as_array()) + .map(|a| a.len()) + .unwrap_or(0); + let text_fallback = format!("chord diagram: {} entities", entity_count); - // Load all resources at compile time using include_str! - const TEMPLATE: &str = include_str!("templates/chord_template.html"); - const D3_MIN: &str = include_str!("templates/assets/d3.min.js"); + let mut result = CallToolResult::structured(data); + result.content = vec![Content::text(text_fallback)]; + result = result.with_meta(Some(ui_resource_meta("ui://autovisualiser/chord"))); - // Replace all placeholders with actual content - let html_content = TEMPLATE - .replace("{{D3_MIN}}", D3_MIN) - .replace("{{CHORD_DATA}}", &data_json); - - // Save to /tmp/chord.html for debugging - let debug_path = std::path::Path::new("/tmp/chord.html"); - if let Err(e) = std::fs::write(debug_path, &html_content) { - tracing::warn!("Failed to write debug HTML to /tmp/chord.html: {}", e); - } else { - tracing::info!("Debug HTML saved to /tmp/chord.html"); - } - - // Use BlobResourceContents with base64 encoding to avoid JSON string escaping issues - let html_bytes = html_content.as_bytes(); - let base64_encoded = STANDARD.encode(html_bytes); - - let resource_contents = ResourceContents::BlobResourceContents { - uri: "ui://chord/diagram".to_string(), - mime_type: Some("text/html".to_string()), - blob: base64_encoded, - meta: None, - }; - - Ok(CallToolResult::success(vec![Content::resource( - resource_contents, - ) - .with_audience(vec![Role::User])])) + Ok(result) } /// show an interactive map visualization with location markers @@ -902,14 +1199,17 @@ Example: {"lat": 37.7749, "lng": -122.4194, "name": "SF Store", "value": 150000}, {"lat": 40.7128, "lng": -74.0060, "name": "NYC Store", "value": 200000} ] -}"# +}"#, + meta = ui_resource_meta("ui://autovisualiser/map") )] pub async fn render_map( &self, params: Parameters, ) -> Result { + let inner = params.0; + inner.data.validate()?; let data = validate_data_param( - &serde_json::to_value(params.0).map_err(|e| { + &serde_json::to_value(inner).map_err(|e| { ErrorData::new( ErrorCode::INVALID_PARAMS, format!("Invalid parameters: {}", e), @@ -919,64 +1219,22 @@ Example: false, )?; - // Extract title and subtitle from data if provided let title = data .get("title") .and_then(|v| v.as_str()) .unwrap_or("Interactive Map"); - let subtitle = data - .get("subtitle") - .and_then(|v| v.as_str()) - .unwrap_or("Geographic data visualization"); + let marker_count = data + .get("markers") + .and_then(|v| v.as_array()) + .map(|a| a.len()) + .unwrap_or(0); + let text_fallback = format!("map: \"{}\" with {} marker(s)", title, marker_count); - // Convert the data to JSON string - let data_json = serde_json::to_string(&data).map_err(|e| { - ErrorData::new( - ErrorCode::INVALID_PARAMS, - format!("Invalid JSON data: {}", e), - None, - ) - })?; + let mut result = CallToolResult::structured(data); + result.content = vec![Content::text(text_fallback)]; + result = result.with_meta(Some(ui_resource_meta("ui://autovisualiser/map"))); - // Load all resources at compile time using include_str! - const TEMPLATE: &str = include_str!("templates/map_template.html"); - const LEAFLET_JS: &str = include_str!("templates/assets/leaflet.min.js"); - const LEAFLET_CSS: &str = include_str!("templates/assets/leaflet.min.css"); - const MARKERCLUSTER_JS: &str = - include_str!("templates/assets/leaflet.markercluster.min.js"); - - // Replace all placeholders with actual content - let html_content = TEMPLATE - .replace("{{LEAFLET_JS}}", LEAFLET_JS) - .replace("{{LEAFLET_CSS}}", LEAFLET_CSS) - .replace("{{MARKERCLUSTER_JS}}", MARKERCLUSTER_JS) - .replace("{{MAP_DATA}}", &data_json) - .replace("{{TITLE}}", title) - .replace("{{SUBTITLE}}", subtitle); - - // Save to /tmp/map.html for debugging - let debug_path = std::path::Path::new("/tmp/map.html"); - if let Err(e) = std::fs::write(debug_path, &html_content) { - tracing::warn!("Failed to write debug HTML to /tmp/map.html: {}", e); - } else { - tracing::info!("Debug HTML saved to /tmp/map.html"); - } - - // Use BlobResourceContents with base64 encoding to avoid JSON string escaping issues - let html_bytes = html_content.as_bytes(); - let base64_encoded = STANDARD.encode(html_bytes); - - let resource_contents = ResourceContents::BlobResourceContents { - uri: "ui://map/visualization".to_string(), - mime_type: Some("text/html".to_string()), - blob: base64_encoded, - meta: None, - }; - - Ok(CallToolResult::success(vec![Content::resource( - resource_contents, - ) - .with_audience(vec![Role::User])])) + Ok(result) } /// show a Mermaid diagram from Mermaid syntax @@ -992,46 +1250,31 @@ graph TD; A-->C; B-->D; C-->D; -"# +"#, + meta = ui_resource_meta("ui://autovisualiser/mermaid") )] pub async fn render_mermaid( &self, params: Parameters, ) -> Result { - let mermaid_code = params.0.mermaid_code; + let mermaid_code = ¶ms.0.mermaid_code; - // Load all resources at compile time using include_str! - const TEMPLATE: &str = include_str!("templates/mermaid_template.html"); - const MERMAID_MIN: &str = include_str!("templates/assets/mermaid.min.js"); + let first_line = mermaid_code.lines().next().unwrap_or("diagram").trim(); + let text_fallback = format!("mermaid diagram: {}", first_line); - // Replace all placeholders with actual content - let html_content = TEMPLATE - .replace("{{MERMAID_MIN}}", MERMAID_MIN) - .replace("{{MERMAID_CODE}}", &mermaid_code); + let data = serde_json::to_value(¶ms.0).map_err(|e| { + ErrorData::new( + ErrorCode::INVALID_PARAMS, + format!("Invalid parameters: {}", e), + None, + ) + })?; - // Save to /tmp/mermaid.html for debugging - let debug_path = std::path::Path::new("/tmp/mermaid.html"); - if let Err(e) = std::fs::write(debug_path, &html_content) { - tracing::warn!("Failed to write debug HTML to /tmp/mermaid.html: {}", e); - } else { - tracing::info!("Debug HTML saved to /tmp/mermaid.html"); - } + let mut result = CallToolResult::structured(data); + result.content = vec![Content::text(text_fallback)]; + result = result.with_meta(Some(ui_resource_meta("ui://autovisualiser/mermaid"))); - // Use BlobResourceContents with base64 encoding to avoid JSON string escaping issues - let html_bytes = html_content.as_bytes(); - let base64_encoded = STANDARD.encode(html_bytes); - - let resource_contents = ResourceContents::BlobResourceContents { - uri: "ui://mermaid/diagram".to_string(), - mime_type: Some("text/html".to_string()), - blob: base64_encoded, - meta: None, - }; - - Ok(CallToolResult::success(vec![Content::resource( - resource_contents, - ) - .with_audience(vec![Role::User])])) + Ok(result) } /// show interactive line, scatter, or bar charts @@ -1050,14 +1293,17 @@ Example: "datasets": [ {"label": "Product A", "data": [65, 59, 80]} ] -}"# +}"#, + meta = ui_resource_meta("ui://autovisualiser/chart") )] pub async fn show_chart( &self, params: Parameters, ) -> Result { + let inner = params.0; + inner.data.validate()?; let data = validate_data_param( - &serde_json::to_value(params.0).map_err(|e| { + &serde_json::to_value(inner).map_err(|e| { ErrorData::new( ErrorCode::INVALID_PARAMS, format!("Invalid parameters: {}", e), @@ -1067,47 +1313,30 @@ Example: false, )?; - // Convert the data to JSON string - let data_json = serde_json::to_string(&data).map_err(|e| { - ErrorData::new( - ErrorCode::INVALID_PARAMS, - format!("Invalid JSON data: {}", e), - None, - ) - })?; + // Build a text fallback describing the chart for non-UI hosts + let chart_type = data.get("type").and_then(|v| v.as_str()).unwrap_or("chart"); + let title = data + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("Untitled"); + let dataset_count = data + .get("datasets") + .and_then(|v| v.as_array()) + .map(|a| a.len()) + .unwrap_or(0); + let text_fallback = format!( + "{} chart: \"{}\" with {} dataset(s)", + chart_type, title, dataset_count + ); - // Load all resources at compile time using include_str! - const TEMPLATE: &str = include_str!("templates/chart_template.html"); - const CHART_MIN: &str = include_str!("templates/assets/chart.min.js"); + // Return structuredContent (the raw data) + text fallback. + // The host fetches the template via read_resource and sends this data + // to the template via the MCP Apps postMessage lifecycle. + let mut result = CallToolResult::structured(data); + result.content = vec![Content::text(text_fallback)]; + result = result.with_meta(Some(ui_resource_meta("ui://autovisualiser/chart"))); - // Replace all placeholders with actual content - let html_content = TEMPLATE - .replace("{{CHART_MIN}}", CHART_MIN) - .replace("{{CHART_DATA}}", &data_json); - - // Save to /tmp/chart.html for debugging - let debug_path = std::path::Path::new("/tmp/chart.html"); - if let Err(e) = std::fs::write(debug_path, &html_content) { - tracing::warn!("Failed to write debug HTML to /tmp/chart.html: {}", e); - } else { - tracing::info!("Debug HTML saved to /tmp/chart.html"); - } - - // Use BlobResourceContents with base64 encoding to avoid JSON string escaping issues - let html_bytes = html_content.as_bytes(); - let base64_encoded = STANDARD.encode(html_bytes); - - let resource_contents = ResourceContents::BlobResourceContents { - uri: "ui://chart/interactive".to_string(), - mime_type: Some("text/html".to_string()), - blob: base64_encoded, - meta: None, - }; - - Ok(CallToolResult::success(vec![Content::resource( - resource_contents, - ) - .with_audience(vec![Role::User])])) + Ok(result) } } @@ -1252,6 +1481,37 @@ mod tests { assert!(err.message.contains("without comments")); } + fn assert_mcp_apps_result( + tool_result: &CallToolResult, + expected_uri: &str, + text_contains: &str, + ) { + assert_eq!(tool_result.content.len(), 1); + if let RawContent::Text(text_content) = &tool_result.content[0].raw { + assert!( + text_content.text.contains(text_contains), + "Text fallback '{}' should contain '{}'", + text_content.text, + text_contains + ); + } else { + panic!("Expected text content as fallback"); + } + + assert!( + tool_result.structured_content.is_some(), + "structured_content should be present" + ); + + assert!(tool_result.meta.is_some(), "_meta should be present"); + let meta = tool_result.meta.as_ref().unwrap(); + let ui = meta.0.get("ui").expect("meta should have 'ui' key"); + assert_eq!( + ui.get("resourceUri").and_then(|v| v.as_str()), + Some(expected_uri) + ); + } + #[tokio::test] async fn test_render_sankey() { let router = AutoVisualiserRouter::new(); @@ -1277,30 +1537,7 @@ mod tests { let result = router.render_sankey(params).await; assert!(result.is_ok()); - let tool_result = result.unwrap(); - assert_eq!(tool_result.content.len(), 1); - - // Check the audience is set to User - assert!(tool_result.content[0].audience().is_some()); - assert_eq!( - tool_result.content[0].audience().unwrap(), - &vec![Role::User] - ); - - // Check it's a resource with HTML content - // Content is Annotated, access underlying RawContent via * - if let RawContent::Resource(resource) = &*tool_result.content[0] { - if let ResourceContents::BlobResourceContents { uri, mime_type, .. } = - &resource.resource - { - assert_eq!(uri, "ui://sankey/diagram"); - assert_eq!(mime_type.as_ref().unwrap(), "text/html"); - } else { - panic!("Expected BlobResourceContents"); - } - } else { - panic!("Expected Resource content"); - } + assert_mcp_apps_result(&result.unwrap(), "ui://autovisualiser/sankey", "sankey"); } #[tokio::test] @@ -1322,66 +1559,28 @@ mod tests { let result = router.render_radar(params).await; assert!(result.is_ok()); - let tool_result = result.unwrap(); - assert_eq!(tool_result.content.len(), 1); - - // Check the audience is set to User - assert!(tool_result.content[0].audience().is_some()); - assert_eq!( - tool_result.content[0].audience().unwrap(), - &vec![Role::User] - ); - - // Check it's a resource with HTML content - // Content is Annotated, access underlying RawContent via * - if let RawContent::Resource(resource) = &*tool_result.content[0] { - if let ResourceContents::BlobResourceContents { - uri, - mime_type, - blob, - .. - } = &resource.resource - { - assert_eq!(uri, "ui://radar/chart"); - assert_eq!(mime_type.as_ref().unwrap(), "text/html"); - assert!(!blob.is_empty(), "HTML content should not be empty"); - } else { - panic!("Expected BlobResourceContents"); - } - } else { - panic!("Expected Resource content"); - } + assert_mcp_apps_result(&result.unwrap(), "ui://autovisualiser/radar", "radar"); } #[tokio::test] async fn test_render_donut() { let router = AutoVisualiserRouter::new(); let params = Parameters(RenderDonutParams { - data: DonutData { - data: DonutChartData::Single(SingleDonutChart { - data: vec![ - DonutDataItem::Number(30.0), - DonutDataItem::Number(40.0), - DonutDataItem::Number(30.0), - ], - labels: Some(vec!["A".to_string(), "B".to_string(), "C".to_string()]), - title: None, - chart_type: None, - }), - }, + data: DonutChartData::Single(SingleDonutChart { + values: vec![ + DonutDataItem::Number(30.0), + DonutDataItem::Number(40.0), + DonutDataItem::Number(30.0), + ], + labels: Some(vec!["A".to_string(), "B".to_string(), "C".to_string()]), + title: None, + chart_type: None, + }), }); let result = router.render_donut(params).await; assert!(result.is_ok()); - let tool_result = result.unwrap(); - assert_eq!(tool_result.content.len(), 1); - - // Check the audience is set to User - assert!(tool_result.content[0].audience().is_some()); - assert_eq!( - tool_result.content[0].audience().unwrap(), - &vec![Role::User] - ); + assert_mcp_apps_result(&result.unwrap(), "ui://autovisualiser/donut", "donut"); } #[tokio::test] @@ -1411,15 +1610,7 @@ mod tests { let result = router.render_treemap(params).await; assert!(result.is_ok()); - let tool_result = result.unwrap(); - assert_eq!(tool_result.content.len(), 1); - - // Check the audience is set to User - assert!(tool_result.content[0].audience().is_some()); - assert_eq!( - tool_result.content[0].audience().unwrap(), - &vec![Role::User] - ); + assert_mcp_apps_result(&result.unwrap(), "ui://autovisualiser/treemap", "treemap"); } #[tokio::test] @@ -1438,15 +1629,7 @@ mod tests { let result = router.render_chord(params).await; assert!(result.is_ok()); - let tool_result = result.unwrap(); - assert_eq!(tool_result.content.len(), 1); - - // Check the audience is set to User - assert!(tool_result.content[0].audience().is_some()); - assert_eq!( - tool_result.content[0].audience().unwrap(), - &vec![Role::User] - ); + assert_mcp_apps_result(&result.unwrap(), "ui://autovisualiser/chord", "chord"); } #[tokio::test] @@ -1477,15 +1660,7 @@ mod tests { let result = router.render_map(params).await; assert!(result.is_ok()); - let tool_result = result.unwrap(); - assert_eq!(tool_result.content.len(), 1); - - // Check the audience is set to User - assert!(tool_result.content[0].audience().is_some()); - assert_eq!( - tool_result.content[0].audience().unwrap(), - &vec![Role::User] - ); + assert_mcp_apps_result(&result.unwrap(), "ui://autovisualiser/map", "map"); } #[tokio::test] @@ -1515,46 +1690,404 @@ mod tests { }); let result = router.show_chart(params).await; - if let Err(e) = &result { - eprintln!("Error in test_show_chart: {:?}", e); - } assert!(result.is_ok()); - let tool_result = result.unwrap(); - assert_eq!(tool_result.content.len(), 1); - - // Check the audience is set to User - assert!(tool_result.content[0].audience().is_some()); - assert_eq!( - tool_result.content[0].audience().unwrap(), - &vec![Role::User] - ); + assert_mcp_apps_result(&result.unwrap(), "ui://autovisualiser/chart", "scatter"); } #[tokio::test] async fn test_render_mermaid() { let router = AutoVisualiserRouter::new(); let params = Parameters(RenderMermaidParams { - mermaid_code: r#"graph TD; - A-->B; - A-->C; - B-->D; - C-->D;"# - .to_string(), + mermaid_code: "graph TD;\n A-->B;\n A-->C;\n B-->D;\n C-->D;".to_string(), }); let result = router.render_mermaid(params).await; - if let Err(e) = &result { - eprintln!("Error in test_render_mermaid: {:?}", e); - } assert!(result.is_ok()); - let tool_result = result.unwrap(); - assert_eq!(tool_result.content.len(), 1); + assert_mcp_apps_result(&result.unwrap(), "ui://autovisualiser/mermaid", "mermaid"); + } +} - // Check the audience is set to User - assert!(tool_result.content[0].audience().is_some()); - assert_eq!( - tool_result.content[0].audience().unwrap(), - &vec![Role::User] +#[cfg(test)] +mod donut_format_tests { + use super::*; + use serde_json::json; + + fn round_trip(input: serde_json::Value) -> Result { + let parsed: RenderDonutParams = serde_json::from_value(input).map_err(|e| e.to_string())?; + let serialized = serde_json::to_value(&parsed).map_err(|e| e.to_string())?; + // Simulate validate_data_param extracting "data" + Ok(serialized.get("data").cloned().unwrap_or(serialized)) + } + + #[test] + fn labeled_values_single_chart() { + // {"data": {"values": [{"label": "A", "value": 10}, ...]}} + let input = json!({ + "data": { + "values": [ + {"label": "A", "value": 10}, + {"label": "B", "value": 20} + ] + } + }); + let result = round_trip(input); + assert!( + result.is_ok(), + "labeled values should parse: {:?}", + result.err() + ); + } + + #[test] + fn parallel_arrays_single_chart() { + // {"data": {"values": [10, 20], "labels": ["A", "B"]}} + let input = json!({ + "data": { + "values": [10, 20], + "labels": ["A", "B"] + } + }); + let result = round_trip(input); + assert!( + result.is_ok(), + "parallel arrays should parse: {:?}", + result.err() + ); + } + + #[test] + fn multiple_charts_array() { + // {"data": [{"values": [10, 20], "labels": ["A", "B"], "title": "Q1"}, ...]} + let input = json!({ + "data": [ + {"values": [60, 40], "labels": ["Yes", "No"], "title": "Q1"}, + {"values": [75, 25], "labels": ["Yes", "No"], "title": "Q2"} + ] + }); + let result = round_trip(input); + assert!( + result.is_ok(), + "multiple charts should parse: {:?}", + result.err() + ); + } + + #[test] + fn labeled_values_with_title_and_type() { + let input = json!({ + "data": { + "values": [ + {"label": "Marketing", "value": 25000}, + {"label": "Development", "value": 35000} + ], + "title": "Budget", + "type": "pie" + } + }); + let result = round_trip(input); + assert!( + result.is_ok(), + "labeled values with title/type should parse: {:?}", + result.err() ); } } + +#[cfg(test)] +mod donut_regression_tests { + use super::*; + use serde_json::json; + + #[test] + fn old_format_1_label_value_as_data_rejects() { + // Old broken format: {"data": [{"label": "A", "value": 10}]} + // Items have no "values" field, so this should NOT parse as Multiple + let input = json!({ + "data": [{"label": "A", "value": 10}, {"label": "B", "value": 20}] + }); + let parsed: Result = serde_json::from_value(input); + assert!( + parsed.is_err(), + "bare label/value items without 'values' wrapper should not parse" + ); + } + + #[test] + fn old_format_3_array_wrapped_with_data_key_still_works() { + // Old working format used "data" key inside chart objects. + // Template now reads c.values || c.data, so if an LLM sends the old shape + // the Rust types would reject it (items need "values" not "data"). + // This verifies the schema enforces the new shape. + let input = json!({ + "data": [{"labels": ["A", "B"], "data": [10, 20]}] + }); + let parsed: Result = serde_json::from_value(input); + assert!( + parsed.is_err(), + "old 'data' key inside chart objects should not parse — schema now requires 'values'" + ); + } +} + +#[cfg(test)] +mod validation_tests { + use super::*; + + #[test] + fn sankey_rejects_empty_nodes() { + let data = SankeyData { + nodes: vec![], + links: vec![SankeyLink { + source: "A".into(), + target: "B".into(), + value: 10.0, + }], + }; + assert!(data.validate().is_err()); + } + + #[test] + fn sankey_rejects_unknown_source() { + let data = SankeyData { + nodes: vec![SankeyNode { + name: "A".into(), + category: None, + }], + links: vec![SankeyLink { + source: "X".into(), + target: "A".into(), + value: 10.0, + }], + }; + let err = data.validate().unwrap_err(); + assert!(err.message.contains("'X' not found")); + } + + #[test] + fn sankey_rejects_non_positive_value() { + let data = SankeyData { + nodes: vec![ + SankeyNode { + name: "A".into(), + category: None, + }, + SankeyNode { + name: "B".into(), + category: None, + }, + ], + links: vec![SankeyLink { + source: "A".into(), + target: "B".into(), + value: 0.0, + }], + }; + assert!(data.validate().is_err()); + } + + #[test] + fn radar_rejects_mismatched_dimensions() { + let data = RadarData { + labels: vec!["A".into(), "B".into(), "C".into()], + datasets: vec![RadarDataset { + label: "Test".into(), + data: vec![1.0, 2.0], // 2 values but 3 labels + }], + }; + let err = data.validate().unwrap_err(); + assert!(err.message.contains("2 values")); + assert!(err.message.contains("3 labels")); + } + + #[test] + fn radar_rejects_empty_datasets() { + let data = RadarData { + labels: vec!["A".into()], + datasets: vec![], + }; + assert!(data.validate().is_err()); + } + + #[test] + fn donut_rejects_empty_values() { + let data = DonutChartData::Single(SingleDonutChart { + values: vec![], + title: None, + chart_type: None, + labels: None, + }); + assert!(data.validate().is_err()); + } + + #[test] + fn donut_rejects_mismatched_labels() { + let data = DonutChartData::Single(SingleDonutChart { + values: vec![DonutDataItem::Number(10.0), DonutDataItem::Number(20.0)], + title: None, + chart_type: None, + labels: Some(vec!["A".into()]), // 1 label but 2 values + }); + let err = data.validate().unwrap_err(); + assert!(err.message.contains("labels")); + } + + #[test] + fn donut_rejects_empty_multiple() { + let data = DonutChartData::Multiple(vec![]); + assert!(data.validate().is_err()); + } + + #[test] + fn chord_rejects_mismatched_matrix() { + let data = ChordData { + labels: vec!["A".into(), "B".into()], + matrix: vec![vec![0.0, 1.0]], // 1 row but 2 labels + }; + let err = data.validate().unwrap_err(); + assert!(err.message.contains("1 rows")); + assert!(err.message.contains("2 labels")); + } + + #[test] + fn chord_rejects_ragged_matrix() { + let data = ChordData { + labels: vec!["A".into(), "B".into()], + matrix: vec![vec![0.0, 1.0], vec![1.0]], // row 1 has 1 col instead of 2 + }; + let err = data.validate().unwrap_err(); + assert!(err.message.contains("row 1")); + } + + #[test] + fn treemap_rejects_empty_node() { + let data = TreemapNode { + name: "Root".into(), + value: None, + category: None, + children: None, + }; + assert!(data.validate().is_err()); + } + + #[test] + fn treemap_validates_recursively() { + let data = TreemapNode { + name: "Root".into(), + value: None, + category: None, + children: Some(vec![TreemapNode { + name: "Empty child".into(), + value: None, + category: None, + children: Some(vec![]), + }]), + }; + let err = data.validate().unwrap_err(); + assert!(err.message.contains("Empty child")); + } + + #[test] + fn chart_rejects_empty_datasets() { + let data = ChartData { + chart_type: ChartType::Line, + datasets: vec![], + labels: None, + title: None, + subtitle: None, + x_axis_label: None, + y_axis_label: None, + }; + assert!(data.validate().is_err()); + } + + #[test] + fn chart_rejects_mismatched_labels() { + let data = ChartData { + chart_type: ChartType::Bar, + datasets: vec![ChartDataset { + label: "Test".into(), + data: ChartDataValues::Numbers(vec![1.0, 2.0]), + background_color: None, + border_color: None, + border_width: None, + tension: None, + fill: None, + }], + labels: Some(vec!["A".into(), "B".into(), "C".into()]), // 3 labels but 2 values + title: None, + subtitle: None, + x_axis_label: None, + y_axis_label: None, + }; + let err = data.validate().unwrap_err(); + assert!(err.message.contains("2 values")); + assert!(err.message.contains("3 labels")); + } + + #[test] + fn map_rejects_empty_markers() { + let data = MapData { + markers: vec![], + title: None, + subtitle: None, + center: None, + zoom: None, + clustering: None, + cluster_radius: None, + auto_fit: None, + }; + assert!(data.validate().is_err()); + } + + #[test] + fn map_rejects_invalid_lat() { + let data = MapData { + markers: vec![MapMarker { + lat: 91.0, + lng: 0.0, + name: None, + value: None, + description: None, + popup: None, + color: None, + label: None, + use_default_icon: None, + }], + title: None, + subtitle: None, + center: None, + zoom: None, + clustering: None, + cluster_radius: None, + auto_fit: None, + }; + let err = data.validate().unwrap_err(); + assert!(err.message.contains("latitude")); + } + + #[test] + fn map_rejects_invalid_lng() { + let data = MapData { + markers: vec![MapMarker { + lat: 0.0, + lng: 200.0, + name: None, + value: None, + description: None, + popup: None, + color: None, + label: None, + use_default_icon: None, + }], + title: None, + subtitle: None, + center: None, + zoom: None, + clustering: None, + cluster_radius: None, + auto_fit: None, + }; + let err = data.validate().unwrap_err(); + assert!(err.message.contains("longitude")); + } +} diff --git a/crates/goose-mcp/src/autovisualiser/templates/assets/mcp-app-base.css b/crates/goose-mcp/src/autovisualiser/templates/assets/mcp-app-base.css new file mode 100644 index 0000000000..02ba42d754 --- /dev/null +++ b/crates/goose-mcp/src/autovisualiser/templates/assets/mcp-app-base.css @@ -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); +} diff --git a/crates/goose-mcp/src/autovisualiser/templates/assets/mcp-app-bridge.js b/crates/goose-mcp/src/autovisualiser/templates/assets/mcp-app-bridge.js new file mode 100644 index 0000000000..efbf7c184a --- /dev/null +++ b/crates/goose-mcp/src/autovisualiser/templates/assets/mcp-app-bridge.js @@ -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; + }, + }; +})(); diff --git a/crates/goose-mcp/src/autovisualiser/templates/chart_template.html b/crates/goose-mcp/src/autovisualiser/templates/chart_template.html index 1bccd843ce..c902638793 100644 --- a/crates/goose-mcp/src/autovisualiser/templates/chart_template.html +++ b/crates/goose-mcp/src/autovisualiser/templates/chart_template.html @@ -3,332 +3,300 @@ - Interactive Chart + Chart - - + + + -
-
-

📊 Chart Visualization

-

Interactive data visualization

+
Waiting for data…
+