Skip to content

Commit b6847a9

Browse files
dcramerclaude
andcommitted
feat(tools): Add MCP Apps visualization support for search_events
Add interactive chart visualizations for aggregate query results using the MCP Apps protocol. When search_events returns aggregate data, it now includes structured chart data that MCP Apps-compatible clients can render as bar, pie, line charts, tables, or single numbers. Key changes: - Create new mcp-apps-ui package with Chart.js-based visualization - Extend ToolConfig with optional UI metadata for resource URIs - Register UI resources in server for client fetching - Enhance formatters to return chart data alongside text for aggregates - Add chartType field to search_events agent for visualization hints The implementation maintains backward compatibility - clients without MCP Apps support continue to receive text responses. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent bcabee1 commit b6847a9

20 files changed

Lines changed: 1138 additions & 21 deletions

File tree

packages/mcp-apps-ui/package.json

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"name": "@sentry/mcp-apps-ui",
3+
"version": "0.29.0",
4+
"private": true,
5+
"type": "module",
6+
"license": "FSL-1.1-ALv2",
7+
"description": "MCP Apps UI components for Sentry MCP - Interactive chart visualizations",
8+
"files": ["./dist/*"],
9+
"exports": {
10+
".": {
11+
"types": "./dist/index.d.ts",
12+
"default": "./dist/index.js"
13+
}
14+
},
15+
"scripts": {
16+
"build": "tsdown && vite build && tsx scripts/bundle-apps.ts",
17+
"dev": "vite build --watch",
18+
"tsc": "tsc --noEmit"
19+
},
20+
"devDependencies": {
21+
"@sentry/mcp-server-tsconfig": "workspace:*",
22+
"tsdown": "catalog:",
23+
"vite": "catalog:",
24+
"vite-plugin-singlefile": "^2.0.3"
25+
},
26+
"dependencies": {
27+
"@modelcontextprotocol/ext-apps": "^0.4.0",
28+
"chart.js": "^4.4.7"
29+
}
30+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* Post-build script that reads bundled HTML apps and generates
3+
* TypeScript/JavaScript exports for consumption by other packages.
4+
*/
5+
6+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
7+
import { join, dirname } from "node:path";
8+
9+
const DIST_DIR = join(dirname(new URL(import.meta.url).pathname), "..", "dist");
10+
const APPS_DIR = join(DIST_DIR, "apps");
11+
12+
// Map of app names to their bundled HTML file
13+
// Vite outputs HTML files in nested directories preserving the source structure
14+
const APPS = {
15+
searchEventsChart: "src/apps/search-events-chart/index.html",
16+
};
17+
18+
function main() {
19+
// Ensure dist directory exists
20+
if (!existsSync(DIST_DIR)) {
21+
mkdirSync(DIST_DIR, { recursive: true });
22+
}
23+
24+
// Read bundled HTML files and generate exports
25+
const exports: Record<string, string> = {};
26+
27+
for (const [exportName, fileName] of Object.entries(APPS)) {
28+
const htmlPath = join(APPS_DIR, fileName);
29+
30+
if (!existsSync(htmlPath)) {
31+
console.error(`Warning: ${htmlPath} not found. Skipping.`);
32+
continue;
33+
}
34+
35+
const htmlContent = readFileSync(htmlPath, "utf-8");
36+
exports[exportName] = htmlContent;
37+
console.log(`Bundled ${fileName} -> ${exportName}Html`);
38+
}
39+
40+
// Generate JavaScript module
41+
const jsContent = `// Auto-generated by bundle-apps.ts - do not edit manually
42+
${Object.entries(exports)
43+
.map(
44+
([name, content]) =>
45+
`export const ${name}Html = ${JSON.stringify(content)};`,
46+
)
47+
.join("\n")}
48+
49+
// Re-export shared types and utilities
50+
export { inferChartType } from "./shared/chart-data.js";
51+
`;
52+
53+
writeFileSync(join(DIST_DIR, "index.js"), jsContent);
54+
console.log("Generated dist/index.js");
55+
56+
// Generate TypeScript declarations
57+
const dtsContent = `// Auto-generated by bundle-apps.ts - do not edit manually
58+
${Object.keys(exports)
59+
.map((name) => `export declare const ${name}Html: string;`)
60+
.join("\n")}
61+
62+
// Re-export shared types
63+
export type { ChartData, ChartType } from "./shared/chart-data.js";
64+
export { inferChartType } from "./shared/chart-data.js";
65+
`;
66+
67+
writeFileSync(join(DIST_DIR, "index.d.ts"), dtsContent);
68+
console.log("Generated dist/index.d.ts");
69+
}
70+
71+
main();
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
import { App } from "@modelcontextprotocol/ext-apps";
2+
import { Chart, registerables } from "chart.js";
3+
import { SENTRY_COLORS, CHART_DEFAULTS } from "../../shared/sentry-theme";
4+
import type { ChartData, ChartType } from "../../shared/chart-data";
5+
import { inferChartType } from "../../shared/chart-data";
6+
7+
// Register all Chart.js components
8+
Chart.register(...registerables);
9+
10+
// Apply global chart defaults
11+
Chart.defaults.font.family = CHART_DEFAULTS.font.family;
12+
Chart.defaults.font.size = CHART_DEFAULTS.font.size;
13+
Chart.defaults.color = CHART_DEFAULTS.colors.text;
14+
15+
const app = new App({ name: "Sentry Search Events Chart", version: "1.0.0" });
16+
17+
let currentChart: Chart | null = null;
18+
19+
/**
20+
* Parse chart data from tool result content
21+
*/
22+
function parseChartData(result: {
23+
content?: Array<{
24+
type: string;
25+
resource?: { mimeType?: string; text?: string };
26+
}>;
27+
}): ChartData | null {
28+
// Find the JSON resource containing chart data
29+
const chartResource = result.content?.find(
30+
(c) =>
31+
c.type === "resource" &&
32+
c.resource?.mimeType === "application/json;chart",
33+
);
34+
35+
if (!chartResource?.resource?.text) {
36+
return null;
37+
}
38+
39+
try {
40+
return JSON.parse(chartResource.resource.text) as ChartData;
41+
} catch {
42+
console.error("Failed to parse chart data");
43+
return null;
44+
}
45+
}
46+
47+
/**
48+
* Format a number for display (with thousands separators)
49+
*/
50+
function formatNumber(value: unknown): string {
51+
if (typeof value === "number") {
52+
return value.toLocaleString();
53+
}
54+
return String(value);
55+
}
56+
57+
/**
58+
* Render a single number display
59+
*/
60+
function renderNumberDisplay(data: ChartData): void {
61+
const content = document.getElementById("content");
62+
if (!content) return;
63+
64+
const value = data.data[0]?.[data.values[0]];
65+
66+
content.innerHTML = `
67+
<div class="number-display">
68+
<div class="number-value">${formatNumber(value)}</div>
69+
<div class="number-label">${data.values[0]}</div>
70+
</div>
71+
`;
72+
}
73+
74+
/**
75+
* Render a data table
76+
*/
77+
function renderTable(data: ChartData): void {
78+
const content = document.getElementById("content");
79+
if (!content) return;
80+
81+
const allColumns = [...data.labels, ...data.values];
82+
83+
let tableHtml = `
84+
<div class="table-container">
85+
<table>
86+
<thead>
87+
<tr>
88+
${allColumns.map((col) => `<th>${col}</th>`).join("")}
89+
</tr>
90+
</thead>
91+
<tbody>
92+
`;
93+
94+
for (const row of data.data) {
95+
tableHtml += "<tr>";
96+
for (const col of allColumns) {
97+
const value = row[col];
98+
tableHtml += `<td>${formatNumber(value)}</td>`;
99+
}
100+
tableHtml += "</tr>";
101+
}
102+
103+
tableHtml += `
104+
</tbody>
105+
</table>
106+
</div>
107+
`;
108+
109+
content.innerHTML = tableHtml;
110+
}
111+
112+
/**
113+
* Render a Chart.js chart
114+
*/
115+
function renderChart(data: ChartData, chartType: ChartType): void {
116+
const content = document.getElementById("content");
117+
if (!content) return;
118+
119+
// Clean up existing chart
120+
if (currentChart) {
121+
currentChart.destroy();
122+
currentChart = null;
123+
}
124+
125+
content.innerHTML = `
126+
<div class="chart-container">
127+
<canvas id="chart"></canvas>
128+
</div>
129+
`;
130+
131+
const canvas = document.getElementById("chart") as HTMLCanvasElement;
132+
if (!canvas) return;
133+
134+
const ctx = canvas.getContext("2d");
135+
if (!ctx) return;
136+
137+
// Extract labels (x-axis values)
138+
const labelField = data.labels[0] || "label";
139+
const labels = data.data.map((d) => String(d[labelField] || "Unknown"));
140+
141+
// Extract values (y-axis datasets)
142+
const datasets = data.values.map((valueField, index) => ({
143+
label: valueField,
144+
data: data.data.map((d) => {
145+
const val = d[valueField];
146+
return typeof val === "number" ? val : 0;
147+
}),
148+
backgroundColor:
149+
chartType === "pie"
150+
? data.data.map(
151+
(_, i) => SENTRY_COLORS.series[i % SENTRY_COLORS.series.length],
152+
)
153+
: SENTRY_COLORS.series[index % SENTRY_COLORS.series.length],
154+
borderColor:
155+
chartType === "line"
156+
? SENTRY_COLORS.series[index % SENTRY_COLORS.series.length]
157+
: undefined,
158+
borderWidth: chartType === "line" ? 2 : 0,
159+
fill: chartType === "line" ? false : undefined,
160+
tension: chartType === "line" ? 0.3 : undefined,
161+
}));
162+
163+
// Map our chart types to Chart.js types (number/table are handled separately)
164+
const chartJsTypeMap: Record<string, "bar" | "pie" | "line"> = {
165+
pie: "pie",
166+
line: "line",
167+
};
168+
const chartJsType = chartJsTypeMap[chartType] ?? "bar";
169+
170+
currentChart = new Chart(ctx, {
171+
type: chartJsType,
172+
data: {
173+
labels,
174+
datasets,
175+
},
176+
options: {
177+
responsive: true,
178+
maintainAspectRatio: false,
179+
plugins: {
180+
legend: {
181+
display: datasets.length > 1 || chartType === "pie",
182+
position: chartType === "pie" ? "right" : "top",
183+
},
184+
title: {
185+
display: false,
186+
},
187+
},
188+
scales:
189+
chartType === "pie"
190+
? {}
191+
: {
192+
x: {
193+
grid: {
194+
color: CHART_DEFAULTS.colors.gridLines,
195+
},
196+
},
197+
y: {
198+
beginAtZero: true,
199+
grid: {
200+
color: CHART_DEFAULTS.colors.gridLines,
201+
},
202+
},
203+
},
204+
},
205+
});
206+
}
207+
208+
/**
209+
* Main render function
210+
*/
211+
function render(data: ChartData): void {
212+
const title = document.getElementById("title");
213+
const subtitle = document.getElementById("subtitle");
214+
215+
if (title) {
216+
title.textContent = data.query;
217+
}
218+
219+
if (subtitle) {
220+
subtitle.textContent = `${data.data.length} result${data.data.length === 1 ? "" : "s"}`;
221+
}
222+
223+
// Determine chart type (use provided or infer)
224+
const chartType: ChartType =
225+
data.chartType || inferChartType(data.data, data.labels, data.values);
226+
227+
switch (chartType) {
228+
case "number":
229+
renderNumberDisplay(data);
230+
break;
231+
case "table":
232+
renderTable(data);
233+
break;
234+
default:
235+
renderChart(data, chartType);
236+
}
237+
}
238+
239+
/**
240+
* Show error message
241+
*/
242+
function showError(message: string): void {
243+
const content = document.getElementById("content");
244+
if (content) {
245+
content.innerHTML = `<div class="error">${message}</div>`;
246+
}
247+
}
248+
249+
// Handle tool results from the server
250+
app.ontoolresult = (result) => {
251+
const chartData = parseChartData(result);
252+
253+
if (chartData) {
254+
render(chartData);
255+
} else {
256+
showError("No chart data available in the tool response.");
257+
}
258+
};
259+
260+
// Connect to the host
261+
app.connect().catch((error) => {
262+
console.error("Failed to connect to MCP host:", error);
263+
showError("Failed to connect to visualization host.");
264+
});

0 commit comments

Comments
 (0)