diff --git a/packages/mcp-apps-ui/package.json b/packages/mcp-apps-ui/package.json new file mode 100644 index 000000000..593faf54a --- /dev/null +++ b/packages/mcp-apps-ui/package.json @@ -0,0 +1,34 @@ +{ + "name": "@sentry/mcp-apps-ui", + "version": "0.37.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", + "test": "vitest run", + "tsc": "tsc --noEmit" + }, + "devDependencies": { + "@sentry/mcp-server-tsconfig": "workspace:*", + "tsdown": "catalog:", + "vite": "catalog:", + "vite-plugin-singlefile": "^2.0.3", + "vitest": "catalog:" + }, + "dependencies": { + "@modelcontextprotocol/ext-apps": "^1.7.5", + "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 000000000..be143e695 --- /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 000000000..7d686eec4 --- /dev/null +++ b/packages/mcp-apps-ui/src/apps/search-events-chart/app.ts @@ -0,0 +1,248 @@ +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"; +import { + renderErrorHtml, + renderNumberDisplayHtml, + renderTableHtml, +} from "../../shared/html"; + +// 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 = renderNumberDisplayHtml( + 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]; + + content.innerHTML = renderTableHtml( + data.data.map((row) => + Object.fromEntries( + allColumns.map((column) => [column, formatNumber(row[column])]), + ), + ), + allColumns, + ); +} + +/** + * 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 = renderErrorHtml(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 000000000..48db81a1a --- /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 000000000..722777e15 --- /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 000000000..d8020ae0f --- /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/html.ts b/packages/mcp-apps-ui/src/shared/html.ts new file mode 100644 index 000000000..09ecd7676 --- /dev/null +++ b/packages/mcp-apps-ui/src/shared/html.ts @@ -0,0 +1,53 @@ +const HTML_ESCAPE_CHARACTERS: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", +}; + +export function escapeHtml(value: unknown): string { + return String(value).replace( + /[&<>"']/g, + (character) => HTML_ESCAPE_CHARACTERS[character] ?? character, + ); +} + +export function renderNumberDisplayHtml(value: unknown, label: string): string { + return ` +
+
${escapeHtml(value)}
+
${escapeHtml(label)}
+
+ `; +} + +export function renderTableHtml( + data: Record[], + columns: string[], +): string { + const headerCells = columns + .map((column) => `${escapeHtml(column)}`) + .join(""); + const rows = data + .map( + (row) => + `${columns + .map((column) => `${escapeHtml(row[column])}`) + .join("")}`, + ) + .join(""); + + return ` +
+ + ${headerCells} + ${rows} +
+
+ `; +} + +export function renderErrorHtml(message: string): string { + return `
${escapeHtml(message)}
`; +} 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 000000000..311cdbf53 --- /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/tests/html.test.ts b/packages/mcp-apps-ui/tests/html.test.ts new file mode 100644 index 000000000..bf358b78d --- /dev/null +++ b/packages/mcp-apps-ui/tests/html.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { + escapeHtml, + renderErrorHtml, + renderNumberDisplayHtml, + renderTableHtml, +} from "../src/shared/html"; + +const maliciousValue = ''; + +describe("safe chart markup", () => { + it("escapes HTML-significant characters", () => { + expect(escapeHtml(`&<>"'`)).toBe("&<>"'"); + }); + + it("escapes dynamic values in number, table, and error displays", () => { + const markup = [ + renderNumberDisplayHtml(maliciousValue, maliciousValue), + renderTableHtml([{ [maliciousValue]: maliciousValue }], [maliciousValue]), + renderErrorHtml(maliciousValue), + ].join("\n"); + + expect(markup).not.toContain(" ({ @@ -189,6 +190,57 @@ describe("buildServer", () => { }); }); + it.each(["v1", "v2"] as const)( + "registers search event UI metadata and resources with SDK %s", + async (sdkVersion) => { + const server = + sdkVersion === "v2" + ? buildServer({ context: baseContext, sdkVersion: "v2" }) + : buildServer({ context: baseContext }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const client = new Client({ + name: "ui-resource-test-client", + version: "1.0.0", + }); + + await server.connect(serverTransport); + await client.connect(clientTransport); + + try { + const searchEventsTool = (await client.listTools()).tools.find( + (tool) => tool.name === "search_events", + ); + expect(searchEventsTool?._meta).toMatchObject({ + ui: { resourceUri: SEARCH_EVENTS_CHART_RESOURCE_URI }, + "ui/resourceUri": SEARCH_EVENTS_CHART_RESOURCE_URI, + }); + + expect((await client.listResources()).resources).toContainEqual( + expect.objectContaining({ + name: "Search Events Chart", + uri: SEARCH_EVENTS_CHART_RESOURCE_URI, + mimeType: "text/html;profile=mcp-app", + }), + ); + + const resource = await client.readResource({ + uri: SEARCH_EVENTS_CHART_RESOURCE_URI, + }); + expect(resource.contents).toContainEqual( + expect.objectContaining({ + uri: SEARCH_EVENTS_CHART_RESOURCE_URI, + mimeType: "text/html;profile=mcp-app", + text: expect.stringContaining("Sentry Search Events Chart"), + }), + ); + } finally { + await client.close(); + await server.close(); + } + }, + ); + describe("telemetry context", () => { it("generates compatibility text for structured-only tool output", async () => { const server = buildServer({ diff --git a/packages/mcp-core/src/server.ts b/packages/mcp-core/src/server.ts index 875f96ef1..74af7403d 100644 --- a/packages/mcp-core/src/server.ts +++ b/packages/mcp-core/src/server.ts @@ -20,6 +20,10 @@ import { McpServer as LegacyMcpServer } from "@modelcontextprotocol/sdk/server/m * ``` */ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { + RESOURCE_MIME_TYPE, + RESOURCE_URI_META_KEY, +} from "@modelcontextprotocol/ext-apps/server"; import { McpServer as ModernMcpServer } from "@modelcontextprotocol/server"; import { getActiveSpan, @@ -44,6 +48,7 @@ import { import tools from "./tools/index"; import type { StructuredToolOutput } from "./tools/types"; import type { ServerContext } from "./types"; +import { UI_RESOURCES } from "./ui-resources"; import { LIB_VERSION } from "./version"; function getSkillGrantedAttributeName(skill: Skill): string { @@ -131,6 +136,45 @@ type BuildServerOptions = { tools?: ToolRegistry; }; +function createUiResourceResult( + uri: URL, + resource: { html: string }, +): Promise<{ + contents: Array<{ uri: string; mimeType: string; text: string }>; +}> { + return Promise.resolve({ + contents: [ + { + uri: uri.href, + mimeType: RESOURCE_MIME_TYPE, + text: resource.html, + }, + ], + }); +} + +function registerLegacyUiResources(server: LegacyMcpServer): void { + for (const [resourceUri, resource] of Object.entries(UI_RESOURCES)) { + server.registerResource( + resource.name, + resourceUri, + { mimeType: RESOURCE_MIME_TYPE }, + (uri) => createUiResourceResult(uri, resource), + ); + } +} + +function registerModernUiResources(server: ModernMcpServer): void { + for (const [resourceUri, resource] of Object.entries(UI_RESOURCES)) { + server.registerResource( + resource.name, + resourceUri, + { mimeType: RESOURCE_MIME_TYPE }, + (uri) => createUiResourceResult(uri, resource), + ); + } +} + export function buildServer( options: BuildServerOptions & { sdkVersion: "v2" }, ): ModernMcpServer; @@ -163,6 +207,7 @@ export function buildServer({ for (const { name, config, handler } of registrations) { server.registerTool(name, config, handler); } + registerModernUiResources(server); return wrapMcpServerWithSentry(server); } @@ -176,6 +221,7 @@ export function buildServer({ for (const { name, config, handler } of registrations) { server.registerTool(name, config, handler); } + registerLegacyUiResources(server); return wrapMcpServerWithSentry(server); } @@ -253,6 +299,12 @@ function configureServer({ inputSchema: filteredInputSchema, outputSchema: tool.outputSchema, annotations: tool.annotations, + _meta: tool.ui + ? { + ui: { resourceUri: tool.ui.resourceUri }, + [RESOURCE_URI_META_KEY]: tool.ui.resourceUri, + } + : undefined, }; const handleToolCall = async (params: unknown): Promise => { // Get the active MCP server span and attach request-scoped attributes. diff --git a/packages/mcp-core/src/tools/catalog/search-events.test.ts b/packages/mcp-core/src/tools/catalog/search-events.test.ts index 12fa200af..6987b53f6 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.test.ts @@ -5,6 +5,24 @@ import searchEvents from "./search-events"; import { MAX_EVENTS_VALIDATION_ATTEMPTS } from "../support/search-events/utils"; import { generateText } from "ai"; import { UserInputError } from "../../errors"; +import { getTextContent } from "../../test-utils/structured-content"; + +function getToolText(result: unknown): string { + if (typeof result === "string") { + return result; + } + if (Array.isArray(result)) { + const textContent = result.find( + (item) => + typeof item === "object" && + item !== null && + "text" in item && + typeof item.text === "string", + ); + return textContent && "text" in textContent ? textContent.text : ""; + } + return getTextContent(result); +} // Mock the AI SDK vi.mock("@ai-sdk/openai", () => { @@ -249,11 +267,12 @@ describe("search_events", () => { ); expect(mockGenerateText).not.toHaveBeenCalled(); - expect(result).toContain('"tags[type]": "Unified"'); - expect(result).toContain( + const text = getToolText(result); + expect(text).toContain('"tags[type]": "Unified"'); + expect(text).toContain( '- Query: `transaction:"VPN connections" tags[type]:Unified tags[country]:CN`', ); - expect(result).toContain( + expect(text).toContain( "- Fields: `tags[type]`, `tags[sequence]`, `span.status`, `tags[reason]`, `count()`", ); }); @@ -498,8 +517,9 @@ describe("search_events", () => { ); expect(mockGenerateText).toHaveBeenCalled(); - expect(result).toContain('"tags[type]": "Unified"'); - expect(result).toContain('"tags[sequence]": "42"'); + const text = getToolText(result); + expect(text).toContain('"tags[type]": "Unified"'); + expect(text).toContain('"tags[sequence]": "42"'); }); it("should include the sort field even when caller's explicit fields omit it", async () => { @@ -975,7 +995,7 @@ describe("search_events", () => { }, ); - expect(result).toMatchInlineSnapshot(` + expect(getToolText(result)).toMatchInlineSnapshot(` "# Search Results for "slow request duration metrics" **Suggested presentation:** A compact table with grouping labels and units works well for these metric aggregates. @@ -1012,6 +1032,16 @@ describe("search_events", () => { - Switch between samples and aggregates in Sentry for deeper analysis " `); + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "resource", + resource: expect.objectContaining({ + mimeType: "application/json;chart", + }), + }), + ]), + ); }); it("should request trace metric identity fields for metrics sample queries", async () => { @@ -1075,12 +1105,9 @@ describe("search_events", () => { }, ); - expect(typeof result).toBe("string"); - if (typeof result !== "string") { - throw new Error("Expected string result"); - } + const text = getToolText(result); - const urlMatch = result.match(/https:\/\/[^\n]+/); + const urlMatch = text.match(/https:\/\/[^\n]+/); expect(urlMatch).not.toBeNull(); const url = new URL(urlMatch![0]); @@ -1358,12 +1385,9 @@ describe("search_events", () => { }, ); - expect(typeof result).toBe("string"); - if (typeof result !== "string") { - throw new Error("Expected string result"); - } + const text = getToolText(result); - const urlMatch = result.match(/https:\/\/[^\n]+/); + const urlMatch = text.match(/https:\/\/[^\n]+/); expect(urlMatch).not.toBeNull(); const url = new URL(urlMatch![0]); @@ -2390,11 +2414,12 @@ describe("search_events", () => { ); expect(mockGenerateText).toHaveBeenCalled(); - expect(result).toContain("Mozilla/5.0"); - expect(result).toContain("150"); - expect(result).toContain("120"); + const text = getToolText(result); + expect(text).toContain("Mozilla/5.0"); + expect(text).toContain("150"); + expect(text).toContain("120"); // Should NOT contain user.id references - expect(result).not.toContain("user.id"); + expect(text).not.toContain("user.id"); }); it("should search events with direct query syntax (no agent)", async () => { diff --git a/packages/mcp-core/src/tools/catalog/search-events.ts b/packages/mcp-core/src/tools/catalog/search-events.ts index e74f4d354..480404584 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.ts @@ -11,6 +11,7 @@ import { ParamRegionUrl, } from "../../schema"; import type { ServerContext } from "../../types"; +import { SEARCH_EVENTS_CHART_RESOURCE_URI } from "../../ui-resource-uris"; import { PUBLIC_EVENTS_DATASETS, type PublicEventsDataset, @@ -458,6 +459,9 @@ export default defineTool({ destructiveHint: false, openWorldHint: true, }, + ui: { + resourceUri: SEARCH_EVENTS_CHART_RESOURCE_URI, + }, async handler(params, context: ServerContext) { const apiService = apiServiceFromContext(context, { regionUrl: params.regionUrl ?? undefined, @@ -500,6 +504,7 @@ export default defineTool({ let sortParam: string; let timeParams: { statsPeriod?: string; start?: string; end?: string }; let explanation: string | undefined; + let chartType: SearchEventsAgentResult["chartType"] = null; let environment: string | string[] | null | undefined = params.environment; const explicitSort = params.sort?.trim() || undefined; @@ -562,6 +567,7 @@ export default defineTool({ ? explicitSort : parsed.sort?.trim() || defaultSortForDataset(dataset); explanation = parsed.explanation; + chartType = parsed.chartType; environment = params.environment ?? parsed.environment; timeParams = @@ -763,6 +769,7 @@ export default defineTool({ parsed, )); validationExplanation = parsed.explanation || validationExplanation; + chartType = parsed.chartType ?? chartType; sentryQuery = applyEnvironmentToEventsQuery( dataset, sentryQuery, @@ -886,6 +893,7 @@ export default defineTool({ sentryQuery, fields, explanation, + chartType: chartType ?? undefined, executedSearch: { dataset, query: sentryQuery, diff --git a/packages/mcp-core/src/tools/support/search-events/agent.ts b/packages/mcp-core/src/tools/support/search-events/agent.ts index 15acc7d7b..b9f810f52 100644 --- a/packages/mcp-core/src/tools/support/search-events/agent.ts +++ b/packages/mcp-core/src/tools/support/search-events/agent.ts @@ -50,6 +50,13 @@ export const searchEventsAgentOutputSchema = z .string() .default("") .describe("Brief explanation of how you translated this query."), + chartType: z + .enum(["bar", "pie", "line", "table", "number"]) + .nullable() + .default(null) + .describe( + "Suggested visualization for aggregate results. Use null for individual event or replay results.", + ), }) .refine( (data) => { diff --git a/packages/mcp-core/src/tools/support/search-events/formatters.test.ts b/packages/mcp-core/src/tools/support/search-events/formatters.test.ts index fb2cdff0a..826b90672 100644 --- a/packages/mcp-core/src/tools/support/search-events/formatters.test.ts +++ b/packages/mcp-core/src/tools/support/search-events/formatters.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it } from "vitest"; +import type { SentryApiService } from "../../../api-client"; -import { formatExecutedSearch } from "./formatters.js"; +import { + formatErrorResults, + formatExecutedSearch, + formatLogResults, + formatProfileResults, + formatSpanResults, + formatTraceMetricsResults, +} from "./formatters.js"; describe("formatExecutedSearch", () => { it("pads inline code values that start or end with backticks", () => { @@ -18,3 +26,52 @@ describe("formatExecutedSearch", () => { expect(result).toContain("- Sort: `-count()`"); }); }); + +describe("aggregate chart data", () => { + const formatters = [ + formatErrorResults, + formatLogResults, + formatSpanResults, + formatProfileResults, + formatTraceMetricsResults, + ]; + + it.each(formatters)("adds chart data for %p", (formatter) => { + const result = formatter({ + eventData: [{ release: '', "count()": 3 }], + inputQuery: "events by release", + apiService: {} as SentryApiService, + organizationSlug: "test-org", + explorerUrl: "https://test-org.sentry.io/explore/", + sentryQuery: "", + fields: ["release", "count()"], + }); + + expect(Array.isArray(result)).toBe(true); + if (!Array.isArray(result)) { + throw new Error("Expected aggregate formatter to return chart data"); + } + + expect(result[0]).toMatchObject({ type: "text" }); + expect(result[1]).toMatchObject({ + type: "resource", + resource: { + mimeType: "application/json;chart", + }, + }); + const chartResource = result[1]; + if ( + chartResource.type !== "resource" || + !("text" in chartResource.resource) + ) { + throw new Error("Expected chart data as a text resource"); + } + expect(JSON.parse(chartResource.resource.text)).toEqual({ + chartType: "pie", + data: [{ release: '', "count()": 3 }], + labels: ["release"], + values: ["count()"], + query: "events by release", + }); + }); +}); diff --git a/packages/mcp-core/src/tools/support/search-events/formatters.ts b/packages/mcp-core/src/tools/support/search-events/formatters.ts index cb7ac3e90..6550d3b1c 100644 --- a/packages/mcp-core/src/tools/support/search-events/formatters.ts +++ b/packages/mcp-core/src/tools/support/search-events/formatters.ts @@ -1,4 +1,9 @@ import type { SentryApiService } from "../../../api-client"; +import type { + EmbeddedResource, + TextContent, +} from "@modelcontextprotocol/sdk/types.js"; +import { type ChartType, inferChartType } from "@sentry/mcp-apps-ui"; import { formatToolCallInstruction } from "../../../internal/tool-helpers/tool-call-formatting"; import { formatUserGeoSummary } from "../../../internal/user-formatting"; import { logInfo } from "../../../telem/logging"; @@ -108,12 +113,63 @@ export interface FormatEventResultsParams { sentryQuery: string; fields: string[]; explanation?: string; + chartType?: ChartType; executedSearch?: ExecutedSearch; experimentalMode?: boolean; availableToolNames?: ReadonlySet; directToolNames?: ReadonlySet; } +export type FormatterResult = string | (TextContent | EmbeddedResource)[]; + +function createChartDataResource( + eventData: FlexibleEventData[], + fields: string[], + inputQuery: string, + chartType?: ChartType, +): EmbeddedResource { + 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: inputQuery, + }), + }, + }; +} + +function withChartData( + output: string, + params: FormatEventResultsParams, +): FormatterResult { + if (!isAggregateQuery(params.fields) || params.eventData.length === 0) { + return output; + } + + return [ + { type: "text", text: output }, + createChartDataResource( + params.eventData, + params.fields, + params.inputQuery, + params.chartType, + ), + ]; +} + function formatUserFieldLines( value: Record, options: { prefix?: string } = {}, @@ -139,7 +195,9 @@ function formatUserFieldLines( /** * Format error event results for display */ -export function formatErrorResults(params: FormatEventResultsParams): string { +export function formatErrorResults( + params: FormatEventResultsParams, +): FormatterResult { const { eventData, inputQuery, @@ -271,13 +329,15 @@ 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"; - return output; + return withChartData(output, params); } /** * Format log event results for display */ -export function formatLogResults(params: FormatEventResultsParams): string { +export function formatLogResults( + params: FormatEventResultsParams, +): FormatterResult { const { eventData, inputQuery, @@ -432,13 +492,15 @@ 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"; - return output; + return withChartData(output, params); } /** * Format span/trace event results for display */ -export function formatSpanResults(params: FormatEventResultsParams): string { +export function formatSpanResults( + params: FormatEventResultsParams, +): FormatterResult { const { eventData, inputQuery, @@ -571,7 +633,7 @@ export function formatSpanResults(params: FormatEventResultsParams): string { output += "- Export data: Use the Sentry web interface for advanced analysis\n"; - return output; + return withChartData(output, params); } function getProfileDurationLabel( @@ -630,7 +692,9 @@ function getProfileDetailUrl( return null; } -export function formatProfileResults(params: FormatEventResultsParams): string { +export function formatProfileResults( + params: FormatEventResultsParams, +): FormatterResult { const { eventData, inputQuery, @@ -789,7 +853,7 @@ export function formatProfileResults(params: FormatEventResultsParams): string { output += "- Refine the profiling search in Sentry by transaction, release, platform, or environment\n"; - return output; + return withChartData(output, params); } /** @@ -797,7 +861,7 @@ export function formatProfileResults(params: FormatEventResultsParams): string { */ export function formatTraceMetricsResults( params: FormatEventResultsParams, -): string { +): FormatterResult { const { eventData, inputQuery, @@ -925,5 +989,5 @@ export function formatTraceMetricsResults( output += "- Switch between samples and aggregates in Sentry for deeper analysis\n"; - return output; + return withChartData(output, params); } diff --git a/packages/mcp-core/src/tools/types.ts b/packages/mcp-core/src/tools/types.ts index 5cfd1187d..ed52fbb3d 100644 --- a/packages/mcp-core/src/tools/types.ts +++ b/packages/mcp-core/src/tools/types.ts @@ -78,6 +78,10 @@ export function isToolVisibleInMode( return true; } +export interface ToolUIConfig { + resourceUri: string; +} + export interface ToolConfig< TSchema extends Record = Record, > { @@ -100,6 +104,7 @@ export interface ToolConfig< idempotentHint?: boolean; openWorldHint: boolean; }; + ui?: ToolUIConfig; handler: ToolHandler; } diff --git a/packages/mcp-core/src/ui-resource-uris.ts b/packages/mcp-core/src/ui-resource-uris.ts new file mode 100644 index 000000000..05840795f --- /dev/null +++ b/packages/mcp-core/src/ui-resource-uris.ts @@ -0,0 +1,2 @@ +export const SEARCH_EVENTS_CHART_RESOURCE_URI = + "ui://sentry/search-events-chart.html"; diff --git a/packages/mcp-core/src/ui-resources.ts b/packages/mcp-core/src/ui-resources.ts new file mode 100644 index 000000000..55a2e62e1 --- /dev/null +++ b/packages/mcp-core/src/ui-resources.ts @@ -0,0 +1,20 @@ +/** + * 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"; +import { SEARCH_EVENTS_CHART_RESOURCE_URI } from "./ui-resource-uris"; + +/** + * Registry of UI resources keyed by their resource URI. + * The URI format follows the MCP Apps spec: ui:/// + */ +export const UI_RESOURCES: Record = { + [SEARCH_EVENTS_CHART_RESOURCE_URI]: { + html: searchEventsChartHtml, + name: "Search Events Chart", + }, +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c50d18793..dd8417b17 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -232,6 +232,31 @@ importers: specifier: ^7.0.15 version: 7.0.15 + packages/mcp-apps-ui: + dependencies: + '@modelcontextprotocol/ext-apps': + specifier: ^1.7.5 + version: 1.7.5(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) + 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.9.3) + vite: + specifier: 'catalog:' + version: 6.3.5(@types/node@24.0.10)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.9.0) + vite-plugin-singlefile: + specifier: ^2.0.3 + version: 2.3.3(rollup@4.62.2)(vite@6.3.5(@types/node@24.0.10)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.9.0)) + vitest: + specifier: 'catalog:' + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.0.10)(msw@2.10.2(@types/node@24.0.10)(typescript@5.9.3))(vite@6.3.5(@types/node@24.0.10)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.9.0)) + packages/mcp-cloudflare: dependencies: '@ai-sdk/mcp': @@ -394,6 +419,9 @@ importers: '@logtape/sentry': specifier: ^1.1.1 version: 1.1.1(@logtape/logtape@1.1.1) + '@modelcontextprotocol/ext-apps': + specifier: ^1.7.5 + version: 1.7.5(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) '@modelcontextprotocol/sdk': specifier: 1.29.0 version: 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3) @@ -403,6 +431,9 @@ importers: '@sentry/core': specifier: 'catalog:' version: 10.54.0 + '@sentry/mcp-apps-ui': + specifier: workspace:* + version: link:../mcp-apps-ui ai: specifier: 'catalog:' version: 6.0.64(zod@4.4.3) @@ -2080,6 +2111,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==} @@ -2096,6 +2130,20 @@ packages: resolution: {integrity: sha512-HKbY9XTbsDy1Y6r2I55TGE3JEapM0vg96e1MUmBIF9LGjos5gjhcIrTz1yvBPLg2aFKHjwhUAQfRdrCEnPxNew==} engines: {node: '>=20'} + '@modelcontextprotocol/ext-apps@1.7.5': + resolution: {integrity: sha512-TjPH2S2y5UEGKhmI6+XGFuqfqOV4ppe1x6DA3txnUaEWkgtA4G5vo14jGKFZmegdkZ1H4QMLyujLvoU1BEdnAg==} + engines: {node: '>=20'} + peerDependencies: + '@modelcontextprotocol/sdk': 1.29.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.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} @@ -3285,6 +3333,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'} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -3985,9 +4037,6 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true - jose@6.1.3: - resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} - jose@6.2.4: resolution: {integrity: sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==} @@ -5312,6 +5361,16 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-plugin-singlefile@2.3.3: + resolution: {integrity: sha512-XVnGH0QzbOa8fxRSsHdCarVN1BSBXNi7uLMQYlrGRN5apdHkk62XQWRJhVever0lnfuyBkwn+kvVChdm/OoOUg==} + engines: {node: '>18.0.0'} + peerDependencies: + rollup: ^4.59.0 + vite: ^5.4.21 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + rollup: + optional: true + vite@6.3.5: resolution: {integrity: sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -6684,6 +6743,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)': @@ -6705,6 +6766,15 @@ snapshots: dependencies: zod: 4.4.3 + '@modelcontextprotocol/ext-apps@1.7.5(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3)': + dependencies: + '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3) + '@standard-schema/spec': 1.1.0 + zod: 4.4.3 + optionalDependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + '@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3)': dependencies: '@hono/node-server': 1.19.9(hono@4.12.32) @@ -6714,13 +6784,13 @@ snapshots: cors: 2.8.5 cross-spawn: 7.0.6 eventsource: 3.0.7 - eventsource-parser: 3.0.6 + eventsource-parser: 3.1.0 express: 5.2.1 express-rate-limit: 8.2.1(express@5.2.1) hono: 4.12.32 - jose: 6.1.3 + jose: 6.2.4 json-schema-typed: 8.0.2 - pkce-challenge: 5.0.0 + pkce-challenge: 5.0.1 raw-body: 3.0.1 zod: 4.4.3 zod-to-json-schema: 3.25.1(zod@4.4.3) @@ -7515,6 +7585,15 @@ snapshots: msw: 2.10.2(@types/node@24.0.10)(typescript@5.9.3) vite: 6.3.5(@types/node@24.0.10)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3) + '@vitest/mocker@4.1.10(msw@2.10.2(@types/node@24.0.10)(typescript@5.9.3))(vite@6.3.5(@types/node@24.0.10)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.10.2(@types/node@24.0.10)(typescript@5.9.3) + vite: 6.3.5(@types/node@24.0.10)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.9.0) + '@vitest/pretty-format@4.1.10': dependencies: tinyrainbow: 3.1.0 @@ -7769,6 +7848,10 @@ snapshots: character-reference-invalid@2.0.1: {} + chart.js@4.5.1: + dependencies: + '@kurkle/color': 0.3.4 + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -8500,8 +8583,6 @@ snapshots: jiti@2.7.0: {} - jose@6.1.3: {} - jose@6.2.4: {} jpeg-js@0.4.4: {} @@ -10255,6 +10336,13 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.2 + vite-plugin-singlefile@2.3.3(rollup@4.62.2)(vite@6.3.5(@types/node@24.0.10)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.9.0)): + dependencies: + micromatch: 4.0.8 + vite: 6.3.5(@types/node@24.0.10)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.9.0) + optionalDependencies: + rollup: 4.62.2 + vite@6.3.5(@types/node@22.16.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.9.0): dependencies: esbuild: 0.25.12 @@ -10425,6 +10513,34 @@ snapshots: transitivePeerDependencies: - msw + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.0.10)(msw@2.10.2(@types/node@24.0.10)(typescript@5.9.3))(vite@6.3.5(@types/node@24.0.10)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(msw@2.10.2(@types/node@24.0.10)(typescript@5.9.3))(vite@6.3.5(@types/node@24.0.10)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.0.0 + tinybench: 2.9.0 + tinyexec: 1.0.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 6.3.5(@types/node@24.0.10)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 24.0.10 + transitivePeerDependencies: + - msw + walk-back@5.1.1: {} webidl-conversions@3.0.1: {}