mcp-dashboards 2.1.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +291 -244
- package/THIRD_PARTY_LICENSES.md +337 -0
- package/dist/index.js +38 -3
- package/dist/index.js.map +1 -1
- package/dist/mcp-app.html +543 -416
- package/dist/preview-server.d.ts +3 -2
- package/dist/preview-server.d.ts.map +1 -1
- package/dist/preview-server.js +69 -10
- package/dist/preview-server.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +453 -86
- package/dist/server.js.map +1 -1
- package/dist/url-safety.d.ts +30 -0
- package/dist/url-safety.d.ts.map +1 -0
- package/dist/url-safety.js +164 -0
- package/dist/url-safety.js.map +1 -0
- package/package.json +112 -103
package/dist/server.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { registerAppResource, registerAppTool, RESOURCE_MIME_TYPE, } from "@modelcontextprotocol/ext-apps/server";
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { z } from "zod";
|
|
8
|
-
import { getPreviewUrls } from "./preview-server.js";
|
|
8
|
+
import { getPreviewUrls, evictChartFromCache, TEMP_DIR, CHART_FILENAME_RE } from "./preview-server.js";
|
|
9
|
+
import { assertSafeUrl, acquireOutbound, UrlSafetyError } from "./url-safety.js";
|
|
9
10
|
// Works both from source (server.ts) and compiled (dist/server.js)
|
|
10
11
|
const __filename = fileURLToPath(import.meta.url);
|
|
11
12
|
const __dirname = path.dirname(__filename);
|
|
@@ -20,7 +21,7 @@ const PieDataItem = z.object({
|
|
|
20
21
|
});
|
|
21
22
|
const ColorsOption = z.array(z.string()).optional().describe("Custom color palette as hex codes (e.g. ['#FF6384', '#36A2EB']). Uses default palette if omitted.");
|
|
22
23
|
// Shared theme parameters for all chart tools
|
|
23
|
-
const ThemeParam = z.string().optional().describe("Theme preset: boardroom, corporate, sales-floor, golden-treasury, clinical, startup, ops-control, tokyo-midnight, zen-garden, consultant, black-tron, black-elegance, black-matrix, forest-amber, forest-earth, sky-light, sky-ocean, sky-twilight, gray-hf, gray-copilot");
|
|
24
|
+
const ThemeParam = z.string().optional().describe("Theme preset: boardroom, corporate, sales-floor, golden-treasury, clinical, startup, ops-control, tokyo-midnight, zen-garden, consultant, black-tron, black-elegance, black-matrix, forest-amber, forest-earth, sky-light, sky-ocean, sky-twilight, gray-hf, gray-copilot, office-red");
|
|
24
25
|
const PaletteParam = z.string().optional().describe("Override palette only (mix-and-match)");
|
|
25
26
|
const TypographyParam = z.string().optional().describe("Override typography: professional, luxury, cyberpunk, editorial, mono, bold, system, techno");
|
|
26
27
|
const EffectsParam = z.string().optional().describe("Override effects: none, subtle, shimmer, neon, energetic");
|
|
@@ -167,35 +168,53 @@ function _registerChartTool(server, name, meta, inputSchema, buildResult, summar
|
|
|
167
168
|
return await _buildChartResult(server, chartData, summarize(args));
|
|
168
169
|
});
|
|
169
170
|
}
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
|
|
171
|
+
// Builds the standard tool response. Writes a self-contained HTML file for the
|
|
172
|
+
// chart AND lazy-starts a same-machine HTTP server (127.0.0.1, random port) so
|
|
173
|
+
// the chart can be opened, saved, and shared from a browser regardless of
|
|
174
|
+
// which client is in use:
|
|
175
|
+
//
|
|
176
|
+
// - Apps-aware clients (Claude Code, Claude Desktop, VS Code Insiders):
|
|
177
|
+
// chart renders inline from structuredContent + _meta.ui.resourceUri.
|
|
178
|
+
// The localhost URL is still useful for "open in browser to print", save
|
|
179
|
+
// a permanent copy, or share with a colleague.
|
|
180
|
+
//
|
|
181
|
+
// - Non-Apps clients (generic LLM IDE plugins, older clients): the URL is
|
|
182
|
+
// the only way to view the chart at all.
|
|
183
|
+
//
|
|
184
|
+
// Why we always emit URLs (no capability detection): Claude Code v2.1.x does
|
|
185
|
+
// not advertise capabilities.extensions["io.modelcontextprotocol/ui"], so any
|
|
186
|
+
// capability-based detection silently treats it as non-Apps and is therefore
|
|
187
|
+
// dead code. The shareable HTML link is genuinely useful even when the chart
|
|
188
|
+
// renders inline.
|
|
189
|
+
//
|
|
190
|
+
// Opt out entirely with MCP_DASHBOARDS_DISABLE_PREVIEW=1.
|
|
191
|
+
async function _buildChartResult(_server, chartData, summary) {
|
|
192
|
+
const urls = await getPreviewUrls(chartData);
|
|
193
|
+
// Mirror preview URLs into structuredContent for clients that surface it
|
|
194
|
+
// (e.g. Claude Code dumps structuredContent as JSON). Renderers ignore
|
|
195
|
+
// unknown fields, so MCP Apps clients are unaffected.
|
|
196
|
+
const enrichedChartData = urls
|
|
197
|
+
? { ...chartData, preview: { browser: urls.httpUrl, file: urls.fileUrl } }
|
|
198
|
+
: chartData;
|
|
199
|
+
const enrichedSummary = urls
|
|
200
|
+
? `${summary}\n\n[Open chart in browser](${urls.httpUrl}) - clickable link to view the interactive chart. AI assistant: please include this link verbatim in your reply so the user can click it. The standalone HTML file at ${urls.fileUrl} can be saved or shared (auto-deleted after 7 days). The user can ask you to delete chart files anytime via the delete_chart_files tool.`
|
|
201
|
+
: summary;
|
|
178
202
|
const content = [
|
|
179
|
-
{ type: "text", text:
|
|
203
|
+
{ type: "text", text: enrichedSummary },
|
|
180
204
|
];
|
|
181
|
-
if (
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
`**Save or share:** ${urls.fileUrl}\n` +
|
|
192
|
-
` - Self-contained HTML file on your disk\n` +
|
|
193
|
-
` - Works offline, survives server restart, can be emailed or archived\n`,
|
|
194
|
-
});
|
|
195
|
-
}
|
|
205
|
+
if (urls) {
|
|
206
|
+
content.push({
|
|
207
|
+
type: "text",
|
|
208
|
+
text: `\n## View this chart\n` +
|
|
209
|
+
`If the chart renders inline above, you're done. Otherwise click the link below:\n\n` +
|
|
210
|
+
`**[Open chart in browser](${urls.httpUrl})**\n\n` +
|
|
211
|
+
`Or save/share the standalone HTML file: ${urls.fileUrl}\n` +
|
|
212
|
+
`- Auto-deleted after ${process.env.MCP_DASHBOARDS_RETAIN_DAYS ?? "7"} days (configure via MCP_DASHBOARDS_RETAIN_DAYS, 0 disables)\n` +
|
|
213
|
+
`- Use the chart's download button inside the rendered HTML to save a permanent copy (PNG / PPT / A4)\n`,
|
|
214
|
+
});
|
|
196
215
|
}
|
|
197
|
-
content.push({ type: "text", text: JSON.stringify(
|
|
198
|
-
return { content, structuredContent:
|
|
216
|
+
content.push({ type: "text", text: JSON.stringify(enrichedChartData) });
|
|
217
|
+
return { content, structuredContent: enrichedChartData };
|
|
199
218
|
}
|
|
200
219
|
/**
|
|
201
220
|
* Creates a new MCP Dashboards server instance.
|
|
@@ -203,14 +222,8 @@ async function _buildChartResult(server, chartData, summary) {
|
|
|
203
222
|
export function createServer() {
|
|
204
223
|
const server = new McpServer({
|
|
205
224
|
name: "MCP Dashboards",
|
|
206
|
-
version: "2.
|
|
225
|
+
version: "2.4.0",
|
|
207
226
|
});
|
|
208
|
-
// After initialize handshake, detect if client supports MCP Apps inline rendering.
|
|
209
|
-
// Clients that do will see the interactive chart; those that don't get preview URLs.
|
|
210
|
-
server.server.oninitialized = () => {
|
|
211
|
-
const caps = server.server.getClientCapabilities?.();
|
|
212
|
-
_clientSupportsInline.set(server, !!getUiCapability(caps));
|
|
213
|
-
};
|
|
214
227
|
// -- Shared HTML resource --
|
|
215
228
|
registerAppResource(server, RESOURCE_URI, RESOURCE_URI, { mimeType: RESOURCE_MIME_TYPE }, async () => {
|
|
216
229
|
const html = await fs.readFile(path.join(DIST_DIR, "mcp-app.html"), "utf-8");
|
|
@@ -218,7 +231,6 @@ export function createServer() {
|
|
|
218
231
|
contents: [{ uri: RESOURCE_URI, mimeType: RESOURCE_MIME_TYPE, text: html }],
|
|
219
232
|
};
|
|
220
233
|
});
|
|
221
|
-
// -- Tool: render_pie_chart --
|
|
222
234
|
registerAppTool(server, "render_pie_chart", {
|
|
223
235
|
title: "Pie Chart",
|
|
224
236
|
description: "Render an interactive pie or donut chart from key-value data. Provide an array of {label, value} pairs. Supports themes for styled visuals.",
|
|
@@ -250,7 +262,6 @@ export function createServer() {
|
|
|
250
262
|
.join(", ");
|
|
251
263
|
return await _buildChartResult(server, chartData, `${args.title}: ${summary}`);
|
|
252
264
|
});
|
|
253
|
-
// -- Tool: render_bar_chart --
|
|
254
265
|
registerAppTool(server, "render_bar_chart", {
|
|
255
266
|
title: "Bar Chart",
|
|
256
267
|
description: "Render an interactive bar chart. Supports vertical/horizontal, stacked, multi-series, and click-to-drill-down (options.drilldown). Supports themes for styled visuals.",
|
|
@@ -283,7 +294,6 @@ export function createServer() {
|
|
|
283
294
|
.join("; ");
|
|
284
295
|
return await _buildChartResult(server, chartData, `${args.title} - ${summary}`);
|
|
285
296
|
});
|
|
286
|
-
// -- Tool: render_line_chart --
|
|
287
297
|
registerAppTool(server, "render_line_chart", {
|
|
288
298
|
title: "Line Chart",
|
|
289
299
|
description: "Render an interactive line or area chart. Supports smooth curves, gradient fill, and multiple series. Supports themes for styled visuals.",
|
|
@@ -316,7 +326,6 @@ export function createServer() {
|
|
|
316
326
|
.join("; ");
|
|
317
327
|
return await _buildChartResult(server, chartData, `${args.title} - ${summary}`);
|
|
318
328
|
});
|
|
319
|
-
// -- Tool: render_hero_metric --
|
|
320
329
|
const GemTypeEnum = z.enum([
|
|
321
330
|
"diamond", "ruby", "sapphire", "emerald",
|
|
322
331
|
"golden_pearl", "white_pearl", "black_pearl", "crystal",
|
|
@@ -424,7 +433,6 @@ export function createServer() {
|
|
|
424
433
|
const summary = `${args.title}: [${variant}] ${args.value ?? ""}${args.unit ? " " + args.unit : ""}`;
|
|
425
434
|
return await _buildChartResult(server, chartData, summary);
|
|
426
435
|
});
|
|
427
|
-
// -- Tool: render_dashboard --
|
|
428
436
|
const HeroSchema = z.object({
|
|
429
437
|
variant: HeroVariantEnum.optional().describe("Hero variant (default: progress_ring for dashboard)"),
|
|
430
438
|
value: z.union([z.string(), z.number()]).optional().describe("Hero metric value"),
|
|
@@ -454,7 +462,7 @@ export function createServer() {
|
|
|
454
462
|
});
|
|
455
463
|
registerAppTool(server, "render_dashboard", {
|
|
456
464
|
title: "Dashboard",
|
|
457
|
-
description: "Render a full dashboard with KPI cards, charts, and optional hero metric in a responsive grid. Available themes: boardroom (investors, board decks), corporate (enterprise daily use), sales-floor (quota tracking, leaderboards), golden-treasury (wealth, luxury real estate), clinical (healthcare, compliance - WCAG AAA), startup (SaaS metrics, YC demos), ops-control (DevOps, manufacturing), tokyo-midnight (crypto, trading, gaming), zen-garden (wellness, sustainability), consultant (agency deliverables, presentations). Mix-and-match: set palette + typography + effects independently.",
|
|
465
|
+
description: "Render a full dashboard with KPI cards, charts, and optional hero metric in a responsive grid. Available themes: boardroom (investors, board decks), corporate (enterprise daily use), sales-floor (quota tracking, leaderboards), golden-treasury (wealth, luxury real estate), clinical (healthcare, compliance - WCAG AAA), startup (SaaS metrics, YC demos), ops-control (DevOps, manufacturing), tokyo-midnight (crypto, trading, gaming), zen-garden (wellness, sustainability), consultant (agency deliverables, presentations), office-red (corporate report-style, Word/PowerPoint aesthetic). Mix-and-match: set palette + typography + effects independently.",
|
|
458
466
|
inputSchema: {
|
|
459
467
|
title: z.string().describe("Dashboard title"),
|
|
460
468
|
kpis: z.array(KpiSchema).optional().describe("KPI cards at the top. Include sparkline[] with 5-20 trend values whenever a metric has a % change - this adds an inline mini chart to the card"),
|
|
@@ -498,10 +506,9 @@ export function createServer() {
|
|
|
498
506
|
}
|
|
499
507
|
return await _buildChartResult(server, chartData, parts.join(" | "));
|
|
500
508
|
});
|
|
501
|
-
// -- Tool: render_chart_catalog --
|
|
502
509
|
registerAppTool(server, "render_chart_catalog", {
|
|
503
510
|
title: "Chart Catalog",
|
|
504
|
-
description: "Show a visual catalog of every available chart type as a dashboard of mini previews. Click any card to learn more about that chart tool.",
|
|
511
|
+
description: "Show a visual catalog of every available chart type as a dashboard of mini previews. Click any card to learn more about that chart tool. For a master entry point covering all customization dimensions, see render_catalog.",
|
|
505
512
|
inputSchema: {
|
|
506
513
|
theme: ThemeParam,
|
|
507
514
|
},
|
|
@@ -541,13 +548,24 @@ export function createServer() {
|
|
|
541
548
|
{ type: "geo", title: "render_geo_chart", data: [
|
|
542
549
|
{ country: "US", value: 80 }, { country: "DE", value: 60 }, { country: "IN", value: 50 }, { country: "BR", value: 35 }, { country: "AU", value: 25 },
|
|
543
550
|
] },
|
|
551
|
+
{ type: "bubble_map", title: "render_bubble_map", data: [
|
|
552
|
+
{ label: "New York", latitude: 40.7, longitude: -74.0, value: 80 }, { label: "London", latitude: 51.5, longitude: -0.1, value: 60 },
|
|
553
|
+
{ label: "Tokyo", latitude: 35.7, longitude: 139.7, value: 50 }, { label: "Mumbai", latitude: 19.1, longitude: 72.9, value: 45 }, { label: "Sydney", latitude: -33.9, longitude: 151.2, value: 30 },
|
|
554
|
+
] },
|
|
555
|
+
{ type: "scatter", title: "render_scatter_chart", datasets: [
|
|
556
|
+
{ label: "Series", data: [{ x: 10, y: 20 }, { x: 25, y: 35 }, { x: 33, y: 28 }, { x: 45, y: 52 }, { x: 58, y: 41 }, { x: 70, y: 63 }, { x: 82, y: 55 }] },
|
|
557
|
+
] },
|
|
558
|
+
{ type: "candlestick", title: "render_candlestick_chart", data: [
|
|
559
|
+
{ date: "2024-01-02", o: 100, h: 110, l: 95, c: 108 }, { date: "2024-01-03", o: 108, h: 115, l: 104, c: 106 }, { date: "2024-01-04", o: 106, h: 112, l: 100, c: 111 },
|
|
560
|
+
{ date: "2024-01-05", o: 111, h: 120, l: 109, c: 118 }, { date: "2024-01-08", o: 118, h: 122, l: 112, c: 114 },
|
|
561
|
+
], options: {} },
|
|
544
562
|
// -- CSS-delegated charts --
|
|
545
563
|
// Dashboard spreads c.data then c into payload. For array data,
|
|
546
564
|
// ...c puts data:[array] which is what renderers read. Extra props
|
|
547
565
|
// go at root level so ...c spreads them into the payload.
|
|
548
|
-
{ type: "bullet", title: "render_bullet_chart", data:
|
|
549
|
-
label: "Revenue", actual: 275, target: 300,
|
|
550
|
-
|
|
566
|
+
{ type: "bullet", title: "render_bullet_chart", data: [
|
|
567
|
+
{ label: "Revenue", actual: 275, target: 300, zones: [150, 225, 300] },
|
|
568
|
+
] },
|
|
551
569
|
{ type: "lollipop", title: "render_lollipop_chart", data: [
|
|
552
570
|
{ label: "Alpha", value: 85 }, { label: "Beta", value: 62 }, { label: "Gamma", value: 43 }, { label: "Delta", value: 91 },
|
|
553
571
|
] },
|
|
@@ -585,14 +603,24 @@ export function createServer() {
|
|
|
585
603
|
{ label: "Launch", status: "pending", date: "Jun" },
|
|
586
604
|
{ label: "Scale", status: "pending", date: "Sep" },
|
|
587
605
|
] },
|
|
606
|
+
{ type: "sparkline", title: "render_sparkline_chart", data: [
|
|
607
|
+
{ label: "Revenue", value: "$1.2M", change: "+12%", sparkline: [3, 5, 4, 7, 6, 9, 8], good: true },
|
|
608
|
+
{ label: "Users", value: "8.4K", change: "+5%", sparkline: [5, 6, 5, 7, 8, 9, 11], good: true },
|
|
609
|
+
{ label: "Churn", value: "2.1%", change: "-3%", sparkline: [9, 8, 8, 6, 7, 5, 4], good: false },
|
|
610
|
+
] },
|
|
611
|
+
// -- Diagram preview (static thumbnail, not the live full-canvas tool) --
|
|
612
|
+
{ type: "flowchart_preview", title: "render_flowchart" },
|
|
588
613
|
// -- Hero types --
|
|
589
614
|
{ type: "hero", title: "render_hero_metric", data: {
|
|
590
615
|
variant: "progress_ring", value: 87, unit: "%", label: "Completion", progress: 87,
|
|
591
616
|
} },
|
|
592
617
|
];
|
|
593
618
|
const kpis = [
|
|
594
|
-
|
|
595
|
-
|
|
619
|
+
// Count derived from the tiles rendered so it never drifts. The "+"
|
|
620
|
+
// acknowledges standalone tools (table, live, from_json/url, poll_http)
|
|
621
|
+
// that are real render tools but not visual catalog tiles.
|
|
622
|
+
{ label: "Chart types", value: charts.length, suffix: "+ types" },
|
|
623
|
+
{ label: "Themes", value: 21, suffix: " presets" },
|
|
596
624
|
];
|
|
597
625
|
const chartData = {
|
|
598
626
|
type: "dashboard",
|
|
@@ -601,27 +629,119 @@ export function createServer() {
|
|
|
601
629
|
charts,
|
|
602
630
|
columns: 3,
|
|
603
631
|
theme: args.theme,
|
|
604
|
-
footer: { text: "mcp-dashboards", lastUpdated: "
|
|
632
|
+
footer: { text: "mcp-dashboards", lastUpdated: "Standalone tools (not dashboard tiles): render_flowchart, render_table, render_from_json, render_from_url, render_live_chart, poll_http. Master catalog: render_catalog." },
|
|
605
633
|
};
|
|
606
|
-
return await _buildChartResult(server, chartData,
|
|
634
|
+
return await _buildChartResult(server, chartData, `Chart Catalog: ${charts.length}+ chart types, each a live preview. Click any card to ask about it. A few standalone tools (table, live, from_json, from_url, poll_http) are not visual tiles and are listed in the footer. The flowchart tile is a static preview - render_flowchart needs the full canvas, so it is a standalone tool, not a dashboard tile.`);
|
|
607
635
|
});
|
|
608
|
-
// -- Tool: render_theme_catalog --
|
|
609
636
|
registerAppTool(server, "render_theme_catalog", {
|
|
610
637
|
title: "Theme Catalog",
|
|
611
|
-
description: "Show a visual catalog of all 21 available themes. Each card previews the theme's colors, typography, and effects. Click any card to use that theme.",
|
|
638
|
+
description: "Show a visual catalog of all 21 available themes. Each card previews the theme's colors, typography, and effects. Click any card to use that theme. For a master entry point covering all customization dimensions, see render_catalog.",
|
|
612
639
|
inputSchema: {},
|
|
613
640
|
_meta: { ui: { resourceUri: RESOURCE_URI } },
|
|
614
641
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
|
615
642
|
}, async () => {
|
|
616
|
-
return {
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
643
|
+
return await _buildChartResult(server, { type: "theme_catalog" }, "Theme Catalog: 21 theme previews with color swatches, typography, and effects. Click any card to use it.");
|
|
644
|
+
});
|
|
645
|
+
registerAppTool(server, "render_hero_catalog", {
|
|
646
|
+
title: "Hero Variant Catalog",
|
|
647
|
+
description: "Show all 11 hero metric variants in a single dashboard - big_number, progress_ring, status, comparison, rank, countdown, threshold, breakdown, nps, orb, gem. Each card is a working preview with sample data. Click any card to learn about that variant.",
|
|
648
|
+
inputSchema: { theme: ThemeParam },
|
|
649
|
+
_meta: { ui: { resourceUri: RESOURCE_URI } },
|
|
650
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
|
651
|
+
}, async (args) => {
|
|
652
|
+
const charts = [
|
|
653
|
+
{ type: "hero", title: "Big number", data: { variant: "big_number", value: 47200, unit: "$", label: "MRR", change: 12.4, changePeriod: "vs last month", sparkline: [38, 39, 41, 40, 42, 43, 45, 46, 47] } },
|
|
654
|
+
{ type: "hero", title: "Progress ring", data: { variant: "progress_ring", value: 87, unit: "%", label: "Q3 target", progress: 87 } },
|
|
655
|
+
{ type: "hero", title: "Status", data: { variant: "status", label: "System health", statusLevel: "good", subsystems: [{ name: "API", status: "good" }, { name: "DB", status: "warn" }, { name: "Cache", status: "good" }], count: 1247 } },
|
|
656
|
+
{ type: "hero", title: "Comparison", data: { variant: "comparison", before: 23.5, after: 41.2, improvement: "+75%", beforeLabel: "Last Q", afterLabel: "This Q" } },
|
|
657
|
+
{ type: "hero", title: "Rank", data: { variant: "rank", rank: 3, total: 247, percentile: 99, rankChange: 5 } },
|
|
658
|
+
{ type: "hero", title: "Countdown", data: { variant: "countdown", segments: [{ value: 7, label: "days" }, { value: 14, label: "hours" }, { value: 32, label: "min" }] } },
|
|
659
|
+
{ type: "hero", title: "Threshold", data: { variant: "threshold", value: 78, max: 100, threshold: 80, unit: "%", zones: [{ label: "Safe", from: 0, to: 60, color: "#22c55e" }, { label: "Watch", from: 60, to: 80, color: "#eab308" }, { label: "Critical", from: 80, to: 100, color: "#ef4444" }] } },
|
|
660
|
+
{ type: "hero", title: "Breakdown", data: { variant: "breakdown", items: [{ label: "Tech", value: 40, color: "#3b82f6" }, { label: "Sales", value: 30, color: "#22c55e" }, { label: "Ops", value: 20, color: "#eab308" }, { label: "Other", value: 10, color: "#94a3b8" }] } },
|
|
661
|
+
{ type: "hero", title: "NPS", data: { variant: "nps", value: 67, max: 100, rating: "good" } },
|
|
662
|
+
{ type: "hero", title: "Orb", data: { variant: "orb", value: 87, unit: "%", label: "Active users", color: "#3b82f6" } },
|
|
663
|
+
{ type: "hero", title: "Gem", data: { variant: "gem", value: 1247, label: "Net worth", unit: "k", gemType: "diamond" } },
|
|
664
|
+
];
|
|
665
|
+
const chartData = {
|
|
666
|
+
type: "dashboard",
|
|
667
|
+
title: "Hero Variant Catalog",
|
|
668
|
+
kpis: [
|
|
669
|
+
{ label: "Variants", value: charts.length, suffix: " types" },
|
|
620
670
|
],
|
|
621
|
-
|
|
671
|
+
charts,
|
|
672
|
+
columns: 3,
|
|
673
|
+
theme: args.theme,
|
|
674
|
+
footer: { text: "Tool: render_hero_metric - pass variant=<name>. Tip: gem has 8 sub-types (diamond, ruby, sapphire, emerald, golden_pearl, white_pearl, black_pearl, crystal)." },
|
|
622
675
|
};
|
|
676
|
+
return await _buildChartResult(server, chartData, `Hero Catalog: ${charts.length} variants of render_hero_metric. Click any card to ask about it.`);
|
|
677
|
+
});
|
|
678
|
+
registerAppTool(server, "render_effects_catalog", {
|
|
679
|
+
title: "Effects Catalog",
|
|
680
|
+
description: "Show a visual catalog of all 5 effect presets - none, subtle, shimmer, neon, energetic. Each card previews the effect and lists the underlying treatments it bundles. Click any card to use that effect. For a master entry point covering all customization dimensions, see render_catalog.",
|
|
681
|
+
inputSchema: {},
|
|
682
|
+
_meta: { ui: { resourceUri: RESOURCE_URI } },
|
|
683
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
|
684
|
+
}, async () => {
|
|
685
|
+
return await _buildChartResult(server, { type: "effects_catalog" }, "Effects Catalog: 5 effect previews with the bundled treatments labeled. Click any card to use it. Pass effects=<name> on any chart to override theme defaults.");
|
|
686
|
+
});
|
|
687
|
+
registerAppTool(server, "render_catalog", {
|
|
688
|
+
title: "Master Catalog",
|
|
689
|
+
description: "Master entry point to discover everything mcp-dashboards can do. Each tile is a live visual preview of one customization dimension - a real bar chart for charts, a pie for themes, a progress ring for hero variants, a neon-glowing card for effects. Click any tile then hit Ask, and the AI will route to the matching sub-catalog tool (render_chart_catalog, render_theme_catalog, render_hero_catalog, render_effects_catalog).",
|
|
690
|
+
inputSchema: { theme: ThemeParam },
|
|
691
|
+
_meta: { ui: { resourceUri: RESOURCE_URI } },
|
|
692
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
|
693
|
+
}, async (args) => {
|
|
694
|
+
// Each tile is a real working chart - the card title carries the tool
|
|
695
|
+
// name so click + Ask routes the model to the matching sub-catalog,
|
|
696
|
+
// and the visual itself is what that catalog contains (variety of
|
|
697
|
+
// chart types, palette colors, a hero metric, an effect glow).
|
|
698
|
+
const charts = [
|
|
699
|
+
{
|
|
700
|
+
type: "bar",
|
|
701
|
+
title: "Charts",
|
|
702
|
+
labels: ["Pie", "Bar", "Line", "Radar", "Map", "Hero"],
|
|
703
|
+
datasets: [{ label: "Variety", data: [8, 14, 11, 6, 9, 12] }],
|
|
704
|
+
},
|
|
705
|
+
{
|
|
706
|
+
type: "pie",
|
|
707
|
+
title: "Themes",
|
|
708
|
+
data: [
|
|
709
|
+
{ label: "Boardroom", value: 18 },
|
|
710
|
+
{ label: "Tokyo-midnight", value: 16 },
|
|
711
|
+
{ label: "Forest-amber", value: 14 },
|
|
712
|
+
{ label: "Sky-ocean", value: 13 },
|
|
713
|
+
{ label: "Office-red", value: 12 },
|
|
714
|
+
{ label: "Black-matrix", value: 11 },
|
|
715
|
+
{ label: "Zen-garden", value: 16 },
|
|
716
|
+
],
|
|
717
|
+
},
|
|
718
|
+
{
|
|
719
|
+
type: "bar",
|
|
720
|
+
title: "Hero variants",
|
|
721
|
+
labels: ["Number", "Ring", "Status", "Compare", "Rank", "Count", "Bar", "NPS", "Orb", "Gem"],
|
|
722
|
+
datasets: [{ label: "Variants", data: [9, 12, 7, 10, 8, 6, 11, 9, 13, 10] }],
|
|
723
|
+
},
|
|
724
|
+
{
|
|
725
|
+
type: "bar",
|
|
726
|
+
title: "Effects",
|
|
727
|
+
labels: ["None", "Subtle", "Shimmer", "Neon", "Energetic"],
|
|
728
|
+
datasets: [{ label: "Presets", data: [4, 7, 11, 15, 13] }],
|
|
729
|
+
},
|
|
730
|
+
];
|
|
731
|
+
const chartData = {
|
|
732
|
+
type: "dashboard",
|
|
733
|
+
title: "MCP Dashboards Catalog",
|
|
734
|
+
kpis: [
|
|
735
|
+
{ label: "Total dimensions", value: charts.length, suffix: " catalogs" },
|
|
736
|
+
{ label: "Tip", value: "Click any tile, hit Ask" },
|
|
737
|
+
],
|
|
738
|
+
charts,
|
|
739
|
+
columns: 2,
|
|
740
|
+
theme: args.theme,
|
|
741
|
+
footer: { text: "Each tile previews what its sub-catalog contains. Click any tile then hit Ask to open the matching catalog. Or call render_chart_catalog / render_theme_catalog / render_hero_catalog / render_effects_catalog directly." },
|
|
742
|
+
};
|
|
743
|
+
return await _buildChartResult(server, chartData, `Master Catalog: ${charts.length} customization dimensions, each shown as a live preview. Click any tile and hit Ask to open the matching sub-catalog (render_chart_catalog, render_theme_catalog, render_hero_catalog, render_effects_catalog).`);
|
|
623
744
|
});
|
|
624
|
-
// -- Tool: render_radar_chart --
|
|
625
745
|
_registerChartTool(server, "render_radar_chart", {
|
|
626
746
|
title: "Radar Chart",
|
|
627
747
|
description: "Render a radar (spider/web) chart - 'How do items compare across multiple dimensions?' Great for skill profiles, product comparisons, competitive analysis.",
|
|
@@ -646,7 +766,6 @@ export function createServer() {
|
|
|
646
766
|
const ds = args.datasets;
|
|
647
767
|
return `${args.title}: ${ds.map((d) => d.label).join(", ")} across ${args.labels.length} dimensions`;
|
|
648
768
|
});
|
|
649
|
-
// -- Tool: render_treemap_chart --
|
|
650
769
|
_registerChartTool(server, "render_treemap_chart", {
|
|
651
770
|
title: "Treemap",
|
|
652
771
|
description: "Render a treemap - 'What takes up the most space?' Nested rectangles sized by value. Supports optional grouping for hierarchical data (e.g. region > country). Great for budget breakdowns, disk usage, portfolio allocation.",
|
|
@@ -670,7 +789,6 @@ export function createServer() {
|
|
|
670
789
|
const items = args.data;
|
|
671
790
|
return `${args.title}: ${items.length} items, largest: ${items.sort((a, b) => b.value - a.value)[0]?.label}`;
|
|
672
791
|
});
|
|
673
|
-
// -- Tool: render_sankey_chart --
|
|
674
792
|
_registerChartTool(server, "render_sankey_chart", {
|
|
675
793
|
title: "Sankey Diagram",
|
|
676
794
|
description: "Render a sankey flow diagram - 'Where does it go?' Shows flows between nodes with width proportional to value. Great for budget flows, user journeys, energy transfers, conversion funnels with multiple paths.",
|
|
@@ -695,7 +813,6 @@ export function createServer() {
|
|
|
695
813
|
const nodes = [...new Set(flows.flatMap((f) => [f.from, f.to]))];
|
|
696
814
|
return `${args.title}: ${flows.length} flows across ${nodes.length} nodes`;
|
|
697
815
|
});
|
|
698
|
-
// -- Tool: render_wordcloud_chart --
|
|
699
816
|
_registerChartTool(server, "render_wordcloud_chart", {
|
|
700
817
|
title: "Word Cloud",
|
|
701
818
|
description: "Render a word cloud - 'What are the dominant themes?' Words sized by frequency or importance. Great for survey responses, keyword analysis, topic frequency.",
|
|
@@ -718,7 +835,6 @@ export function createServer() {
|
|
|
718
835
|
const top = words.sort((a, b) => b.value - a.value).slice(0, 5).map((w) => w.text);
|
|
719
836
|
return `${args.title}: ${words.length} words, top: ${top.join(", ")}`;
|
|
720
837
|
});
|
|
721
|
-
// -- Tool: render_boxplot_chart --
|
|
722
838
|
_registerChartTool(server, "render_boxplot_chart", {
|
|
723
839
|
title: "Boxplot / Violin",
|
|
724
840
|
description: "Render a boxplot or violin chart - 'What is the distribution?' Shows median, quartiles, whiskers, and outliers. Pass raw number arrays per category - stats computed automatically. Use style='violin' for density shape.",
|
|
@@ -744,7 +860,6 @@ export function createServer() {
|
|
|
744
860
|
const ds = args.datasets;
|
|
745
861
|
return `${args.title}: ${ds.map((d) => d.label).join(", ")} across ${args.labels.length} categories`;
|
|
746
862
|
});
|
|
747
|
-
// -- Tool: render_live_chart --
|
|
748
863
|
_registerChartTool(server, "render_live_chart", {
|
|
749
864
|
title: "Live Chart",
|
|
750
865
|
description: "Render a real-time auto-updating line chart that polls a tool at a regular interval. Use when the user wants to MONITOR a live data source. Set pollTool to 'poll_http' with pollArgs containing a preset or URL to poll external APIs (including other MCP servers' data). The chart auto-refreshes - no user action needed.",
|
|
@@ -778,7 +893,6 @@ export function createServer() {
|
|
|
778
893
|
const series = args.values.map((v) => v.label).join(", ");
|
|
779
894
|
return `Live chart "${args.title}" - polling ${args.pollTool} every ${args.interval ?? 2}s: ${series}`;
|
|
780
895
|
});
|
|
781
|
-
// -- Tool: render_scatter_chart --
|
|
782
896
|
registerAppTool(server, "render_scatter_chart", {
|
|
783
897
|
title: "Scatter Chart",
|
|
784
898
|
description: "Render an interactive scatter plot with x/y coordinate data. Supports multiple series and optional connecting lines. Supports themes for styled visuals.",
|
|
@@ -809,7 +923,6 @@ export function createServer() {
|
|
|
809
923
|
.join("; ");
|
|
810
924
|
return await _buildChartResult(server, chartData, `${args.title} - ${summary}`);
|
|
811
925
|
});
|
|
812
|
-
// -- Tool: render_candlestick_chart --
|
|
813
926
|
registerAppTool(server, "render_candlestick_chart", {
|
|
814
927
|
title: "Candlestick Chart",
|
|
815
928
|
description: "Render an interactive candlestick or OHLC financial chart. Provide date/OHLC data for stock prices, crypto, forex, or any time-series financial data. Supports themes for styled visuals.",
|
|
@@ -840,7 +953,6 @@ export function createServer() {
|
|
|
840
953
|
const change = last ? ((last.c - first.o) / first.o * 100).toFixed(2) : "0";
|
|
841
954
|
return await _buildChartResult(server, chartData, `${args.title}: ${args.data.length} bars, ${first?.date ?? "?"} to ${last?.date ?? "?"}, change: ${change}%`);
|
|
842
955
|
});
|
|
843
|
-
// -- Tool: render_table --
|
|
844
956
|
registerAppTool(server, "render_table", {
|
|
845
957
|
title: "Data Table",
|
|
846
958
|
description: "Render a sortable, interactive data table. Click column headers to sort. Supports themes for styled visuals.",
|
|
@@ -873,7 +985,6 @@ export function createServer() {
|
|
|
873
985
|
};
|
|
874
986
|
return await _buildChartResult(server, chartData, `${args.title}: ${args.rows.length} rows, ${args.columns.length} columns`);
|
|
875
987
|
});
|
|
876
|
-
// -- Tool: render_from_json --
|
|
877
988
|
registerAppTool(server, "render_from_json", {
|
|
878
989
|
title: "Auto Chart",
|
|
879
990
|
description: "Automatically detect the best chart type for arbitrary JSON data. Pass any JSON - arrays, objects, nested structures - and get the most appropriate visualization.",
|
|
@@ -895,7 +1006,6 @@ export function createServer() {
|
|
|
895
1006
|
};
|
|
896
1007
|
return await _buildChartResult(server, chartData, `Auto-visualizing: ${args.title}`);
|
|
897
1008
|
});
|
|
898
|
-
// -- Tool: render_from_url --
|
|
899
1009
|
registerAppTool(server, "render_from_url", {
|
|
900
1010
|
title: "Chart from URL",
|
|
901
1011
|
description: "Fetch JSON data from a URL and automatically visualize it. The server fetches the data, detects the best chart type, and renders it interactively.",
|
|
@@ -910,7 +1020,12 @@ export function createServer() {
|
|
|
910
1020
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
911
1021
|
}, async (args) => {
|
|
912
1022
|
try {
|
|
913
|
-
|
|
1023
|
+
// SSRF protection: reject private/loopback/link-local destinations
|
|
1024
|
+
// unless the hostname is in MCP_URL_ALLOWLIST.
|
|
1025
|
+
const safeUrl = await assertSafeUrl(args.url);
|
|
1026
|
+
// Throttle outbound calls per hostname.
|
|
1027
|
+
await acquireOutbound(safeUrl.hostname);
|
|
1028
|
+
const response = await fetch(safeUrl, {
|
|
914
1029
|
headers: { "Accept": "application/json", "User-Agent": "MCP-Dashboard/1.0" },
|
|
915
1030
|
signal: AbortSignal.timeout(15000),
|
|
916
1031
|
});
|
|
@@ -930,33 +1045,89 @@ export function createServer() {
|
|
|
930
1045
|
return await _buildChartResult(server, chartData, `Fetched and visualizing: ${args.title} (from ${args.url})`);
|
|
931
1046
|
}
|
|
932
1047
|
catch (err) {
|
|
1048
|
+
const msg = err instanceof UrlSafetyError
|
|
1049
|
+
? err.message
|
|
1050
|
+
: `Error fetching ${args.url}: ${err.message}`;
|
|
933
1051
|
return {
|
|
934
|
-
content: [{ type: "text", text:
|
|
1052
|
+
content: [{ type: "text", text: msg }],
|
|
935
1053
|
isError: true,
|
|
936
1054
|
};
|
|
937
1055
|
}
|
|
938
1056
|
});
|
|
939
|
-
// -- Tool: save_file
|
|
940
|
-
//
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
1057
|
+
// -- Tool: save_file --
|
|
1058
|
+
// Two-layer defense:
|
|
1059
|
+
// 1. _meta.ui.visibility=["app"] tells compliant MCP clients (Claude Code,
|
|
1060
|
+
// Claude Desktop, etc.) to NOT surface this tool to the LLM. Only the
|
|
1061
|
+
// View bundle running inside the chart iframe can invoke it via
|
|
1062
|
+
// app.callServerTool() over the MCP Apps PostMessage transport.
|
|
1063
|
+
// 2. Extension allowlist (.png, .csv) is the server-side enforcement that
|
|
1064
|
+
// runs regardless of client compliance, so even a buggy/malicious
|
|
1065
|
+
// client cannot make us write arbitrary file types to ~/Downloads.
|
|
1066
|
+
//
|
|
1067
|
+
// The View only ever emits PNG (chart screenshots) and CSV (table data) -
|
|
1068
|
+
// see addPngExportButton / addCsvExportButton in src/charts/shared.ts.
|
|
1069
|
+
const SAVE_FILE_ALLOWED_EXTS = new Set([".png", ".csv"]);
|
|
1070
|
+
registerAppTool(server, "save_file", {
|
|
1071
|
+
title: "Save File",
|
|
1072
|
+
description: "Save a chart export (PNG or CSV) to the user's Downloads folder. App-only - invoked by the chart View's Download buttons, not by the AI.",
|
|
1073
|
+
inputSchema: {
|
|
1074
|
+
filename: z.string().describe("Filename with extension (.png or .csv only)"),
|
|
1075
|
+
data: z.string().describe("File contents: base64-encoded binary or plain text"),
|
|
1076
|
+
encoding: z.enum(["base64", "utf-8"]).describe("How data is encoded"),
|
|
1077
|
+
},
|
|
1078
|
+
_meta: {
|
|
1079
|
+
ui: {
|
|
1080
|
+
resourceUri: RESOURCE_URI,
|
|
1081
|
+
visibility: ["app"],
|
|
1082
|
+
},
|
|
1083
|
+
},
|
|
1084
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
1085
|
+
}, async (args) => {
|
|
946
1086
|
try {
|
|
947
|
-
|
|
1087
|
+
// Sanitize the filename - replace path separators and Windows-reserved chars
|
|
1088
|
+
// with hyphens rather than using path.basename (which would strip the title
|
|
1089
|
+
// down to only the trailing segment when a chart title happens to contain `/`
|
|
1090
|
+
// or `\`, e.g. "Cost / yr" becoming "yr.png").
|
|
1091
|
+
const INVALID = /[\\/:*?"<>|\x00-\x1f]/g;
|
|
1092
|
+
const extMatch = args.filename.match(/\.[A-Za-z0-9]{1,5}$/);
|
|
1093
|
+
const ext = (extMatch ? extMatch[0] : "").toLowerCase();
|
|
1094
|
+
// Server-side extension allowlist - defense even if a non-compliant
|
|
1095
|
+
// client surfaces this tool to the LLM despite the visibility hint.
|
|
1096
|
+
if (!SAVE_FILE_ALLOWED_EXTS.has(ext)) {
|
|
1097
|
+
return {
|
|
1098
|
+
content: [{
|
|
1099
|
+
type: "text",
|
|
1100
|
+
text: `Refusing to save: extension "${ext || "(none)"}" not in allowlist. Allowed: ${[...SAVE_FILE_ALLOWED_EXTS].join(", ")}`,
|
|
1101
|
+
}],
|
|
1102
|
+
isError: true,
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
const base = ext ? args.filename.slice(0, -ext.length) : args.filename;
|
|
1106
|
+
let sanitized = (base.replace(INVALID, "-").trim() || "chart") + ext;
|
|
1107
|
+
// Cap to 200 chars to stay comfortably under the 255 byte NTFS limit.
|
|
1108
|
+
if (sanitized.length > 200) {
|
|
1109
|
+
sanitized = sanitized.slice(0, 200 - ext.length) + ext;
|
|
1110
|
+
}
|
|
948
1111
|
const downloadsDir = path.join(os.homedir(), "Downloads");
|
|
949
|
-
// Ensure Downloads folder exists
|
|
950
1112
|
await fs.mkdir(downloadsDir, { recursive: true });
|
|
951
1113
|
const filePath = path.join(downloadsDir, sanitized);
|
|
1114
|
+
// Defense in depth: confirm the resolved path stays inside Downloads.
|
|
1115
|
+
const resolved = path.resolve(filePath);
|
|
1116
|
+
const downloadsResolved = path.resolve(downloadsDir);
|
|
1117
|
+
if (path.dirname(resolved) !== downloadsResolved) {
|
|
1118
|
+
return {
|
|
1119
|
+
content: [{ type: "text", text: "Refusing to save: path escapes Downloads directory" }],
|
|
1120
|
+
isError: true,
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
952
1123
|
if (args.encoding === "base64") {
|
|
953
|
-
await fs.writeFile(
|
|
1124
|
+
await fs.writeFile(resolved, Buffer.from(args.data, "base64"));
|
|
954
1125
|
}
|
|
955
1126
|
else {
|
|
956
|
-
await fs.writeFile(
|
|
1127
|
+
await fs.writeFile(resolved, args.data, "utf-8");
|
|
957
1128
|
}
|
|
958
1129
|
return {
|
|
959
|
-
content: [{ type: "text", text: `Saved to ${
|
|
1130
|
+
content: [{ type: "text", text: `Saved to ${resolved}` }],
|
|
960
1131
|
};
|
|
961
1132
|
}
|
|
962
1133
|
catch (err) {
|
|
@@ -966,7 +1137,136 @@ export function createServer() {
|
|
|
966
1137
|
};
|
|
967
1138
|
}
|
|
968
1139
|
});
|
|
969
|
-
// --
|
|
1140
|
+
// -- Tool: list_chart_files (model-visible) --
|
|
1141
|
+
// Lists chart preview HTML files currently on disk in the temp folder.
|
|
1142
|
+
// Returns name, ID, size (KB), and modified timestamp for each. Read-only.
|
|
1143
|
+
server.tool("list_chart_files", "List all chart preview HTML files saved on disk (in the system temp folder). These are auto-generated each time a chart is rendered and auto-cleaned after 7 days (configurable via MCP_DASHBOARDS_RETAIN_DAYS env var). Returns file metadata (id, name, size, age). Use this to audit disk usage, then call delete_chart_files to remove specific files or all of them.", {}, { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, async () => {
|
|
1144
|
+
try {
|
|
1145
|
+
const entries = await fs.readdir(TEMP_DIR).catch(() => []);
|
|
1146
|
+
const files = [];
|
|
1147
|
+
for (const name of entries) {
|
|
1148
|
+
if (!CHART_FILENAME_RE.test(name))
|
|
1149
|
+
continue;
|
|
1150
|
+
try {
|
|
1151
|
+
const stat = await fs.stat(path.join(TEMP_DIR, name));
|
|
1152
|
+
const id = name.replace(/^chart-/, "").replace(/\.html$/, "");
|
|
1153
|
+
files.push({
|
|
1154
|
+
id,
|
|
1155
|
+
name,
|
|
1156
|
+
sizeKB: Math.round(stat.size / 1024),
|
|
1157
|
+
modifiedAt: new Date(stat.mtimeMs).toISOString(),
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
catch { /* skip locked / permission-denied */ }
|
|
1161
|
+
}
|
|
1162
|
+
// Sort newest first
|
|
1163
|
+
files.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt));
|
|
1164
|
+
const retainDays = Number.parseInt(process.env.MCP_DASHBOARDS_RETAIN_DAYS ?? "7", 10);
|
|
1165
|
+
const summary = files.length === 0
|
|
1166
|
+
? `No chart files in ${TEMP_DIR}`
|
|
1167
|
+
: `${files.length} chart file(s) in ${TEMP_DIR} (auto-cleanup at ${retainDays} days)`;
|
|
1168
|
+
const structured = { dir: TEMP_DIR, retainDays, files };
|
|
1169
|
+
return {
|
|
1170
|
+
content: [
|
|
1171
|
+
{ type: "text", text: summary },
|
|
1172
|
+
{ type: "text", text: JSON.stringify(structured) },
|
|
1173
|
+
],
|
|
1174
|
+
structuredContent: structured,
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
catch (err) {
|
|
1178
|
+
return {
|
|
1179
|
+
content: [{ type: "text", text: `Failed to list chart files: ${err.message}` }],
|
|
1180
|
+
isError: true,
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
});
|
|
1184
|
+
// -- Tool: delete_chart_files (destructive) --
|
|
1185
|
+
// Manually deletes chart preview HTML files. User-initiated only (the AI calls
|
|
1186
|
+
// this when the user explicitly asks). Scope is hard-locked to the temp subfolder
|
|
1187
|
+
// and the chart-{hex}.html pattern - it cannot delete anything else.
|
|
1188
|
+
server.tool("delete_chart_files", "Delete chart preview HTML files saved on disk (in the system temp folder). REQUIRES the user's explicit instruction to run - it deletes files from disk and cannot be undone. Provide at least one of: chartIds (specific file IDs to delete), olderThanDays (bulk delete by age), or all=true (delete every chart file). Scope is hard-locked to mcp-dashboards temp folder; cannot touch anything else. Returns lists of successfully deleted files and per-file failures (e.g. files currently locked by an open browser).", {
|
|
1189
|
+
chartIds: z.array(z.string()).optional().describe("Specific chart IDs to delete (12-char hex strings). Each must match /^[a-f0-9]{12}$/ or it is rejected."),
|
|
1190
|
+
olderThanDays: z.number().int().positive().optional().describe("Delete chart files older than N days (positive integer)."),
|
|
1191
|
+
all: z.boolean().optional().describe("Explicit confirmation to delete ALL chart files in the temp folder. Defaults to false."),
|
|
1192
|
+
}, { readOnlyHint: false, destructiveHint: true, idempotentHint: false }, async (args) => {
|
|
1193
|
+
const { chartIds, olderThanDays, all } = args;
|
|
1194
|
+
if (!chartIds?.length && olderThanDays === undefined && !all) {
|
|
1195
|
+
return {
|
|
1196
|
+
content: [{
|
|
1197
|
+
type: "text",
|
|
1198
|
+
text: "delete_chart_files requires at least one of: chartIds, olderThanDays, or all=true. Refusing to act on empty input.",
|
|
1199
|
+
}],
|
|
1200
|
+
isError: true,
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
const deleted = [];
|
|
1204
|
+
const failed = [];
|
|
1205
|
+
try {
|
|
1206
|
+
const entries = await fs.readdir(TEMP_DIR).catch(() => []);
|
|
1207
|
+
const now = Date.now();
|
|
1208
|
+
const ageCutoff = olderThanDays !== undefined
|
|
1209
|
+
? now - olderThanDays * 24 * 60 * 60 * 1000
|
|
1210
|
+
: null;
|
|
1211
|
+
const idSet = chartIds?.length ? new Set(chartIds.filter((id) => /^[a-f0-9]{12}$/.test(id))) : null;
|
|
1212
|
+
for (const name of entries) {
|
|
1213
|
+
if (!CHART_FILENAME_RE.test(name))
|
|
1214
|
+
continue;
|
|
1215
|
+
const id = name.replace(/^chart-/, "").replace(/\.html$/, "");
|
|
1216
|
+
// Decide whether this file qualifies for deletion
|
|
1217
|
+
let qualifies = false;
|
|
1218
|
+
if (all)
|
|
1219
|
+
qualifies = true;
|
|
1220
|
+
if (!qualifies && idSet?.has(id))
|
|
1221
|
+
qualifies = true;
|
|
1222
|
+
if (!qualifies && ageCutoff !== null) {
|
|
1223
|
+
try {
|
|
1224
|
+
const stat = await fs.stat(path.join(TEMP_DIR, name));
|
|
1225
|
+
if (stat.mtimeMs < ageCutoff)
|
|
1226
|
+
qualifies = true;
|
|
1227
|
+
}
|
|
1228
|
+
catch { /* fall through, treat as not qualifying */ }
|
|
1229
|
+
}
|
|
1230
|
+
if (!qualifies)
|
|
1231
|
+
continue;
|
|
1232
|
+
// Verify the resolved path stays inside TEMP_DIR (defense in depth)
|
|
1233
|
+
const full = path.join(TEMP_DIR, name);
|
|
1234
|
+
if (path.dirname(full) !== TEMP_DIR) {
|
|
1235
|
+
failed.push({ id, name, reason: "path escapes temp dir" });
|
|
1236
|
+
continue;
|
|
1237
|
+
}
|
|
1238
|
+
try {
|
|
1239
|
+
await fs.unlink(full);
|
|
1240
|
+
// Also evict from in-memory chart store so the localhost URL stops
|
|
1241
|
+
// resolving. Without this, the URL keeps serving the cached chart
|
|
1242
|
+
// even though the file is gone - confusing for users who deleted
|
|
1243
|
+
// the chart and expect the link to be dead.
|
|
1244
|
+
evictChartFromCache(id);
|
|
1245
|
+
deleted.push({ id, name });
|
|
1246
|
+
}
|
|
1247
|
+
catch (err) {
|
|
1248
|
+
failed.push({ id, name, reason: err.code || err.message || "unknown error" });
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
const parts = [`Deleted ${deleted.length} chart file(s) from ${TEMP_DIR}`];
|
|
1252
|
+
if (failed.length)
|
|
1253
|
+
parts.push(`${failed.length} failed (locked or permission issues)`);
|
|
1254
|
+
const structured = { dir: TEMP_DIR, deleted, failed };
|
|
1255
|
+
return {
|
|
1256
|
+
content: [
|
|
1257
|
+
{ type: "text", text: parts.join("; ") },
|
|
1258
|
+
{ type: "text", text: JSON.stringify(structured) },
|
|
1259
|
+
],
|
|
1260
|
+
structuredContent: structured,
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
catch (err) {
|
|
1264
|
+
return {
|
|
1265
|
+
content: [{ type: "text", text: `Failed to delete chart files: ${err.message}` }],
|
|
1266
|
+
isError: true,
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
});
|
|
970
1270
|
_registerChartTool(server, "render_bullet_chart", {
|
|
971
1271
|
title: "Bullet Chart",
|
|
972
1272
|
description: "Render bullet charts - 'Are we hitting target?' Horizontal bars with qualitative zones and a target marker. Supports 2-8 zones with optional labels and colors. Great for KPI vs target, seniority bands, maturity models.",
|
|
@@ -1235,6 +1535,58 @@ export function createServer() {
|
|
|
1235
1535
|
const done = ms.filter((m) => m.status === "done").length;
|
|
1236
1536
|
return `${args.title}: ${done}/${ms.length} milestones done`;
|
|
1237
1537
|
});
|
|
1538
|
+
_registerChartTool(server, "render_flowchart", {
|
|
1539
|
+
title: "Flowchart",
|
|
1540
|
+
description: "Render an interactive flowchart or diagram. USE THIS whenever the user wants to visualize a flow or structure: a workflow, data pipeline / ETL, system or software architecture, multi-agent or LLM agent design, decision tree, process map, org chart, or state machine. Nodes are rich cards (6 kinds: agent / tool / decision / data / input / output) with a type chip, name, and up to 3 property rows (e.g. model, tokens, latency); pass `icon` for a custom glyph. Edges support 5 typed kinds (request / response / async / stream / error) with distinct styles. Dagre auto-layout (DAG); draggable, zoomable, pannable, with a node list, minimap, and step-through playback. Status states (queued / running / done / failed / skipped) animate in place: set pollTool + statusPath for live updates, or pass a `steps` sequence of {nodeId: status} snapshots for manual playback. Do NOT hand-author `x`/`y` coordinates to position nodes - omit them and dagre auto-lays out the graph cleanly. Only pass `x`/`y` that were round-tripped back to you from a previous render (a user's saved drag layout), and only as a complete set covering every node; a partial set is ignored to prevent overlaps. This is a full-canvas tool, not a dashboard tile - for multi-chart layouts use render_dashboard.",
|
|
1541
|
+
}, {
|
|
1542
|
+
title: z.string().describe("Chart title"),
|
|
1543
|
+
subtitle: z.string().optional().describe("Optional subtitle"),
|
|
1544
|
+
nodes: z.array(z.object({
|
|
1545
|
+
id: z.string().describe("Unique node id (used by edges + status updates)"),
|
|
1546
|
+
label: z.string().describe("Display name (e.g. 'Orchestrator', 'Web Search Tool')"),
|
|
1547
|
+
kind: z.enum(["agent", "tool", "decision", "data", "input", "output"]).optional().describe("Node category. Default: agent"),
|
|
1548
|
+
status: z.enum(["queued", "running", "done", "failed", "skipped"]).optional().describe("Initial status. Default: queued"),
|
|
1549
|
+
props: z.array(z.object({
|
|
1550
|
+
key: z.string(),
|
|
1551
|
+
value: z.string(),
|
|
1552
|
+
})).max(3).optional().describe("Up to 3 property rows shown in the card (e.g. model, tokens, latency)"),
|
|
1553
|
+
color: z.string().optional().describe("Override accent color (hex). Default uses kind color."),
|
|
1554
|
+
icon: z.string().optional().describe("Override the node icon with a single emoji or glyph. Default uses the kind icon."),
|
|
1555
|
+
x: z.number().optional().describe("Do NOT set this to hand-position nodes - omit it for auto-layout. Only echo back an x that a previous render returned (a saved layout), and only if EVERY node has one; a partial set is ignored to avoid overlaps."),
|
|
1556
|
+
y: z.number().optional().describe("Do NOT set this to hand-position nodes - omit it for auto-layout. Only echo back a y that a previous render returned (a saved layout), and only if EVERY node has one; a partial set is ignored to avoid overlaps."),
|
|
1557
|
+
})).describe("Nodes in the flow"),
|
|
1558
|
+
edges: z.array(z.object({
|
|
1559
|
+
from: z.string().describe("Source node id"),
|
|
1560
|
+
to: z.string().describe("Target node id"),
|
|
1561
|
+
label: z.string().optional().describe("Edge label"),
|
|
1562
|
+
kind: z.enum(["request", "response", "async", "stream", "error"]).optional().describe("Edge style. Default: request"),
|
|
1563
|
+
})).describe("Edges between nodes (form a DAG)"),
|
|
1564
|
+
direction: z.enum(["TB", "LR"]).optional().describe("Layout direction. TB (top-bottom, default) or LR (left-right)"),
|
|
1565
|
+
interactive: z.boolean().optional().describe("Allow drag-to-reposition nodes. Default: true"),
|
|
1566
|
+
pollTool: z.string().optional().describe("Optional: name of an MCP tool to poll for live status updates (use 'poll_http' with a preset for external APIs)"),
|
|
1567
|
+
pollArgs: z.record(z.string(), z.any()).optional().describe("Arguments to pass to pollTool"),
|
|
1568
|
+
statusPath: z.string().optional().describe("Dot-path to a {nodeId: status} map in the poll result (e.g. 'states' or 'agents.statuses'). If omitted, the result itself is treated as the map."),
|
|
1569
|
+
interval: z.number().optional().describe("Poll interval in seconds. Default: 2"),
|
|
1570
|
+
steps: z.array(z.record(z.string(), z.enum(["queued", "running", "done", "failed", "skipped"]))).optional().describe("Optional playback sequence: an ordered array of {nodeId: status} snapshots. The user can step/play through them with the playback control. If omitted, a progressive reveal is derived from the graph topology."),
|
|
1571
|
+
}, (args) => ({
|
|
1572
|
+
type: "flowchart",
|
|
1573
|
+
title: args.title,
|
|
1574
|
+
subtitle: args.subtitle,
|
|
1575
|
+
nodes: args.nodes,
|
|
1576
|
+
edges: args.edges,
|
|
1577
|
+
direction: args.direction,
|
|
1578
|
+
interactive: args.interactive,
|
|
1579
|
+
pollTool: args.pollTool,
|
|
1580
|
+
pollArgs: args.pollArgs,
|
|
1581
|
+
statusPath: args.statusPath,
|
|
1582
|
+
interval: args.interval,
|
|
1583
|
+
steps: args.steps,
|
|
1584
|
+
}), (args) => {
|
|
1585
|
+
const nodes = args.nodes;
|
|
1586
|
+
const edges = args.edges;
|
|
1587
|
+
const live = args.pollTool ? ` (live via ${args.pollTool})` : "";
|
|
1588
|
+
return `${args.title}: ${nodes.length} nodes, ${edges.length} edges${live}`;
|
|
1589
|
+
});
|
|
1238
1590
|
_registerChartTool(server, "render_geo_chart", {
|
|
1239
1591
|
title: "Geo Map",
|
|
1240
1592
|
description: "Render a choropleth world map - 'Where is the value concentrated?' Color-coded countries by numeric value. Pass data as { countryCode: value } using ISO 3166-1 alpha-2 codes (US, DE, IN, GB, etc.).",
|
|
@@ -1347,7 +1699,19 @@ export function createServer() {
|
|
|
1347
1699
|
if (args.headers) {
|
|
1348
1700
|
fetchHeaders = { ...args.headers, ...fetchHeaders };
|
|
1349
1701
|
}
|
|
1350
|
-
|
|
1702
|
+
// SSRF protection: validate target before fetch. Presets are trusted
|
|
1703
|
+
// (operator-configured at env-var level), so they skip the check; raw
|
|
1704
|
+
// URLs from the AI go through the full guard.
|
|
1705
|
+
let safeUrl;
|
|
1706
|
+
if (args.preset) {
|
|
1707
|
+
safeUrl = new URL(fetchUrl);
|
|
1708
|
+
}
|
|
1709
|
+
else {
|
|
1710
|
+
safeUrl = await assertSafeUrl(fetchUrl);
|
|
1711
|
+
}
|
|
1712
|
+
// Throttle outbound calls per hostname (covers both presets and raw).
|
|
1713
|
+
await acquireOutbound(safeUrl.hostname);
|
|
1714
|
+
const resp = await fetch(safeUrl, {
|
|
1351
1715
|
method: args.method ?? "GET",
|
|
1352
1716
|
headers: fetchHeaders,
|
|
1353
1717
|
body: args.method === "POST" ? args.body : undefined,
|
|
@@ -1362,8 +1726,11 @@ export function createServer() {
|
|
|
1362
1726
|
return { content: [{ type: "text", text }] };
|
|
1363
1727
|
}
|
|
1364
1728
|
catch (err) {
|
|
1729
|
+
const msg = err instanceof UrlSafetyError
|
|
1730
|
+
? err.message
|
|
1731
|
+
: `poll_http failed: ${err.message}`;
|
|
1365
1732
|
return {
|
|
1366
|
-
content: [{ type: "text", text:
|
|
1733
|
+
content: [{ type: "text", text: msg }],
|
|
1367
1734
|
isError: true,
|
|
1368
1735
|
};
|
|
1369
1736
|
}
|