diff --git a/packages/mcp-apps-ui/package.json b/packages/mcp-apps-ui/package.json new file mode 100644 index 00000000..57aeb936 --- /dev/null +++ b/packages/mcp-apps-ui/package.json @@ -0,0 +1,30 @@ +{ + "name": "@sentry/mcp-apps-ui", + "version": "0.29.0", + "private": true, + "type": "module", + "license": "FSL-1.1-ALv2", + "description": "MCP Apps UI components for Sentry MCP - Interactive chart visualizations", + "files": ["./dist/*"], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsdown && vite build && tsx scripts/bundle-apps.ts", + "dev": "vite build --watch", + "tsc": "tsc --noEmit" + }, + "devDependencies": { + "@sentry/mcp-server-tsconfig": "workspace:*", + "tsdown": "catalog:", + "vite": "catalog:", + "vite-plugin-singlefile": "^2.0.3" + }, + "dependencies": { + "@modelcontextprotocol/ext-apps": "^0.4.0", + "chart.js": "^4.4.7" + } +} diff --git a/packages/mcp-apps-ui/scripts/bundle-apps.ts b/packages/mcp-apps-ui/scripts/bundle-apps.ts new file mode 100644 index 00000000..be143e69 --- /dev/null +++ b/packages/mcp-apps-ui/scripts/bundle-apps.ts @@ -0,0 +1,71 @@ +/** + * Post-build script that reads bundled HTML apps and generates + * TypeScript/JavaScript exports for consumption by other packages. + */ + +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { join, dirname } from "node:path"; + +const DIST_DIR = join(dirname(new URL(import.meta.url).pathname), "..", "dist"); +const APPS_DIR = join(DIST_DIR, "apps"); + +// Map of app names to their bundled HTML file +// Vite outputs HTML files in nested directories preserving the source structure +const APPS = { + searchEventsChart: "src/apps/search-events-chart/index.html", +}; + +function main() { + // Ensure dist directory exists + if (!existsSync(DIST_DIR)) { + mkdirSync(DIST_DIR, { recursive: true }); + } + + // Read bundled HTML files and generate exports + const exports: Record = {}; + + for (const [exportName, fileName] of Object.entries(APPS)) { + const htmlPath = join(APPS_DIR, fileName); + + if (!existsSync(htmlPath)) { + console.error(`Warning: ${htmlPath} not found. Skipping.`); + continue; + } + + const htmlContent = readFileSync(htmlPath, "utf-8"); + exports[exportName] = htmlContent; + console.log(`Bundled ${fileName} -> ${exportName}Html`); + } + + // Generate JavaScript module + const jsContent = `// Auto-generated by bundle-apps.ts - do not edit manually +${Object.entries(exports) + .map( + ([name, content]) => + `export const ${name}Html = ${JSON.stringify(content)};`, + ) + .join("\n")} + +// Re-export shared types and utilities +export { inferChartType } from "./shared/chart-data.js"; +`; + + writeFileSync(join(DIST_DIR, "index.js"), jsContent); + console.log("Generated dist/index.js"); + + // Generate TypeScript declarations + const dtsContent = `// Auto-generated by bundle-apps.ts - do not edit manually +${Object.keys(exports) + .map((name) => `export declare const ${name}Html: string;`) + .join("\n")} + +// Re-export shared types +export type { ChartData, ChartType } from "./shared/chart-data.js"; +export { inferChartType } from "./shared/chart-data.js"; +`; + + writeFileSync(join(DIST_DIR, "index.d.ts"), dtsContent); + console.log("Generated dist/index.d.ts"); +} + +main(); diff --git a/packages/mcp-apps-ui/src/apps/search-events-chart/app.ts b/packages/mcp-apps-ui/src/apps/search-events-chart/app.ts new file mode 100644 index 00000000..62341a4a --- /dev/null +++ b/packages/mcp-apps-ui/src/apps/search-events-chart/app.ts @@ -0,0 +1,264 @@ +import { App } from "@modelcontextprotocol/ext-apps"; +import { Chart, registerables } from "chart.js"; +import { SENTRY_COLORS, CHART_DEFAULTS } from "../../shared/sentry-theme"; +import type { ChartData, ChartType } from "../../shared/chart-data"; +import { inferChartType } from "../../shared/chart-data"; + +// Register all Chart.js components +Chart.register(...registerables); + +// Apply global chart defaults +Chart.defaults.font.family = CHART_DEFAULTS.font.family; +Chart.defaults.font.size = CHART_DEFAULTS.font.size; +Chart.defaults.color = CHART_DEFAULTS.colors.text; + +const app = new App({ name: "Sentry Search Events Chart", version: "1.0.0" }); + +let currentChart: Chart | null = null; + +/** + * Parse chart data from tool result content + */ +function parseChartData(result: { + content?: Array<{ + type: string; + resource?: { mimeType?: string; text?: string }; + }>; +}): ChartData | null { + // Find the JSON resource containing chart data + const chartResource = result.content?.find( + (c) => + c.type === "resource" && + c.resource?.mimeType === "application/json;chart", + ); + + if (!chartResource?.resource?.text) { + return null; + } + + try { + return JSON.parse(chartResource.resource.text) as ChartData; + } catch { + console.error("Failed to parse chart data"); + return null; + } +} + +/** + * Format a number for display (with thousands separators) + */ +function formatNumber(value: unknown): string { + if (typeof value === "number") { + return value.toLocaleString(); + } + return String(value); +} + +/** + * Render a single number display + */ +function renderNumberDisplay(data: ChartData): void { + const content = document.getElementById("content"); + if (!content) return; + + const value = data.data[0]?.[data.values[0]]; + + content.innerHTML = ` +
+
${formatNumber(value)}
+
${data.values[0]}
+
+ `; +} + +/** + * Render a data table + */ +function renderTable(data: ChartData): void { + const content = document.getElementById("content"); + if (!content) return; + + const allColumns = [...data.labels, ...data.values]; + + let tableHtml = ` +
+ + + + ${allColumns.map((col) => ``).join("")} + + + + `; + + for (const row of data.data) { + tableHtml += ""; + for (const col of allColumns) { + const value = row[col]; + tableHtml += ``; + } + tableHtml += ""; + } + + tableHtml += ` + +
${col}
${formatNumber(value)}
+
+ `; + + content.innerHTML = tableHtml; +} + +/** + * Render a Chart.js chart + */ +function renderChart(data: ChartData, chartType: ChartType): void { + const content = document.getElementById("content"); + if (!content) return; + + // Clean up existing chart + if (currentChart) { + currentChart.destroy(); + currentChart = null; + } + + content.innerHTML = ` +
+ +
+ `; + + const canvas = document.getElementById("chart") as HTMLCanvasElement; + if (!canvas) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + // Extract labels (x-axis values) + const labelField = data.labels[0] || "label"; + const labels = data.data.map((d) => String(d[labelField] || "Unknown")); + + // Extract values (y-axis datasets) + const datasets = data.values.map((valueField, index) => ({ + label: valueField, + data: data.data.map((d) => { + const val = d[valueField]; + return typeof val === "number" ? val : 0; + }), + backgroundColor: + chartType === "pie" + ? data.data.map( + (_, i) => SENTRY_COLORS.series[i % SENTRY_COLORS.series.length], + ) + : SENTRY_COLORS.series[index % SENTRY_COLORS.series.length], + borderColor: + chartType === "line" + ? SENTRY_COLORS.series[index % SENTRY_COLORS.series.length] + : undefined, + borderWidth: chartType === "line" ? 2 : 0, + fill: chartType === "line" ? false : undefined, + tension: chartType === "line" ? 0.3 : undefined, + })); + + // Map our chart types to Chart.js types (number/table are handled separately) + const chartJsTypeMap: Record = { + pie: "pie", + line: "line", + }; + const chartJsType = chartJsTypeMap[chartType] ?? "bar"; + + currentChart = new Chart(ctx, { + type: chartJsType, + data: { + labels, + datasets, + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: datasets.length > 1 || chartType === "pie", + position: chartType === "pie" ? "right" : "top", + }, + title: { + display: false, + }, + }, + scales: + chartType === "pie" + ? {} + : { + x: { + grid: { + color: CHART_DEFAULTS.colors.gridLines, + }, + }, + y: { + beginAtZero: true, + grid: { + color: CHART_DEFAULTS.colors.gridLines, + }, + }, + }, + }, + }); +} + +/** + * Main render function + */ +function render(data: ChartData): void { + const title = document.getElementById("title"); + const subtitle = document.getElementById("subtitle"); + + if (title) { + title.textContent = data.query; + } + + if (subtitle) { + subtitle.textContent = `${data.data.length} result${data.data.length === 1 ? "" : "s"}`; + } + + // Determine chart type (use provided or infer) + const chartType: ChartType = + data.chartType || inferChartType(data.data, data.labels, data.values); + + switch (chartType) { + case "number": + renderNumberDisplay(data); + break; + case "table": + renderTable(data); + break; + default: + renderChart(data, chartType); + } +} + +/** + * Show error message + */ +function showError(message: string): void { + const content = document.getElementById("content"); + if (content) { + content.innerHTML = `
${message}
`; + } +} + +// Handle tool results from the server +app.ontoolresult = (result) => { + const chartData = parseChartData(result); + + if (chartData) { + render(chartData); + } else { + showError("No chart data available in the tool response."); + } +}; + +// Connect to the host +app.connect().catch((error) => { + console.error("Failed to connect to MCP host:", error); + showError("Failed to connect to visualization host."); +}); diff --git a/packages/mcp-apps-ui/src/apps/search-events-chart/index.html b/packages/mcp-apps-ui/src/apps/search-events-chart/index.html new file mode 100644 index 00000000..48db81a1 --- /dev/null +++ b/packages/mcp-apps-ui/src/apps/search-events-chart/index.html @@ -0,0 +1,125 @@ + + + + + + Sentry Search Events Chart + + + +
+
+
Search Results
+
Loading...
+
+
+
Loading chart data...
+
+
+ + + diff --git a/packages/mcp-apps-ui/src/index.ts b/packages/mcp-apps-ui/src/index.ts new file mode 100644 index 00000000..722777e1 --- /dev/null +++ b/packages/mcp-apps-ui/src/index.ts @@ -0,0 +1,33 @@ +/** + * MCP Apps UI - Interactive visualizations for Sentry MCP + * + * This module exports bundled HTML apps that can be served as UI resources + * via the MCP Apps protocol. + * + * Usage in mcp-core: + * ```typescript + * import { searchEventsChartHtml } from "@sentry/mcp-apps-ui"; + * + * server.resource( + * "ui://sentry/search-events-chart.html", + * "Search Events Chart UI", + * { mimeType: RESOURCE_MIME_TYPE }, + * async () => ({ + * contents: [{ + * uri: "ui://sentry/search-events-chart.html", + * mimeType: RESOURCE_MIME_TYPE, + * text: searchEventsChartHtml + * }] + * }) + * ); + * ``` + */ + +// Note: Actual exports are generated at build time by bundle-apps.ts +// This file serves as documentation and for TypeScript development + +export type { ChartData, ChartType } from "./shared/chart-data"; +export { inferChartType } from "./shared/chart-data"; + +// The following are generated at build time: +// export const searchEventsChartHtml: string; diff --git a/packages/mcp-apps-ui/src/shared/chart-data.ts b/packages/mcp-apps-ui/src/shared/chart-data.ts new file mode 100644 index 00000000..d8020ae0 --- /dev/null +++ b/packages/mcp-apps-ui/src/shared/chart-data.ts @@ -0,0 +1,63 @@ +/** + * Chart data types and utilities for MCP Apps visualization. + */ + +/** + * Suggested chart type for visualization + */ +export type ChartType = "bar" | "pie" | "line" | "table" | "number"; + +/** + * Chart data structure passed from the server to the UI + */ +export interface ChartData { + /** Suggested chart type for visualization */ + chartType: ChartType; + /** Raw data from the search query */ + data: Record[]; + /** Fields used for grouping (labels/x-axis) */ + labels: string[]; + /** Fields containing aggregate values (y-axis) */ + values: string[]; + /** Original natural language query for context */ + query: string; +} + +/** + * Infer the best chart type based on the data structure + */ +export function inferChartType( + data: Record[], + labels: string[], + values: string[], +): ChartType { + // Single value = number display + if (data.length === 1 && labels.length === 0 && values.length === 1) { + return "number"; + } + + // Check if labels look like time-based + const firstLabel = labels[0]; + const isTimeBased = + firstLabel?.toLowerCase().includes("time") || + firstLabel?.toLowerCase().includes("date") || + firstLabel?.toLowerCase().includes("day") || + firstLabel?.toLowerCase().includes("hour"); + + if (isTimeBased) { + return "line"; + } + + // Multiple values or many categories = table might be better + if (values.length > 2 || data.length > 10) { + return "table"; + } + + // Single category with counts = pie chart works well + if (labels.length === 1 && values.length === 1 && data.length <= 7) { + return "pie"; + } + + // Default to bar chart + return "bar"; +} diff --git a/packages/mcp-apps-ui/src/shared/sentry-theme.ts b/packages/mcp-apps-ui/src/shared/sentry-theme.ts new file mode 100644 index 00000000..311cdbf5 --- /dev/null +++ b/packages/mcp-apps-ui/src/shared/sentry-theme.ts @@ -0,0 +1,53 @@ +/** + * Sentry color palette for chart visualizations. + * These colors are derived from Sentry's design system. + */ + +export const SENTRY_COLORS = { + // Primary brand colors + purple: "#362D59", + purpleLight: "#6C5FC7", + purpleLighter: "#9D8BCF", + + // Series colors for charts (distinguishable, accessible) + series: [ + "#6C5FC7", // Purple (primary) + "#F9C33A", // Yellow + "#FA5D35", // Orange + "#45B5AA", // Teal + "#F68BC1", // Pink + "#7C5FC7", // Light Purple + "#53B6F0", // Blue + "#E8835D", // Coral + ], + + // Semantic colors + error: "#FA5D35", + warning: "#F9C33A", + success: "#45B5AA", + info: "#53B6F0", + + // Neutral colors + gray900: "#1D1127", + gray700: "#3E3446", + gray500: "#80708F", + gray300: "#DBD6E1", + gray100: "#F5F3F7", + white: "#FFFFFF", +}; + +/** + * Chart.js configuration defaults using Sentry theme + */ +export const CHART_DEFAULTS = { + font: { + family: + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', + size: 12, + }, + colors: { + text: SENTRY_COLORS.gray700, + gridLines: SENTRY_COLORS.gray300, + background: SENTRY_COLORS.white, + }, +}; diff --git a/packages/mcp-apps-ui/tsconfig.json b/packages/mcp-apps-ui/tsconfig.json new file mode 100644 index 00000000..7fb9520e --- /dev/null +++ b/packages/mcp-apps-ui/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@sentry/mcp-server-tsconfig/tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "lib": ["ES2022", "DOM"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/mcp-apps-ui/tsdown.config.ts b/packages/mcp-apps-ui/tsdown.config.ts new file mode 100644 index 00000000..66338e99 --- /dev/null +++ b/packages/mcp-apps-ui/tsdown.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/shared/**/*.ts"], + format: ["esm"], + dts: true, + sourcemap: true, + clean: true, + outDir: "dist/shared", +}); diff --git a/packages/mcp-apps-ui/vite.config.ts b/packages/mcp-apps-ui/vite.config.ts new file mode 100644 index 00000000..41e7e696 --- /dev/null +++ b/packages/mcp-apps-ui/vite.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from "vite"; +import { viteSingleFile } from "vite-plugin-singlefile"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + plugins: [viteSingleFile()], + build: { + outDir: "dist/apps", + rollupOptions: { + input: { + "search-events-chart": resolve( + __dirname, + "src/apps/search-events-chart/index.html", + ), + }, + output: { + entryFileNames: "[name].js", + chunkFileNames: "[name].js", + assetFileNames: "[name][extname]", + }, + }, + }, +}); diff --git a/packages/mcp-core/package.json b/packages/mcp-core/package.json index 06d3cc25..2dbee0b1 100644 --- a/packages/mcp-core/package.json +++ b/packages/mcp-core/package.json @@ -11,9 +11,7 @@ "author": "Sentry", "description": "Sentry MCP Core - Shared code for MCP transports", "homepage": "https://github.com/getsentry/sentry-mcp", - "keywords": [ - "sentry" - ], + "keywords": ["sentry"], "bugs": { "url": "https://github.com/getsentry/sentry-mcp/issues" }, @@ -21,9 +19,7 @@ "type": "git", "url": "git@github.com:getsentry/sentry-mcp.git" }, - "files": [ - "./dist/*" - ], + "files": ["./dist/*"], "exports": { "./api-client": { "types": "./dist/api-client/index.ts", @@ -156,8 +152,10 @@ "@ai-sdk/openai": "catalog:", "@logtape/logtape": "^1.1.1", "@logtape/sentry": "^1.1.1", + "@modelcontextprotocol/ext-apps": "^0.4.0", "@modelcontextprotocol/sdk": "catalog:", "@sentry/core": "catalog:", + "@sentry/mcp-apps-ui": "workspace:*", "ai": "catalog:", "dotenv": "catalog:", "zod": "catalog:" diff --git a/packages/mcp-core/src/server.ts b/packages/mcp-core/src/server.ts index c1f0efcd..7dcf4c16 100644 --- a/packages/mcp-core/src/server.ts +++ b/packages/mcp-core/src/server.ts @@ -24,6 +24,7 @@ import type { ServerRequest, ServerNotification, } from "@modelcontextprotocol/sdk/types.js"; +import { RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server"; import tools, { AGENT_DEPENDENT_TOOLS, SIMPLE_REPLACEMENT_TOOLS, @@ -50,6 +51,7 @@ import { getConstraintKeysToFilter, } from "./internal/constraint-helpers"; import { hasAgentProvider } from "./internal/agents/provider-factory"; +import { UI_RESOURCES } from "./ui-resources"; /** * Creates and configures a complete MCP server with Sentry instrumentation. @@ -274,11 +276,20 @@ function configureServer({ experimentalMode, }); + // Build tool annotations with UI metadata if the tool has UI configuration + // This follows MCP Apps spec: tools declare UI via _meta.ui.resourceUri + const toolAnnotations = tool.ui + ? { + ...tool.annotations, + _meta: { ui: { resourceUri: tool.ui.resourceUri } }, + } + : tool.annotations; + server.tool( tool.name, resolvedDescription, filteredInputSchema, - tool.annotations, + toolAnnotations, async ( params: any, extra: RequestHandlerExtra, @@ -381,4 +392,23 @@ function configureServer({ }, ); } + + // Register UI resources for MCP Apps visualization + // These resources are served to MCP Apps-capable clients when tools with UI config are invoked + for (const [resourceUri, resource] of Object.entries(UI_RESOURCES)) { + server.resource( + resource.name, + resourceUri, + { mimeType: RESOURCE_MIME_TYPE }, + async () => ({ + contents: [ + { + uri: resourceUri, + mimeType: RESOURCE_MIME_TYPE, + text: resource.html, + }, + ], + }), + ); + } } diff --git a/packages/mcp-core/src/tools/list-events/list-events.test.ts b/packages/mcp-core/src/tools/list-events/list-events.test.ts index b1a9d37e..dd56a2e0 100644 --- a/packages/mcp-core/src/tools/list-events/list-events.test.ts +++ b/packages/mcp-core/src/tools/list-events/list-events.test.ts @@ -2,6 +2,20 @@ import { describe, it, expect } from "vitest"; import listEvents from "./index.js"; import { getServerContext } from "../../test-setup.js"; +/** + * Helper to extract text content from a formatter result. + * Formatters can return a string or an array containing text + chart data. + */ +function getTextContent( + result: string | Array<{ type: string; text?: string }>, +): string { + if (typeof result === "string") { + return result; + } + const textContent = result.find((item) => item.type === "text"); + return textContent?.text ?? ""; +} + describe("list_events", () => { // Note: The mock server has strict requirements for fields and sort parameters. // Tests use fields that match the mock's expectations. @@ -23,8 +37,9 @@ describe("list_events", () => { getServerContext(), ); - expect(result).toContain("Search Results"); - expect(result).toContain("View these results in Sentry"); + const textContent = getTextContent(result); + expect(textContent).toContain("Search Results"); + expect(textContent).toContain("View these results in Sentry"); }); // Note: Spans test skipped because the mock requires very strict parameters (useRpc=1, specific sort) @@ -48,7 +63,7 @@ describe("list_events", () => { // Should return results with aggregation fields expect(result).toBeDefined(); - expect(typeof result).toBe("string"); - expect(result).toContain("Search Results"); + const textContent = getTextContent(result); + expect(textContent).toContain("Search Results"); }); }); diff --git a/packages/mcp-core/src/tools/search-events.test.ts b/packages/mcp-core/src/tools/search-events.test.ts index 9cbad67f..239a9233 100644 --- a/packages/mcp-core/src/tools/search-events.test.ts +++ b/packages/mcp-core/src/tools/search-events.test.ts @@ -5,6 +5,20 @@ import searchEvents from "./search-events"; import { generateText } from "ai"; import { UserInputError } from "../errors"; +/** + * Helper to extract text content from a formatter result. + * Formatters can return a string or an array containing text + chart data. + */ +function getTextContent( + result: string | Array<{ type: string; text?: string }>, +): string { + if (typeof result === "string") { + return result; + } + const textContent = result.find((item) => item.type === "text"); + return textContent?.text ?? ""; +} + // Mock the AI SDK vi.mock("@ai-sdk/openai", () => { const mockModel = vi.fn(() => "mocked-model"); @@ -60,6 +74,7 @@ describe("search_events", () => { sort: sort || defaultSorts[dataset], timeRange: timeRange ?? null, explanation: "Test query translation", + chartType: null, // No chart type for non-aggregate queries }; return { @@ -412,6 +427,7 @@ describe("search_events", () => { sort: "-timestamp", timeRange: null, explanation: "Self-corrected to include sort field in fields array", + chartType: null, }, } as any); @@ -525,10 +541,12 @@ describe("search_events", () => { ); expect(mockGenerateText).toHaveBeenCalled(); - expect(result).toContain("Mozilla/5.0"); - expect(result).toContain("150"); - expect(result).toContain("120"); + // For aggregate queries, result may be an array with text + chart data + const textContent = getTextContent(result); + expect(textContent).toContain("Mozilla/5.0"); + expect(textContent).toContain("150"); + expect(textContent).toContain("120"); // Should NOT contain user.id references - expect(result).not.toContain("user.id"); + expect(textContent).not.toContain("user.id"); }); }); diff --git a/packages/mcp-core/src/tools/search-events/agent.ts b/packages/mcp-core/src/tools/search-events/agent.ts index 31c7d7e1..0de0334e 100644 --- a/packages/mcp-core/src/tools/search-events/agent.ts +++ b/packages/mcp-core/src/tools/search-events/agent.ts @@ -38,6 +38,12 @@ export const searchEventsAgentOutputSchema = z explanation: z .string() .describe("Brief explanation of how you translated this query."), + chartType: z + .enum(["bar", "pie", "line", "table", "number"]) + .nullable() + .describe( + "Suggested chart type for visualization: 'bar' (grouped data), 'pie' (distribution), 'line' (time-based), 'table' (complex multi-dimensional), 'number' (single aggregate value). Null if not an aggregate query.", + ), }) .refine( (data) => { diff --git a/packages/mcp-core/src/tools/search-events/formatters.ts b/packages/mcp-core/src/tools/search-events/formatters.ts index 0b3979f8..a0291f41 100644 --- a/packages/mcp-core/src/tools/search-events/formatters.ts +++ b/packages/mcp-core/src/tools/search-events/formatters.ts @@ -1,10 +1,21 @@ import type { SentryApiService } from "../../api-client"; +import type { + TextContent, + EmbeddedResource, +} from "@modelcontextprotocol/sdk/types.js"; import { logInfo } from "../../telem/logging"; import { type FlexibleEventData, getStringValue, isAggregateQuery, } from "./utils"; +import { type ChartType, inferChartType } from "@sentry/mcp-apps-ui"; + +/** + * Return type for formatters - can be a string (for non-aggregate queries) + * or an array with text + chart data (for aggregate queries) + */ +export type FormatterResult = string | (TextContent | EmbeddedResource)[]; /** * Format an explanation for how a natural language query was translated @@ -26,12 +37,51 @@ export interface FormatEventResultsParams { sentryQuery: string; fields: string[]; explanation?: string; + /** Optional chart type hint from the AI agent for aggregate queries */ + chartType?: ChartType; +} + +/** + * Create chart data resource for MCP Apps visualization. + * This embeds structured data that the UI app can parse and render as a chart. + */ +function createChartDataResource( + eventData: FlexibleEventData[], + fields: string[], + naturalLanguageQuery: string, + chartType?: ChartType, +): EmbeddedResource { + // Separate fields into labels (groupBy) and values (aggregates) + const labels = fields.filter( + (field) => !field.includes("(") || !field.includes(")"), + ); + const values = fields.filter( + (field) => field.includes("(") && field.includes(")"), + ); + + return { + type: "resource", + resource: { + uri: "data:application/json;chart", + mimeType: "application/json;chart", + text: JSON.stringify({ + chartType: chartType || inferChartType(eventData, labels, values), + data: eventData, + labels, + values, + query: naturalLanguageQuery, + }), + }, + }; } /** - * Format error event results for display + * Format error event results for display. + * Returns text for non-aggregate queries, or text + chart data for aggregate queries. */ -export function formatErrorResults(params: FormatEventResultsParams): string { +export function formatErrorResults( + params: FormatEventResultsParams, +): FormatterResult { const { eventData, naturalLanguageQuery, @@ -42,6 +92,7 @@ export function formatErrorResults(params: FormatEventResultsParams): string { sentryQuery, fields, explanation, + chartType, } = params; let output = `# Search Results for "${naturalLanguageQuery}"\n\n`; @@ -147,13 +198,29 @@ export function formatErrorResults(params: FormatEventResultsParams): string { output += "- View error groups: Navigate to the Issues page in Sentry\n"; output += "- Set up alerts: Configure alert rules for these error patterns\n"; + // For aggregate queries, return both text and chart data for MCP Apps visualization + if (isAggregateQuery(fields) && eventData.length > 0) { + return [ + { type: "text", text: output }, + createChartDataResource( + eventData, + fields, + naturalLanguageQuery, + chartType, + ), + ]; + } + return output; } /** - * Format log event results for display + * Format log event results for display. + * Returns text for non-aggregate queries, or text + chart data for aggregate queries. */ -export function formatLogResults(params: FormatEventResultsParams): string { +export function formatLogResults( + params: FormatEventResultsParams, +): FormatterResult { const { eventData, naturalLanguageQuery, @@ -164,6 +231,7 @@ export function formatLogResults(params: FormatEventResultsParams): string { sentryQuery, fields, explanation, + chartType, } = params; let output = `# Search Results for "${naturalLanguageQuery}"\n\n`; @@ -293,13 +361,29 @@ export function formatLogResults(params: FormatEventResultsParams): string { "- Filter by severity: Adjust your query to focus on specific log levels\n"; output += "- Export logs: Use the Sentry web interface for bulk export\n"; + // For aggregate queries, return both text and chart data for MCP Apps visualization + if (isAggregateQuery(fields) && eventData.length > 0) { + return [ + { type: "text", text: output }, + createChartDataResource( + eventData, + fields, + naturalLanguageQuery, + chartType, + ), + ]; + } + return output; } /** - * Format span/trace event results for display + * Format span/trace event results for display. + * Returns text for non-aggregate queries, or text + chart data for aggregate queries. */ -export function formatSpanResults(params: FormatEventResultsParams): string { +export function formatSpanResults( + params: FormatEventResultsParams, +): FormatterResult { const { eventData, naturalLanguageQuery, @@ -310,6 +394,7 @@ export function formatSpanResults(params: FormatEventResultsParams): string { sentryQuery, fields, explanation, + chartType, } = params; let output = `# Search Results for "${naturalLanguageQuery}"\n\n`; @@ -418,5 +503,18 @@ export function formatSpanResults(params: FormatEventResultsParams): string { output += "- Export data: Use the Sentry web interface for advanced analysis\n"; + // For aggregate queries, return both text and chart data for MCP Apps visualization + if (isAggregateQuery(fields) && eventData.length > 0) { + return [ + { type: "text", text: output }, + createChartDataResource( + eventData, + fields, + naturalLanguageQuery, + chartType, + ), + ]; + } + return output; } diff --git a/packages/mcp-core/src/tools/search-events/handler.ts b/packages/mcp-core/src/tools/search-events/handler.ts index 543092a9..901e33ba 100644 --- a/packages/mcp-core/src/tools/search-events/handler.ts +++ b/packages/mcp-core/src/tools/search-events/handler.ts @@ -83,6 +83,11 @@ export default defineTool({ readOnlyHint: true, openWorldHint: true, }, + // MCP Apps UI configuration for interactive chart visualization + // MCP Apps-capable clients will render charts for aggregate query results + ui: { + resourceUri: "ui://sentry/search-events-chart.html", + }, async handler(params, context: ServerContext) { const apiService = apiServiceFromContext(context, { regionUrl: params.regionUrl ?? undefined, @@ -247,6 +252,7 @@ export default defineTool({ sentryQuery, fields, explanation: parsed.explanation, + chartType: parsed.chartType ?? undefined, }; switch (dataset) { diff --git a/packages/mcp-core/src/tools/types.ts b/packages/mcp-core/src/tools/types.ts index 1818ae57..3368dc2f 100644 --- a/packages/mcp-core/src/tools/types.ts +++ b/packages/mcp-core/src/tools/types.ts @@ -49,6 +49,16 @@ export function isToolVisibleInMode( return true; } +/** + * Configuration for MCP Apps UI visualization. + * When set, the tool will expose a UI resource that can be rendered + * by MCP Apps-capable clients. + */ +export interface ToolUIConfig { + /** URI of the UI resource for this tool (e.g., "ui://sentry/search-events-chart.html") */ + resourceUri: string; +} + export interface ToolConfig< TSchema extends Record = Record, > { @@ -66,6 +76,8 @@ export interface ToolConfig< idempotentHint?: boolean; openWorldHint?: boolean; }; + /** Optional MCP Apps UI configuration for interactive visualizations */ + ui?: ToolUIConfig; handler: ( params: z.infer>, context: ServerContext, diff --git a/packages/mcp-core/src/ui-resources.ts b/packages/mcp-core/src/ui-resources.ts new file mode 100644 index 00000000..2a66d1ea --- /dev/null +++ b/packages/mcp-core/src/ui-resources.ts @@ -0,0 +1,19 @@ +/** + * UI Resources for MCP Apps visualization. + * + * This module provides a registry of UI resources that can be served + * to MCP Apps-capable clients for interactive visualizations. + */ + +import { searchEventsChartHtml } from "@sentry/mcp-apps-ui"; + +/** + * Registry of UI resources keyed by their resource URI. + * The URI format follows the MCP Apps spec: ui:/// + */ +export const UI_RESOURCES: Record = { + "ui://sentry/search-events-chart.html": { + html: searchEventsChartHtml, + name: "Search Events Chart", + }, +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df449dd7..36eac540 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -214,6 +214,28 @@ importers: specifier: ^7.0.15 version: 7.0.15 + packages/mcp-apps-ui: + dependencies: + '@modelcontextprotocol/ext-apps': + specifier: ^0.4.0 + version: 0.4.2(@modelcontextprotocol/sdk@1.22.0(@cfworker/json-schema@4.1.1))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(zod@3.25.76) + chart.js: + specifier: ^4.4.7 + version: 4.5.1 + devDependencies: + '@sentry/mcp-server-tsconfig': + specifier: workspace:* + version: link:../mcp-server-tsconfig + tsdown: + specifier: 'catalog:' + version: 0.12.9(typescript@5.8.3) + vite: + specifier: 'catalog:' + version: 6.3.5(@types/node@24.0.10)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0) + vite-plugin-singlefile: + specifier: ^2.0.3 + version: 2.3.0(rollup@4.44.1)(vite@6.3.5(@types/node@24.0.10)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0)) + packages/mcp-cloudflare: dependencies: '@ai-sdk/openai': @@ -361,12 +383,18 @@ importers: '@logtape/sentry': specifier: ^1.1.1 version: 1.1.1(@logtape/logtape@1.1.1) + '@modelcontextprotocol/ext-apps': + specifier: ^0.4.0 + version: 0.4.2(@modelcontextprotocol/sdk@1.22.0(@cfworker/json-schema@4.1.1))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(zod@3.25.76) '@modelcontextprotocol/sdk': specifier: 'catalog:' version: 1.22.0(@cfworker/json-schema@4.1.1) '@sentry/core': specifier: 'catalog:' version: 10.35.0 + '@sentry/mcp-apps-ui': + specifier: workspace:* + version: link:../mcp-apps-ui ai: specifier: 'catalog:' version: 4.3.16(react@19.1.0)(zod@3.25.76) @@ -1371,6 +1399,9 @@ packages: resolution: {integrity: sha512-yYxMVH7Dqw6nO0d5NIV8OQWnitU8k6vXH8NtgqAfIa/IUqRMxRv/NUJJ08VEKbAakwxlgBl5PJdrU0dMPStsnw==} engines: {node: '>=v12.0.0'} + '@kurkle/color@0.3.4': + resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} + '@logtape/logtape@1.1.1': resolution: {integrity: sha512-/zpMuB2BZnL3LnBsy6PfFJp/4qP/60U9hE5j6eFwk25wBVtLpd7/bF3B1j7cnMD37R02fFyDyyHEmRSOz6+Qfg==} @@ -1379,6 +1410,19 @@ packages: peerDependencies: '@logtape/logtape': ^1.1.1 + '@modelcontextprotocol/ext-apps@0.4.2': + resolution: {integrity: sha512-paPStFzWQ6opPMipgXohj+oUwLcHuwRENNlXAWRAAMQL78gJL7gpMhMg61LHyDBMGKACkPUrHUl7gIYBsZiouQ==} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.24.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + '@modelcontextprotocol/sdk@1.22.0': resolution: {integrity: sha512-VUpl106XVTCpDmTBil2ehgJZjhyLY2QZikzF8NvTXtLRF1CvO5iEE2UNZdVIUer35vFOwMKYeUGbjJtvPWan3g==} engines: {node: '>=18'} @@ -1600,6 +1644,61 @@ packages: peerDependencies: '@opentelemetry/api': ^1.1.0 + '@oven/bun-darwin-aarch64@1.3.6': + resolution: {integrity: sha512-27rypIapNkYboOSylkf1tD9UW9Ado2I+P1NBL46Qz29KmOjTL6WuJ7mHDC5O66CYxlOkF5r93NPDAC3lFHYBXw==} + cpu: [arm64] + os: [darwin] + + '@oven/bun-darwin-x64-baseline@1.3.6': + resolution: {integrity: sha512-nqtr+pTsHqusYpG2OZc6s+AmpWDB/FmBvstrK0y5zkti4OqnCuu7Ev2xNjS7uyb47NrAFF40pWqkpaio5XEd7w==} + cpu: [x64] + os: [darwin] + + '@oven/bun-darwin-x64@1.3.6': + resolution: {integrity: sha512-I82xGzPkBxzBKgbl8DsA0RfMQCWTWjNmLjIEkW1ECiv3qK02kHGQ5FGUr/29L/SuvnGsULW4tBTRNZiMzL37nA==} + cpu: [x64] + os: [darwin] + + '@oven/bun-linux-aarch64-musl@1.3.6': + resolution: {integrity: sha512-FR+iJt17rfFgYgpxL3M67AUwujOgjw52ZJzB9vElI5jQXNjTyOKf8eH4meSk4vjlYF3h/AjKYd6pmN0OIUlVKQ==} + cpu: [arm64] + os: [linux] + + '@oven/bun-linux-aarch64@1.3.6': + resolution: {integrity: sha512-YaQEAYjBanoOOtpqk/c5GGcfZIyxIIkQ2m1TbHjedRmJNwxzWBhGinSARFkrRIc3F8pRIGAopXKvJ/2rjN1LzQ==} + cpu: [arm64] + os: [linux] + + '@oven/bun-linux-x64-baseline@1.3.6': + resolution: {integrity: sha512-jRmnX18ak8WzqLrex3siw0PoVKyIeI5AiCv4wJLgSs7VKfOqrPycfHIWfIX2jdn7ngqbHFPzI09VBKANZ4Pckg==} + cpu: [x64] + os: [linux] + + '@oven/bun-linux-x64-musl-baseline@1.3.6': + resolution: {integrity: sha512-7FjVnxnRTp/AgWqSQRT/Vt9TYmvnZ+4M+d9QOKh/Lf++wIFXFGSeAgD6bV1X/yr2UPVmZDk+xdhr2XkU7l2v3w==} + cpu: [x64] + os: [linux] + + '@oven/bun-linux-x64-musl@1.3.6': + resolution: {integrity: sha512-YeXcJ9K6vJAt1zSkeA21J6pTe7PgDMLTHKGI3nQBiMYnYf7Ob3K+b/ChSCznrJG7No5PCPiQPg4zTgA+BOTmSA==} + cpu: [x64] + os: [linux] + + '@oven/bun-linux-x64@1.3.6': + resolution: {integrity: sha512-egfngj0dfJ868cf30E7B+ye9KUWSebYxOG4l9YP5eWeMXCtenpenx0zdKtAn9qxJgEJym5AN6trtlk+J6x8Lig==} + cpu: [x64] + os: [linux] + + '@oven/bun-windows-x64-baseline@1.3.6': + resolution: {integrity: sha512-PFUa7JL4lGoyyppeS4zqfuoXXih+gSE0XxhDMrCPVEUev0yhGNd/tbWBvcdpYnUth80owENoGjc8s5Knopv9wA==} + cpu: [x64] + os: [win32] + + '@oven/bun-windows-x64@1.3.6': + resolution: {integrity: sha512-Sr1KwUcbB0SEpnSPO22tNJppku2khjFluEst+mTGhxHzAGQTQncNeJxDnt3F15n+p9Q+mlcorxehd68n1siikQ==} + cpu: [x64] + os: [win32] + '@oxc-project/runtime@0.75.0': resolution: {integrity: sha512-gzRmVI/vorsPmbDXt7GD4Uh2lD3rCOku/1xWPB4Yx48k0EP4TZmzQudWapjN4+7Vv+rgXr0RqCHQadeaMvdBuw==} engines: {node: '>=6.9.0'} @@ -1853,11 +1952,21 @@ packages: cpu: [arm64] os: [darwin] + '@rollup/rollup-darwin-arm64@4.56.0': + resolution: {integrity: sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==} + cpu: [arm64] + os: [darwin] + '@rollup/rollup-darwin-x64@4.44.1': resolution: {integrity: sha512-gDnWk57urJrkrHQ2WVx9TSVTH7lSlU7E3AFqiko+bgjlh78aJ88/3nycMax52VIVjIm3ObXnDL2H00e/xzoipw==} cpu: [x64] os: [darwin] + '@rollup/rollup-darwin-x64@4.56.0': + resolution: {integrity: sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==} + cpu: [x64] + os: [darwin] + '@rollup/rollup-freebsd-arm64@4.44.1': resolution: {integrity: sha512-wnFQmJ/zPThM5zEGcnDcCJeYJgtSLjh1d//WuHzhf6zT3Md1BvvhJnWoy+HECKu2bMxaIcfWiu3bJgx6z4g2XA==} cpu: [arm64] @@ -1883,6 +1992,11 @@ packages: cpu: [arm64] os: [linux] + '@rollup/rollup-linux-arm64-gnu@4.56.0': + resolution: {integrity: sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==} + cpu: [arm64] + os: [linux] + '@rollup/rollup-linux-arm64-musl@4.44.1': resolution: {integrity: sha512-W+GBM4ifET1Plw8pdVaecwUgxmiH23CfAUj32u8knq0JPFyK4weRy6H7ooxYFD19YxBulL0Ktsflg5XS7+7u9g==} cpu: [arm64] @@ -1918,6 +2032,11 @@ packages: cpu: [x64] os: [linux] + '@rollup/rollup-linux-x64-gnu@4.56.0': + resolution: {integrity: sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==} + cpu: [x64] + os: [linux] + '@rollup/rollup-linux-x64-musl@4.44.1': resolution: {integrity: sha512-iAS4p+J1az6Usn0f8xhgL4PaU878KEtutP4hqw52I4IO6AGoyOkHCxcc4bqufv1tQLdDWFx8lR9YlwxKuv3/3g==} cpu: [x64] @@ -1928,6 +2047,11 @@ packages: cpu: [arm64] os: [win32] + '@rollup/rollup-win32-arm64-msvc@4.56.0': + resolution: {integrity: sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==} + cpu: [arm64] + os: [win32] + '@rollup/rollup-win32-ia32-msvc@4.44.1': resolution: {integrity: sha512-JYA3qvCOLXSsnTR3oiyGws1Dm0YTuxAAeaYGVlGpUsHqloPcFjPg+X0Fj2qODGLNwQOAcCiQmHub/V007kiH5A==} cpu: [ia32] @@ -1938,6 +2062,11 @@ packages: cpu: [x64] os: [win32] + '@rollup/rollup-win32-x64-msvc@4.56.0': + resolution: {integrity: sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==} + cpu: [x64] + os: [win32] + '@sentry-internal/browser-utils@10.35.0': resolution: {integrity: sha512-YjVbyqpJu6E6U/BCdOgIUuUQPUDZ7XdFiBYXtGy59xqQB1qSqNfei163hkfnXxIN90csDubxWNrnit+W5Wo/uQ==} engines: {node: '>=18'} @@ -2589,6 +2718,10 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + chart.js@4.5.1: + resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==} + engines: {pnpm: '>=8'} + check-error@2.1.1: resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} engines: {node: '>= 16'} @@ -4680,6 +4813,13 @@ packages: engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true + vite-plugin-singlefile@2.3.0: + resolution: {integrity: sha512-DAcHzYypM0CasNLSz/WG0VdKOCxGHErfrjOoyIPiNxTPTGmO6rRD/te93n1YL/s+miXq66ipF1brMBikf99c6A==} + engines: {node: '>18.0.0'} + peerDependencies: + rollup: ^4.44.1 + vite: ^5.4.11 || ^6.0.0 || ^7.0.0 + vite@6.3.5: resolution: {integrity: sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -5625,6 +5765,8 @@ snapshots: dependencies: lodash: 4.17.21 + '@kurkle/color@0.3.4': {} + '@logtape/logtape@1.1.1': {} '@logtape/sentry@1.1.1(@logtape/logtape@1.1.1)': @@ -5632,6 +5774,31 @@ snapshots: '@logtape/logtape': 1.1.1 '@sentry/core': 9.34.0 + '@modelcontextprotocol/ext-apps@0.4.2(@modelcontextprotocol/sdk@1.22.0(@cfworker/json-schema@4.1.1))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(zod@3.25.76)': + dependencies: + '@modelcontextprotocol/sdk': 1.22.0(@cfworker/json-schema@4.1.1) + zod: 3.25.76 + optionalDependencies: + '@oven/bun-darwin-aarch64': 1.3.6 + '@oven/bun-darwin-x64': 1.3.6 + '@oven/bun-darwin-x64-baseline': 1.3.6 + '@oven/bun-linux-aarch64': 1.3.6 + '@oven/bun-linux-aarch64-musl': 1.3.6 + '@oven/bun-linux-x64': 1.3.6 + '@oven/bun-linux-x64-baseline': 1.3.6 + '@oven/bun-linux-x64-musl': 1.3.6 + '@oven/bun-linux-x64-musl-baseline': 1.3.6 + '@oven/bun-windows-x64': 1.3.6 + '@oven/bun-windows-x64-baseline': 1.3.6 + '@rollup/rollup-darwin-arm64': 4.56.0 + '@rollup/rollup-darwin-x64': 4.56.0 + '@rollup/rollup-linux-arm64-gnu': 4.56.0 + '@rollup/rollup-linux-x64-gnu': 4.56.0 + '@rollup/rollup-win32-arm64-msvc': 4.56.0 + '@rollup/rollup-win32-x64-msvc': 4.56.0 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + '@modelcontextprotocol/sdk@1.22.0(@cfworker/json-schema@4.1.1)': dependencies: ajv: 8.17.1 @@ -5920,6 +6087,39 @@ snapshots: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@oven/bun-darwin-aarch64@1.3.6': + optional: true + + '@oven/bun-darwin-x64-baseline@1.3.6': + optional: true + + '@oven/bun-darwin-x64@1.3.6': + optional: true + + '@oven/bun-linux-aarch64-musl@1.3.6': + optional: true + + '@oven/bun-linux-aarch64@1.3.6': + optional: true + + '@oven/bun-linux-x64-baseline@1.3.6': + optional: true + + '@oven/bun-linux-x64-musl-baseline@1.3.6': + optional: true + + '@oven/bun-linux-x64-musl@1.3.6': + optional: true + + '@oven/bun-linux-x64@1.3.6': + optional: true + + '@oven/bun-windows-x64-baseline@1.3.6': + optional: true + + '@oven/bun-windows-x64@1.3.6': + optional: true + '@oxc-project/runtime@0.75.0': {} '@oxc-project/types@0.75.0': {} @@ -6122,9 +6322,15 @@ snapshots: '@rollup/rollup-darwin-arm64@4.44.1': optional: true + '@rollup/rollup-darwin-arm64@4.56.0': + optional: true + '@rollup/rollup-darwin-x64@4.44.1': optional: true + '@rollup/rollup-darwin-x64@4.56.0': + optional: true + '@rollup/rollup-freebsd-arm64@4.44.1': optional: true @@ -6140,6 +6346,9 @@ snapshots: '@rollup/rollup-linux-arm64-gnu@4.44.1': optional: true + '@rollup/rollup-linux-arm64-gnu@4.56.0': + optional: true + '@rollup/rollup-linux-arm64-musl@4.44.1': optional: true @@ -6161,18 +6370,27 @@ snapshots: '@rollup/rollup-linux-x64-gnu@4.44.1': optional: true + '@rollup/rollup-linux-x64-gnu@4.56.0': + optional: true + '@rollup/rollup-linux-x64-musl@4.44.1': optional: true '@rollup/rollup-win32-arm64-msvc@4.44.1': optional: true + '@rollup/rollup-win32-arm64-msvc@4.56.0': + optional: true + '@rollup/rollup-win32-ia32-msvc@4.44.1': optional: true '@rollup/rollup-win32-x64-msvc@4.44.1': optional: true + '@rollup/rollup-win32-x64-msvc@4.56.0': + optional: true + '@sentry-internal/browser-utils@10.35.0': dependencies: '@sentry/core': 10.35.0 @@ -6898,6 +7116,10 @@ snapshots: character-reference-invalid@2.0.1: {} + chart.js@4.5.1: + dependencies: + '@kurkle/color': 0.3.4 + check-error@2.1.1: {} chokidar@3.6.0: @@ -9318,6 +9540,12 @@ snapshots: - tsx - yaml + vite-plugin-singlefile@2.3.0(rollup@4.44.1)(vite@6.3.5(@types/node@24.0.10)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0)): + dependencies: + micromatch: 4.0.8 + rollup: 4.44.1 + vite: 6.3.5(@types/node@24.0.10)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0) + vite@6.3.5(@types/node@22.16.0)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0): dependencies: esbuild: 0.25.5