/** * Data Visualizer MCP App * * Interactive visualization for ontology function results. * Supports tables and charts (via Recharts) with dual Y-axis support. */ import type { McpUiHostContext } from "@modelcontextprotocol/ext-apps"; import { useApp, useHostStyles } from "@modelcontextprotocol/ext-apps/react"; import React, { useState, useEffect, useRef, useMemo, useCallback } from "react"; import { createRoot } from "react-dom/client"; import { BarChart3, Table2, Download, Settings } from "lucide-react"; import { DataTable } from "./components/DataTable"; import { DataChart } from "./components/DataChart"; import { SettingsSidebar } from "./components/SettingsSidebar"; import { downloadCSV } from "./utils/csv"; import "./styles.css"; type ViewType = "chart" | "table"; type ChartType = "bar" | "line"; interface UiConfig { type?: "table" | "chart" | "auto"; chartType?: "line" | "bar"; xAxis?: string; leftYAxis?: string | string[]; rightYAxis?: string | string[]; yAxis?: string; // deprecated } const APP_INFO = { name: "ont-visualizer", version: "1.0.0" }; /** * Detect the best visualization type based on data structure. */ function detectVisualizationType( data: unknown, config?: UiConfig | null ): "table" | "chart" { // If config explicitly specifies a type (not "auto"), use it if (config?.type && config.type !== "auto") { return config.type === "chart" ? "chart" : "table"; } if (!data) return "table"; if (Array.isArray(data)) { if (data.length === 0) return "table"; const first = data[0]; if (typeof first !== "object" || first === null) return "table"; // Check if it's tabular data with numeric values const keys = Object.keys(first); const hasNumericValues = keys.some( (k) => typeof (first as Record)[k] === "number" ); if (hasNumericValues && data.length > 1) { return "chart"; } return "table"; } return "table"; } /** * Normalize Y-axis config to array format */ function normalizeYAxis(value: string | string[] | undefined): string[] { if (!value) return []; return Array.isArray(value) ? value : [value]; } function VisualizerApp() { const [hostContext, setHostContext] = useState(); const [data, setData] = useState(null); const [config, setConfig] = useState(null); const [view, setView] = useState("chart"); const [sidebarOpen, setSidebarOpen] = useState(true); const [error, setError] = useState(null); const containerRef = useRef(null); // Chart state const [chartType, setChartType] = useState("bar"); const [xAxis, setXAxis] = useState(""); const [leftYAxes, setLeftYAxes] = useState([]); const [rightYAxes, setRightYAxes] = useState([]); const { app, isConnected, error: connectionError } = useApp({ appInfo: APP_INFO, capabilities: {}, onAppCreated: (app) => { // Handle tool input - receive UI config app.ontoolinput = (params) => { console.log("[ont-visualizer] Tool input received:", params); if (params.arguments?.ui) { setConfig(params.arguments.ui as UiConfig); } }; // Handle tool result - receive data to visualize app.ontoolresult = (result) => { console.log("[ont-visualizer] Tool result received:", result); try { // Prefer structuredContent if available if (result.structuredContent) { const content = result.structuredContent as Record; if (content._uiConfig) { setConfig(content._uiConfig as UiConfig); } const { _uiConfig, ...rest } = content; const actualData = rest.data !== undefined ? rest.data : rest; setData(actualData); setError(null); return; } // Fallback: extract text content const textContent = result.content?.find( (c): c is { type: "text"; text: string } => c.type === "text" ); if (textContent) { const parsed = JSON.parse(textContent.text); setData(parsed); setError(null); } } catch (e) { setError( `Failed to parse result: ${e instanceof Error ? e.message : "Unknown error"}` ); } }; app.ontoolcancelled = (params) => { console.log("[ont-visualizer] Tool cancelled:", params.reason); setError(`Tool cancelled: ${params.reason || "Unknown reason"}`); }; app.onhostcontextchanged = (params) => { setHostContext((prev) => ({ ...prev, ...params })); }; app.onerror = (err) => { console.error("[ont-visualizer] Error:", err); setError(err.message); }; }, }); // Extract field information from data const { stringKeys, numericKeys, allKeys } = useMemo(() => { if (!Array.isArray(data) || data.length === 0) { return { stringKeys: [], numericKeys: [], allKeys: [] }; } const first = data[0]; if (typeof first !== "object" || first === null) { return { stringKeys: [], numericKeys: [], allKeys: [] }; } const keys = Object.keys(first); const stringKeys = keys.filter( (k) => typeof (first as Record)[k] === "string" ); const numericKeys = keys.filter( (k) => typeof (first as Record)[k] === "number" ); return { stringKeys, numericKeys, allKeys: keys }; }, [data]); // Available fields (numeric fields not assigned to either axis) const availableFields = useMemo(() => { return numericKeys.filter( (k) => !leftYAxes.includes(k) && !rightYAxes.includes(k) ); }, [numericKeys, leftYAxes, rightYAxes]); // Initialize state from config when data loads useEffect(() => { if (!data || allKeys.length === 0) return; // Set chart type if (config?.chartType && (config.chartType === "bar" || config.chartType === "line")) { setChartType(config.chartType); } // Set x-axis if (config?.xAxis && allKeys.includes(config.xAxis)) { setXAxis(config.xAxis); } else if (stringKeys[0]) { setXAxis(stringKeys[0]); } else if (allKeys[0]) { setXAxis(allKeys[0]); } // Set Y-axes from config (with backwards compatibility for yAxis) const configLeftY = normalizeYAxis(config?.leftYAxis || config?.yAxis); const configRightY = normalizeYAxis(config?.rightYAxis); // Filter to only include fields that exist and are numeric const validLeftY = configLeftY.filter((k) => numericKeys.includes(k)); const validRightY = configRightY.filter((k) => numericKeys.includes(k)); if (validLeftY.length > 0 || validRightY.length > 0) { setLeftYAxes(validLeftY); setRightYAxes(validRightY); } else { // Default: put all numeric fields on left axis setLeftYAxes(numericKeys); setRightYAxes([]); } // Set view type const detectedView = detectVisualizationType(data, config); setView(detectedView); }, [data, config, allKeys, stringKeys, numericKeys]); // Get initial host context after connection useEffect(() => { if (app) { setHostContext(app.getHostContext()); } }, [app]); // Apply host styles useHostStyles(app, hostContext); // Apply safe area insets useEffect(() => { if (hostContext?.safeAreaInsets && containerRef.current) { const { top, right, bottom, left } = hostContext.safeAreaInsets; containerRef.current.style.paddingTop = `${top}px`; containerRef.current.style.paddingRight = `${right}px`; containerRef.current.style.paddingBottom = `${bottom}px`; containerRef.current.style.paddingLeft = `${left}px`; } }, [hostContext?.safeAreaInsets]); // Handle field drag and drop const handleFieldMove = useCallback( (field: string, target: "left" | "right" | "available") => { // Remove from current location setLeftYAxes((prev) => prev.filter((f) => f !== field)); setRightYAxes((prev) => prev.filter((f) => f !== field)); // Add to new location if (target === "left") { setLeftYAxes((prev) => [...prev, field]); } else if (target === "right") { setRightYAxes((prev) => [...prev, field]); } // "available" just removes from axes, which we already did }, [] ); // Handle CSV download const handleDownloadCSV = useCallback(() => { if (Array.isArray(data)) { downloadCSV(data, "data.csv"); } }, [data]); // Show connection error if (connectionError) { return (
Connection error: {connectionError.message}
); } // Show connecting state if (!isConnected) { return (
Connecting...
); } // Show error state if (error) { return (
{error}
); } // Show waiting state if (!data) { return (
Waiting for data...
); } return (
{/* Header */}
{view === "chart" && ( )}
{/* Main Content */}
{view === "table" && } {view === "chart" && ( )}
{/* Settings Sidebar (chart view only) */} {view === "chart" && ( setSidebarOpen(!sidebarOpen)} chartType={chartType} onChartTypeChange={setChartType} xAxis={xAxis} onXAxisChange={setXAxis} leftYAxes={leftYAxes} rightYAxes={rightYAxes} availableFields={availableFields} allFields={allKeys} numericFields={numericKeys} onFieldMove={handleFieldMove} /> )}
); } // Render the app const container = document.getElementById("root"); if (container) { const root = createRoot(container); root.render(); }