Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions packages/mcp-apps-ui/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
71 changes: 71 additions & 0 deletions packages/mcp-apps-ui/scripts/bundle-apps.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {};

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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Windows path breaks app bundling

Medium Severity

bundle-apps.ts resolves paths with new URL(import.meta.url).pathname instead of fileURLToPath. On Windows that yields an invalid path (leading slash / encoded characters), so the bundled HTML is not found. The script only warns and continues, so the build can succeed without exporting searchEventsChartHtml, leaving the UI resource empty.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6388d9c. Configure here.


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();
248 changes: 248 additions & 0 deletions packages/mcp-apps-ui/src/apps/search-events-chart/app.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Comment thread
cursor[bot] marked this conversation as resolved.

/**
* 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,
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chart instance not destroyed on switch

Low Severity

renderChart destroys currentChart before creating a new chart, but renderNumberDisplay and renderTable replace #content via innerHTML without destroying an existing Chart.js instance. A later number/table result after a chart leaves a live chart bound to a detached canvas.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6388d9c. Configure here.


/**
* 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 = `
<div class="chart-container">
<canvas id="chart"></canvas>
</div>
`;

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<string, "bar" | "pie" | "line"> = {
pie: "pie",
line: "line",
};
const chartJsType = chartJsTypeMap[chartType] ?? "bar";

Check warning on line 153 in packages/mcp-apps-ui/src/apps/search-events-chart/app.ts

View check run for this annotation

@sentry/warden / warden: code-review

Chart labels show "Unknown" for falsy values like 0 and false

In `renderChart`, labels with numeric value `0`, boolean `false`, or empty string are incorrectly replaced with "Unknown" instead of being displayed correctly.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chart labels show "Unknown" for falsy values like 0 and false

In renderChart, labels with numeric value 0, boolean false, or empty string are incorrectly replaced with "Unknown" instead of being displayed correctly.

Evidence
  • Line 123 of app.ts evaluates String(d[labelField] || "Unknown"), which treats any falsy label value as missing.
  • Aggregate group-by fields can legitimately be 0 (e.g., a numeric status code), false (a boolean flag), or "" (an empty tag); these should appear on the axis/pie slice, not as "Unknown".
  • String(0 || "Unknown") yields "Unknown"; String(false || "Unknown") yields "Unknown".
  • The fix is String(d[labelField] ?? "Unknown") so only null/undefined fall back to "Unknown".
  • No tests in the PR cover chart label rendering with falsy field values.

Identified by Warden · code-review · N2L-S6C

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.");
});
Loading
Loading