mcp-dashboards 2.0.1 → 2.2.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 +68 -31
- package/dist/index.js +38 -3
- package/dist/index.js.map +1 -1
- package/dist/mcp-app.html +407 -359
- package/dist/preview-server.d.ts +9 -0
- package/dist/preview-server.d.ts.map +1 -0
- package/dist/preview-server.js +174 -0
- package/dist/preview-server.js.map +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +402 -130
- 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 +22 -15
package/dist/server.js
CHANGED
|
@@ -5,6 +5,8 @@ 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, evictChartFromCache, TEMP_DIR, CHART_FILENAME_RE } from "./preview-server.js";
|
|
9
|
+
import { assertSafeUrl, acquireOutbound, UrlSafetyError } from "./url-safety.js";
|
|
8
10
|
// Works both from source (server.ts) and compiled (dist/server.js)
|
|
9
11
|
const __filename = fileURLToPath(import.meta.url);
|
|
10
12
|
const __dirname = path.dirname(__filename);
|
|
@@ -19,7 +21,7 @@ const PieDataItem = z.object({
|
|
|
19
21
|
});
|
|
20
22
|
const ColorsOption = z.array(z.string()).optional().describe("Custom color palette as hex codes (e.g. ['#FF6384', '#36A2EB']). Uses default palette if omitted.");
|
|
21
23
|
// Shared theme parameters for all chart tools
|
|
22
|
-
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");
|
|
23
25
|
const PaletteParam = z.string().optional().describe("Override palette only (mix-and-match)");
|
|
24
26
|
const TypographyParam = z.string().optional().describe("Override typography: professional, luxury, cyberpunk, editorial, mono, bold, system, techno");
|
|
25
27
|
const EffectsParam = z.string().optional().describe("Override effects: none, subtle, shimmer, neon, energetic");
|
|
@@ -154,6 +156,7 @@ function _registerChartTool(server, name, meta, inputSchema, buildResult, summar
|
|
|
154
156
|
effects: EffectsParam,
|
|
155
157
|
},
|
|
156
158
|
_meta: { ui: { resourceUri: RESOURCE_URI } },
|
|
159
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
|
157
160
|
}, async (args) => {
|
|
158
161
|
const chartData = {
|
|
159
162
|
...buildResult(args),
|
|
@@ -162,22 +165,64 @@ function _registerChartTool(server, name, meta, inputSchema, buildResult, summar
|
|
|
162
165
|
typography: args.typography,
|
|
163
166
|
effects: args.effects,
|
|
164
167
|
};
|
|
165
|
-
return
|
|
166
|
-
content: [
|
|
167
|
-
{ type: "text", text: summarize(args) },
|
|
168
|
-
{ type: "text", text: JSON.stringify(chartData) },
|
|
169
|
-
],
|
|
170
|
-
structuredContent: chartData,
|
|
171
|
-
};
|
|
168
|
+
return await _buildChartResult(server, chartData, summarize(args));
|
|
172
169
|
});
|
|
173
170
|
}
|
|
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;
|
|
202
|
+
const content = [
|
|
203
|
+
{ type: "text", text: enrichedSummary },
|
|
204
|
+
];
|
|
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
|
+
});
|
|
215
|
+
}
|
|
216
|
+
content.push({ type: "text", text: JSON.stringify(enrichedChartData) });
|
|
217
|
+
return { content, structuredContent: enrichedChartData };
|
|
218
|
+
}
|
|
174
219
|
/**
|
|
175
220
|
* Creates a new MCP Dashboards server instance.
|
|
176
221
|
*/
|
|
177
222
|
export function createServer() {
|
|
178
223
|
const server = new McpServer({
|
|
179
224
|
name: "MCP Dashboards",
|
|
180
|
-
version: "2.
|
|
225
|
+
version: "2.2.0",
|
|
181
226
|
});
|
|
182
227
|
// -- Shared HTML resource --
|
|
183
228
|
registerAppResource(server, RESOURCE_URI, RESOURCE_URI, { mimeType: RESOURCE_MIME_TYPE }, async () => {
|
|
@@ -186,7 +231,6 @@ export function createServer() {
|
|
|
186
231
|
contents: [{ uri: RESOURCE_URI, mimeType: RESOURCE_MIME_TYPE, text: html }],
|
|
187
232
|
};
|
|
188
233
|
});
|
|
189
|
-
// -- Tool: render_pie_chart --
|
|
190
234
|
registerAppTool(server, "render_pie_chart", {
|
|
191
235
|
title: "Pie Chart",
|
|
192
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.",
|
|
@@ -200,6 +244,7 @@ export function createServer() {
|
|
|
200
244
|
effects: EffectsParam,
|
|
201
245
|
},
|
|
202
246
|
_meta: { ui: { resourceUri: RESOURCE_URI } },
|
|
247
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
|
203
248
|
}, async (args) => {
|
|
204
249
|
const chartData = {
|
|
205
250
|
type: "pie",
|
|
@@ -215,15 +260,8 @@ export function createServer() {
|
|
|
215
260
|
const summary = args.data
|
|
216
261
|
.map((d) => `${d.label}: ${d.value} (${total > 0 ? ((d.value / total) * 100).toFixed(1) : "0.0"}%)`)
|
|
217
262
|
.join(", ");
|
|
218
|
-
return {
|
|
219
|
-
content: [
|
|
220
|
-
{ type: "text", text: `${args.title}: ${summary}` },
|
|
221
|
-
{ type: "text", text: JSON.stringify(chartData) },
|
|
222
|
-
],
|
|
223
|
-
structuredContent: chartData,
|
|
224
|
-
};
|
|
263
|
+
return await _buildChartResult(server, chartData, `${args.title}: ${summary}`);
|
|
225
264
|
});
|
|
226
|
-
// -- Tool: render_bar_chart --
|
|
227
265
|
registerAppTool(server, "render_bar_chart", {
|
|
228
266
|
title: "Bar Chart",
|
|
229
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.",
|
|
@@ -238,6 +276,7 @@ export function createServer() {
|
|
|
238
276
|
effects: EffectsParam,
|
|
239
277
|
},
|
|
240
278
|
_meta: { ui: { resourceUri: RESOURCE_URI } },
|
|
279
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
|
241
280
|
}, async (args) => {
|
|
242
281
|
const chartData = {
|
|
243
282
|
type: "bar",
|
|
@@ -253,15 +292,8 @@ export function createServer() {
|
|
|
253
292
|
const summary = args.datasets
|
|
254
293
|
.map((ds) => `${ds.label}: [${ds.data.join(", ")}]`)
|
|
255
294
|
.join("; ");
|
|
256
|
-
return {
|
|
257
|
-
content: [
|
|
258
|
-
{ type: "text", text: `${args.title} - ${summary}` },
|
|
259
|
-
{ type: "text", text: JSON.stringify(chartData) },
|
|
260
|
-
],
|
|
261
|
-
structuredContent: chartData,
|
|
262
|
-
};
|
|
295
|
+
return await _buildChartResult(server, chartData, `${args.title} - ${summary}`);
|
|
263
296
|
});
|
|
264
|
-
// -- Tool: render_line_chart --
|
|
265
297
|
registerAppTool(server, "render_line_chart", {
|
|
266
298
|
title: "Line Chart",
|
|
267
299
|
description: "Render an interactive line or area chart. Supports smooth curves, gradient fill, and multiple series. Supports themes for styled visuals.",
|
|
@@ -276,6 +308,7 @@ export function createServer() {
|
|
|
276
308
|
effects: EffectsParam,
|
|
277
309
|
},
|
|
278
310
|
_meta: { ui: { resourceUri: RESOURCE_URI } },
|
|
311
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
|
279
312
|
}, async (args) => {
|
|
280
313
|
const chartData = {
|
|
281
314
|
type: "line",
|
|
@@ -291,15 +324,8 @@ export function createServer() {
|
|
|
291
324
|
const summary = args.datasets
|
|
292
325
|
.map((ds) => `${ds.label}: [${ds.data.join(", ")}]`)
|
|
293
326
|
.join("; ");
|
|
294
|
-
return {
|
|
295
|
-
content: [
|
|
296
|
-
{ type: "text", text: `${args.title} - ${summary}` },
|
|
297
|
-
{ type: "text", text: JSON.stringify(chartData) },
|
|
298
|
-
],
|
|
299
|
-
structuredContent: chartData,
|
|
300
|
-
};
|
|
327
|
+
return await _buildChartResult(server, chartData, `${args.title} - ${summary}`);
|
|
301
328
|
});
|
|
302
|
-
// -- Tool: render_hero_metric --
|
|
303
329
|
const GemTypeEnum = z.enum([
|
|
304
330
|
"diamond", "ruby", "sapphire", "emerald",
|
|
305
331
|
"golden_pearl", "white_pearl", "black_pearl", "crystal",
|
|
@@ -397,6 +423,7 @@ export function createServer() {
|
|
|
397
423
|
effects: EffectsParam,
|
|
398
424
|
},
|
|
399
425
|
_meta: { ui: { resourceUri: RESOURCE_URI } },
|
|
426
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
|
400
427
|
}, async (args) => {
|
|
401
428
|
const chartData = {
|
|
402
429
|
type: "hero_metric",
|
|
@@ -404,15 +431,8 @@ export function createServer() {
|
|
|
404
431
|
};
|
|
405
432
|
const variant = args.variant || "big_number";
|
|
406
433
|
const summary = `${args.title}: [${variant}] ${args.value ?? ""}${args.unit ? " " + args.unit : ""}`;
|
|
407
|
-
return
|
|
408
|
-
content: [
|
|
409
|
-
{ type: "text", text: summary },
|
|
410
|
-
{ type: "text", text: JSON.stringify(chartData) },
|
|
411
|
-
],
|
|
412
|
-
structuredContent: chartData,
|
|
413
|
-
};
|
|
434
|
+
return await _buildChartResult(server, chartData, summary);
|
|
414
435
|
});
|
|
415
|
-
// -- Tool: render_dashboard --
|
|
416
436
|
const HeroSchema = z.object({
|
|
417
437
|
variant: HeroVariantEnum.optional().describe("Hero variant (default: progress_ring for dashboard)"),
|
|
418
438
|
value: z.union([z.string(), z.number()]).optional().describe("Hero metric value"),
|
|
@@ -442,7 +462,7 @@ export function createServer() {
|
|
|
442
462
|
});
|
|
443
463
|
registerAppTool(server, "render_dashboard", {
|
|
444
464
|
title: "Dashboard",
|
|
445
|
-
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.",
|
|
446
466
|
inputSchema: {
|
|
447
467
|
title: z.string().describe("Dashboard title"),
|
|
448
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"),
|
|
@@ -457,6 +477,7 @@ export function createServer() {
|
|
|
457
477
|
layout: z.enum(["default", "hero-center", "kpi-top"]).optional().describe("Layout variant: default (hero above KPIs), hero-center (hero prominent), kpi-top (KPIs first)"),
|
|
458
478
|
},
|
|
459
479
|
_meta: { ui: { resourceUri: RESOURCE_URI } },
|
|
480
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
|
460
481
|
}, async (args) => {
|
|
461
482
|
const chartData = {
|
|
462
483
|
type: "dashboard",
|
|
@@ -483,22 +504,16 @@ export function createServer() {
|
|
|
483
504
|
const heroCount = Array.isArray(args.hero) ? args.hero.length : 1;
|
|
484
505
|
parts.push(`Hero: ${heroCount} widget${heroCount > 1 ? "s" : ""}`);
|
|
485
506
|
}
|
|
486
|
-
return
|
|
487
|
-
content: [
|
|
488
|
-
{ type: "text", text: parts.join(" | ") },
|
|
489
|
-
{ type: "text", text: JSON.stringify(chartData) },
|
|
490
|
-
],
|
|
491
|
-
structuredContent: chartData,
|
|
492
|
-
};
|
|
507
|
+
return await _buildChartResult(server, chartData, parts.join(" | "));
|
|
493
508
|
});
|
|
494
|
-
// -- Tool: render_chart_catalog --
|
|
495
509
|
registerAppTool(server, "render_chart_catalog", {
|
|
496
510
|
title: "Chart Catalog",
|
|
497
|
-
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.",
|
|
498
512
|
inputSchema: {
|
|
499
513
|
theme: ThemeParam,
|
|
500
514
|
},
|
|
501
515
|
_meta: { ui: { resourceUri: RESOURCE_URI } },
|
|
516
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
|
502
517
|
}, async (args) => {
|
|
503
518
|
// CSS-delegated charts spread c.data into the payload, so array data
|
|
504
519
|
// must be wrapped as { data: [...] } to land on payload.data correctly.
|
|
@@ -584,7 +599,7 @@ export function createServer() {
|
|
|
584
599
|
];
|
|
585
600
|
const kpis = [
|
|
586
601
|
{ label: "Total Chart Tools", value: 31, suffix: " tools" },
|
|
587
|
-
{ label: "Themes", value:
|
|
602
|
+
{ label: "Themes", value: 21, suffix: " presets" },
|
|
588
603
|
];
|
|
589
604
|
const chartData = {
|
|
590
605
|
type: "dashboard",
|
|
@@ -595,30 +610,117 @@ export function createServer() {
|
|
|
595
610
|
theme: args.theme,
|
|
596
611
|
footer: { text: "mcp-dashboards", lastUpdated: "Also available: render_table, render_from_json, render_from_url, render_live_chart, poll_http" },
|
|
597
612
|
};
|
|
598
|
-
return {
|
|
599
|
-
content: [
|
|
600
|
-
{ type: "text", text: "Chart Catalog: 22 visual previews of every embeddable chart type. Click any card to ask about it. Standalone-only tools (table, live, auto, URL) listed in footer." },
|
|
601
|
-
{ type: "text", text: JSON.stringify(chartData) },
|
|
602
|
-
],
|
|
603
|
-
structuredContent: chartData,
|
|
604
|
-
};
|
|
613
|
+
return await _buildChartResult(server, chartData, `Chart Catalog: ${charts.length} visual previews of every embeddable chart type. Click any card to ask about it. Standalone-only tools (table, live, auto, URL) listed in footer.`);
|
|
605
614
|
});
|
|
606
|
-
// -- Tool: render_theme_catalog --
|
|
607
615
|
registerAppTool(server, "render_theme_catalog", {
|
|
608
616
|
title: "Theme Catalog",
|
|
609
|
-
description: "Show a visual catalog of all
|
|
617
|
+
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.",
|
|
610
618
|
inputSchema: {},
|
|
611
619
|
_meta: { ui: { resourceUri: RESOURCE_URI } },
|
|
620
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
|
612
621
|
}, async () => {
|
|
613
|
-
return {
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
622
|
+
return await _buildChartResult(server, { type: "theme_catalog" }, "Theme Catalog: 21 theme previews with color swatches, typography, and effects. Click any card to use it.");
|
|
623
|
+
});
|
|
624
|
+
registerAppTool(server, "render_hero_catalog", {
|
|
625
|
+
title: "Hero Variant Catalog",
|
|
626
|
+
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.",
|
|
627
|
+
inputSchema: { theme: ThemeParam },
|
|
628
|
+
_meta: { ui: { resourceUri: RESOURCE_URI } },
|
|
629
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
|
630
|
+
}, async (args) => {
|
|
631
|
+
const charts = [
|
|
632
|
+
{ 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] } },
|
|
633
|
+
{ type: "hero", title: "Progress ring", data: { variant: "progress_ring", value: 87, unit: "%", label: "Q3 target", progress: 87 } },
|
|
634
|
+
{ 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 } },
|
|
635
|
+
{ type: "hero", title: "Comparison", data: { variant: "comparison", before: 23.5, after: 41.2, improvement: "+75%", beforeLabel: "Last Q", afterLabel: "This Q" } },
|
|
636
|
+
{ type: "hero", title: "Rank", data: { variant: "rank", rank: 3, total: 247, percentile: 99, rankChange: 5 } },
|
|
637
|
+
{ type: "hero", title: "Countdown", data: { variant: "countdown", segments: [{ value: 7, label: "days" }, { value: 14, label: "hours" }, { value: 32, label: "min" }] } },
|
|
638
|
+
{ 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" }] } },
|
|
639
|
+
{ 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" }] } },
|
|
640
|
+
{ type: "hero", title: "NPS", data: { variant: "nps", value: 67, max: 100, rating: "good" } },
|
|
641
|
+
{ type: "hero", title: "Orb", data: { variant: "orb", value: 87, unit: "%", label: "Active users", color: "#3b82f6" } },
|
|
642
|
+
{ type: "hero", title: "Gem", data: { variant: "gem", value: 1247, label: "Net worth", unit: "k", gemType: "diamond" } },
|
|
643
|
+
];
|
|
644
|
+
const chartData = {
|
|
645
|
+
type: "dashboard",
|
|
646
|
+
title: "Hero Variant Catalog",
|
|
647
|
+
kpis: [
|
|
648
|
+
{ label: "Variants", value: charts.length, suffix: " types" },
|
|
649
|
+
],
|
|
650
|
+
charts,
|
|
651
|
+
columns: 3,
|
|
652
|
+
theme: args.theme,
|
|
653
|
+
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)." },
|
|
654
|
+
};
|
|
655
|
+
return await _buildChartResult(server, chartData, `Hero Catalog: ${charts.length} variants of render_hero_metric. Click any card to ask about it.`);
|
|
656
|
+
});
|
|
657
|
+
registerAppTool(server, "render_effects_catalog", {
|
|
658
|
+
title: "Effects Catalog",
|
|
659
|
+
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.",
|
|
660
|
+
inputSchema: {},
|
|
661
|
+
_meta: { ui: { resourceUri: RESOURCE_URI } },
|
|
662
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
|
663
|
+
}, async () => {
|
|
664
|
+
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.");
|
|
665
|
+
});
|
|
666
|
+
registerAppTool(server, "render_catalog", {
|
|
667
|
+
title: "Master Catalog",
|
|
668
|
+
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).",
|
|
669
|
+
inputSchema: { theme: ThemeParam },
|
|
670
|
+
_meta: { ui: { resourceUri: RESOURCE_URI } },
|
|
671
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
|
672
|
+
}, async (args) => {
|
|
673
|
+
// Each tile is a real working chart - the card title carries the tool
|
|
674
|
+
// name so click + Ask routes the model to the matching sub-catalog,
|
|
675
|
+
// and the visual itself is what that catalog contains (variety of
|
|
676
|
+
// chart types, palette colors, a hero metric, an effect glow).
|
|
677
|
+
const charts = [
|
|
678
|
+
{
|
|
679
|
+
type: "bar",
|
|
680
|
+
title: "Charts",
|
|
681
|
+
labels: ["Pie", "Bar", "Line", "Radar", "Map", "Hero"],
|
|
682
|
+
datasets: [{ label: "Variety", data: [8, 14, 11, 6, 9, 12] }],
|
|
683
|
+
},
|
|
684
|
+
{
|
|
685
|
+
type: "pie",
|
|
686
|
+
title: "Themes",
|
|
687
|
+
data: [
|
|
688
|
+
{ label: "Boardroom", value: 18 },
|
|
689
|
+
{ label: "Tokyo-midnight", value: 16 },
|
|
690
|
+
{ label: "Forest-amber", value: 14 },
|
|
691
|
+
{ label: "Sky-ocean", value: 13 },
|
|
692
|
+
{ label: "Office-red", value: 12 },
|
|
693
|
+
{ label: "Black-matrix", value: 11 },
|
|
694
|
+
{ label: "Zen-garden", value: 16 },
|
|
695
|
+
],
|
|
696
|
+
},
|
|
697
|
+
{
|
|
698
|
+
type: "bar",
|
|
699
|
+
title: "Hero variants",
|
|
700
|
+
labels: ["Number", "Ring", "Status", "Compare", "Rank", "Count", "Bar", "NPS", "Orb", "Gem"],
|
|
701
|
+
datasets: [{ label: "Variants", data: [9, 12, 7, 10, 8, 6, 11, 9, 13, 10] }],
|
|
702
|
+
},
|
|
703
|
+
{
|
|
704
|
+
type: "bar",
|
|
705
|
+
title: "Effects",
|
|
706
|
+
labels: ["None", "Subtle", "Shimmer", "Neon", "Energetic"],
|
|
707
|
+
datasets: [{ label: "Presets", data: [4, 7, 11, 15, 13] }],
|
|
708
|
+
},
|
|
709
|
+
];
|
|
710
|
+
const chartData = {
|
|
711
|
+
type: "dashboard",
|
|
712
|
+
title: "MCP Dashboards Catalog",
|
|
713
|
+
kpis: [
|
|
714
|
+
{ label: "Total dimensions", value: charts.length, suffix: " catalogs" },
|
|
715
|
+
{ label: "Tip", value: "Click any tile, hit Ask" },
|
|
617
716
|
],
|
|
618
|
-
|
|
717
|
+
charts,
|
|
718
|
+
columns: 2,
|
|
719
|
+
theme: args.theme,
|
|
720
|
+
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." },
|
|
619
721
|
};
|
|
722
|
+
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).`);
|
|
620
723
|
});
|
|
621
|
-
// -- Tool: render_radar_chart --
|
|
622
724
|
_registerChartTool(server, "render_radar_chart", {
|
|
623
725
|
title: "Radar Chart",
|
|
624
726
|
description: "Render a radar (spider/web) chart - 'How do items compare across multiple dimensions?' Great for skill profiles, product comparisons, competitive analysis.",
|
|
@@ -643,7 +745,6 @@ export function createServer() {
|
|
|
643
745
|
const ds = args.datasets;
|
|
644
746
|
return `${args.title}: ${ds.map((d) => d.label).join(", ")} across ${args.labels.length} dimensions`;
|
|
645
747
|
});
|
|
646
|
-
// -- Tool: render_treemap_chart --
|
|
647
748
|
_registerChartTool(server, "render_treemap_chart", {
|
|
648
749
|
title: "Treemap",
|
|
649
750
|
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.",
|
|
@@ -667,7 +768,6 @@ export function createServer() {
|
|
|
667
768
|
const items = args.data;
|
|
668
769
|
return `${args.title}: ${items.length} items, largest: ${items.sort((a, b) => b.value - a.value)[0]?.label}`;
|
|
669
770
|
});
|
|
670
|
-
// -- Tool: render_sankey_chart --
|
|
671
771
|
_registerChartTool(server, "render_sankey_chart", {
|
|
672
772
|
title: "Sankey Diagram",
|
|
673
773
|
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.",
|
|
@@ -692,7 +792,6 @@ export function createServer() {
|
|
|
692
792
|
const nodes = [...new Set(flows.flatMap((f) => [f.from, f.to]))];
|
|
693
793
|
return `${args.title}: ${flows.length} flows across ${nodes.length} nodes`;
|
|
694
794
|
});
|
|
695
|
-
// -- Tool: render_wordcloud_chart --
|
|
696
795
|
_registerChartTool(server, "render_wordcloud_chart", {
|
|
697
796
|
title: "Word Cloud",
|
|
698
797
|
description: "Render a word cloud - 'What are the dominant themes?' Words sized by frequency or importance. Great for survey responses, keyword analysis, topic frequency.",
|
|
@@ -715,7 +814,6 @@ export function createServer() {
|
|
|
715
814
|
const top = words.sort((a, b) => b.value - a.value).slice(0, 5).map((w) => w.text);
|
|
716
815
|
return `${args.title}: ${words.length} words, top: ${top.join(", ")}`;
|
|
717
816
|
});
|
|
718
|
-
// -- Tool: render_boxplot_chart --
|
|
719
817
|
_registerChartTool(server, "render_boxplot_chart", {
|
|
720
818
|
title: "Boxplot / Violin",
|
|
721
819
|
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.",
|
|
@@ -741,7 +839,6 @@ export function createServer() {
|
|
|
741
839
|
const ds = args.datasets;
|
|
742
840
|
return `${args.title}: ${ds.map((d) => d.label).join(", ")} across ${args.labels.length} categories`;
|
|
743
841
|
});
|
|
744
|
-
// -- Tool: render_live_chart --
|
|
745
842
|
_registerChartTool(server, "render_live_chart", {
|
|
746
843
|
title: "Live Chart",
|
|
747
844
|
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.",
|
|
@@ -775,7 +872,6 @@ export function createServer() {
|
|
|
775
872
|
const series = args.values.map((v) => v.label).join(", ");
|
|
776
873
|
return `Live chart "${args.title}" - polling ${args.pollTool} every ${args.interval ?? 2}s: ${series}`;
|
|
777
874
|
});
|
|
778
|
-
// -- Tool: render_scatter_chart --
|
|
779
875
|
registerAppTool(server, "render_scatter_chart", {
|
|
780
876
|
title: "Scatter Chart",
|
|
781
877
|
description: "Render an interactive scatter plot with x/y coordinate data. Supports multiple series and optional connecting lines. Supports themes for styled visuals.",
|
|
@@ -789,6 +885,7 @@ export function createServer() {
|
|
|
789
885
|
effects: EffectsParam,
|
|
790
886
|
},
|
|
791
887
|
_meta: { ui: { resourceUri: RESOURCE_URI } },
|
|
888
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
|
792
889
|
}, async (args) => {
|
|
793
890
|
const chartData = {
|
|
794
891
|
type: "scatter",
|
|
@@ -803,15 +900,8 @@ export function createServer() {
|
|
|
803
900
|
const summary = args.datasets
|
|
804
901
|
.map((ds) => `${ds.label}: ${ds.data.length} points`)
|
|
805
902
|
.join("; ");
|
|
806
|
-
return {
|
|
807
|
-
content: [
|
|
808
|
-
{ type: "text", text: `${args.title} - ${summary}` },
|
|
809
|
-
{ type: "text", text: JSON.stringify(chartData) },
|
|
810
|
-
],
|
|
811
|
-
structuredContent: chartData,
|
|
812
|
-
};
|
|
903
|
+
return await _buildChartResult(server, chartData, `${args.title} - ${summary}`);
|
|
813
904
|
});
|
|
814
|
-
// -- Tool: render_candlestick_chart --
|
|
815
905
|
registerAppTool(server, "render_candlestick_chart", {
|
|
816
906
|
title: "Candlestick Chart",
|
|
817
907
|
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.",
|
|
@@ -825,6 +915,7 @@ export function createServer() {
|
|
|
825
915
|
effects: EffectsParam,
|
|
826
916
|
},
|
|
827
917
|
_meta: { ui: { resourceUri: RESOURCE_URI } },
|
|
918
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
|
828
919
|
}, async (args) => {
|
|
829
920
|
const chartData = {
|
|
830
921
|
type: "candlestick",
|
|
@@ -839,15 +930,8 @@ export function createServer() {
|
|
|
839
930
|
const first = args.data[0];
|
|
840
931
|
const last = args.data[args.data.length - 1];
|
|
841
932
|
const change = last ? ((last.c - first.o) / first.o * 100).toFixed(2) : "0";
|
|
842
|
-
return {
|
|
843
|
-
content: [
|
|
844
|
-
{ type: "text", text: `${args.title}: ${args.data.length} bars, ${first?.date ?? "?"} to ${last?.date ?? "?"}, change: ${change}%` },
|
|
845
|
-
{ type: "text", text: JSON.stringify(chartData) },
|
|
846
|
-
],
|
|
847
|
-
structuredContent: chartData,
|
|
848
|
-
};
|
|
933
|
+
return await _buildChartResult(server, chartData, `${args.title}: ${args.data.length} bars, ${first?.date ?? "?"} to ${last?.date ?? "?"}, change: ${change}%`);
|
|
849
934
|
});
|
|
850
|
-
// -- Tool: render_table --
|
|
851
935
|
registerAppTool(server, "render_table", {
|
|
852
936
|
title: "Data Table",
|
|
853
937
|
description: "Render a sortable, interactive data table. Click column headers to sort. Supports themes for styled visuals.",
|
|
@@ -865,6 +949,7 @@ export function createServer() {
|
|
|
865
949
|
effects: EffectsParam,
|
|
866
950
|
},
|
|
867
951
|
_meta: { ui: { resourceUri: RESOURCE_URI } },
|
|
952
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
|
868
953
|
}, async (args) => {
|
|
869
954
|
const chartData = {
|
|
870
955
|
type: "table",
|
|
@@ -877,15 +962,8 @@ export function createServer() {
|
|
|
877
962
|
typography: args.typography,
|
|
878
963
|
effects: args.effects,
|
|
879
964
|
};
|
|
880
|
-
return {
|
|
881
|
-
content: [
|
|
882
|
-
{ type: "text", text: `${args.title}: ${args.rows.length} rows, ${args.columns.length} columns` },
|
|
883
|
-
{ type: "text", text: JSON.stringify(chartData) },
|
|
884
|
-
],
|
|
885
|
-
structuredContent: chartData,
|
|
886
|
-
};
|
|
965
|
+
return await _buildChartResult(server, chartData, `${args.title}: ${args.rows.length} rows, ${args.columns.length} columns`);
|
|
887
966
|
});
|
|
888
|
-
// -- Tool: render_from_json --
|
|
889
967
|
registerAppTool(server, "render_from_json", {
|
|
890
968
|
title: "Auto Chart",
|
|
891
969
|
description: "Automatically detect the best chart type for arbitrary JSON data. Pass any JSON - arrays, objects, nested structures - and get the most appropriate visualization.",
|
|
@@ -897,6 +975,7 @@ export function createServer() {
|
|
|
897
975
|
}).optional(),
|
|
898
976
|
},
|
|
899
977
|
_meta: { ui: { resourceUri: RESOURCE_URI } },
|
|
978
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
|
900
979
|
}, async (args) => {
|
|
901
980
|
const chartData = {
|
|
902
981
|
type: "auto",
|
|
@@ -904,15 +983,8 @@ export function createServer() {
|
|
|
904
983
|
data: args.data,
|
|
905
984
|
options: args.options ?? {},
|
|
906
985
|
};
|
|
907
|
-
return {
|
|
908
|
-
content: [
|
|
909
|
-
{ type: "text", text: `Auto-visualizing: ${args.title}` },
|
|
910
|
-
{ type: "text", text: JSON.stringify(chartData) },
|
|
911
|
-
],
|
|
912
|
-
structuredContent: chartData,
|
|
913
|
-
};
|
|
986
|
+
return await _buildChartResult(server, chartData, `Auto-visualizing: ${args.title}`);
|
|
914
987
|
});
|
|
915
|
-
// -- Tool: render_from_url --
|
|
916
988
|
registerAppTool(server, "render_from_url", {
|
|
917
989
|
title: "Chart from URL",
|
|
918
990
|
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.",
|
|
@@ -924,9 +996,15 @@ export function createServer() {
|
|
|
924
996
|
}).optional(),
|
|
925
997
|
},
|
|
926
998
|
_meta: { ui: { resourceUri: RESOURCE_URI } },
|
|
999
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
927
1000
|
}, async (args) => {
|
|
928
1001
|
try {
|
|
929
|
-
|
|
1002
|
+
// SSRF protection: reject private/loopback/link-local destinations
|
|
1003
|
+
// unless the hostname is in MCP_URL_ALLOWLIST.
|
|
1004
|
+
const safeUrl = await assertSafeUrl(args.url);
|
|
1005
|
+
// Throttle outbound calls per hostname.
|
|
1006
|
+
await acquireOutbound(safeUrl.hostname);
|
|
1007
|
+
const response = await fetch(safeUrl, {
|
|
930
1008
|
headers: { "Accept": "application/json", "User-Agent": "MCP-Dashboard/1.0" },
|
|
931
1009
|
signal: AbortSignal.timeout(15000),
|
|
932
1010
|
});
|
|
@@ -943,42 +1021,92 @@ export function createServer() {
|
|
|
943
1021
|
data,
|
|
944
1022
|
options: args.options ?? {},
|
|
945
1023
|
};
|
|
946
|
-
return {
|
|
947
|
-
content: [
|
|
948
|
-
{ type: "text", text: `Fetched and visualizing: ${args.title} (from ${args.url})` },
|
|
949
|
-
{ type: "text", text: JSON.stringify(chartData) },
|
|
950
|
-
],
|
|
951
|
-
structuredContent: chartData,
|
|
952
|
-
};
|
|
1024
|
+
return await _buildChartResult(server, chartData, `Fetched and visualizing: ${args.title} (from ${args.url})`);
|
|
953
1025
|
}
|
|
954
1026
|
catch (err) {
|
|
1027
|
+
const msg = err instanceof UrlSafetyError
|
|
1028
|
+
? err.message
|
|
1029
|
+
: `Error fetching ${args.url}: ${err.message}`;
|
|
955
1030
|
return {
|
|
956
|
-
content: [{ type: "text", text:
|
|
1031
|
+
content: [{ type: "text", text: msg }],
|
|
957
1032
|
isError: true,
|
|
958
1033
|
};
|
|
959
1034
|
}
|
|
960
1035
|
});
|
|
961
|
-
// -- Tool: save_file
|
|
962
|
-
//
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
1036
|
+
// -- Tool: save_file --
|
|
1037
|
+
// Two-layer defense:
|
|
1038
|
+
// 1. _meta.ui.visibility=["app"] tells compliant MCP clients (Claude Code,
|
|
1039
|
+
// Claude Desktop, etc.) to NOT surface this tool to the LLM. Only the
|
|
1040
|
+
// View bundle running inside the chart iframe can invoke it via
|
|
1041
|
+
// app.callServerTool() over the MCP Apps PostMessage transport.
|
|
1042
|
+
// 2. Extension allowlist (.png, .csv) is the server-side enforcement that
|
|
1043
|
+
// runs regardless of client compliance, so even a buggy/malicious
|
|
1044
|
+
// client cannot make us write arbitrary file types to ~/Downloads.
|
|
1045
|
+
//
|
|
1046
|
+
// The View only ever emits PNG (chart screenshots) and CSV (table data) -
|
|
1047
|
+
// see addPngExportButton / addCsvExportButton in src/charts/shared.ts.
|
|
1048
|
+
const SAVE_FILE_ALLOWED_EXTS = new Set([".png", ".csv"]);
|
|
1049
|
+
registerAppTool(server, "save_file", {
|
|
1050
|
+
title: "Save File",
|
|
1051
|
+
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.",
|
|
1052
|
+
inputSchema: {
|
|
1053
|
+
filename: z.string().describe("Filename with extension (.png or .csv only)"),
|
|
1054
|
+
data: z.string().describe("File contents: base64-encoded binary or plain text"),
|
|
1055
|
+
encoding: z.enum(["base64", "utf-8"]).describe("How data is encoded"),
|
|
1056
|
+
},
|
|
1057
|
+
_meta: {
|
|
1058
|
+
ui: {
|
|
1059
|
+
resourceUri: RESOURCE_URI,
|
|
1060
|
+
visibility: ["app"],
|
|
1061
|
+
},
|
|
1062
|
+
},
|
|
1063
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
967
1064
|
}, async (args) => {
|
|
968
1065
|
try {
|
|
969
|
-
|
|
1066
|
+
// Sanitize the filename - replace path separators and Windows-reserved chars
|
|
1067
|
+
// with hyphens rather than using path.basename (which would strip the title
|
|
1068
|
+
// down to only the trailing segment when a chart title happens to contain `/`
|
|
1069
|
+
// or `\`, e.g. "Cost / yr" becoming "yr.png").
|
|
1070
|
+
const INVALID = /[\\/:*?"<>|\x00-\x1f]/g;
|
|
1071
|
+
const extMatch = args.filename.match(/\.[A-Za-z0-9]{1,5}$/);
|
|
1072
|
+
const ext = (extMatch ? extMatch[0] : "").toLowerCase();
|
|
1073
|
+
// Server-side extension allowlist - defense even if a non-compliant
|
|
1074
|
+
// client surfaces this tool to the LLM despite the visibility hint.
|
|
1075
|
+
if (!SAVE_FILE_ALLOWED_EXTS.has(ext)) {
|
|
1076
|
+
return {
|
|
1077
|
+
content: [{
|
|
1078
|
+
type: "text",
|
|
1079
|
+
text: `Refusing to save: extension "${ext || "(none)"}" not in allowlist. Allowed: ${[...SAVE_FILE_ALLOWED_EXTS].join(", ")}`,
|
|
1080
|
+
}],
|
|
1081
|
+
isError: true,
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
const base = ext ? args.filename.slice(0, -ext.length) : args.filename;
|
|
1085
|
+
let sanitized = (base.replace(INVALID, "-").trim() || "chart") + ext;
|
|
1086
|
+
// Cap to 200 chars to stay comfortably under the 255 byte NTFS limit.
|
|
1087
|
+
if (sanitized.length > 200) {
|
|
1088
|
+
sanitized = sanitized.slice(0, 200 - ext.length) + ext;
|
|
1089
|
+
}
|
|
970
1090
|
const downloadsDir = path.join(os.homedir(), "Downloads");
|
|
971
|
-
// Ensure Downloads folder exists
|
|
972
1091
|
await fs.mkdir(downloadsDir, { recursive: true });
|
|
973
1092
|
const filePath = path.join(downloadsDir, sanitized);
|
|
1093
|
+
// Defense in depth: confirm the resolved path stays inside Downloads.
|
|
1094
|
+
const resolved = path.resolve(filePath);
|
|
1095
|
+
const downloadsResolved = path.resolve(downloadsDir);
|
|
1096
|
+
if (path.dirname(resolved) !== downloadsResolved) {
|
|
1097
|
+
return {
|
|
1098
|
+
content: [{ type: "text", text: "Refusing to save: path escapes Downloads directory" }],
|
|
1099
|
+
isError: true,
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
974
1102
|
if (args.encoding === "base64") {
|
|
975
|
-
await fs.writeFile(
|
|
1103
|
+
await fs.writeFile(resolved, Buffer.from(args.data, "base64"));
|
|
976
1104
|
}
|
|
977
1105
|
else {
|
|
978
|
-
await fs.writeFile(
|
|
1106
|
+
await fs.writeFile(resolved, args.data, "utf-8");
|
|
979
1107
|
}
|
|
980
1108
|
return {
|
|
981
|
-
content: [{ type: "text", text: `Saved to ${
|
|
1109
|
+
content: [{ type: "text", text: `Saved to ${resolved}` }],
|
|
982
1110
|
};
|
|
983
1111
|
}
|
|
984
1112
|
catch (err) {
|
|
@@ -988,7 +1116,136 @@ export function createServer() {
|
|
|
988
1116
|
};
|
|
989
1117
|
}
|
|
990
1118
|
});
|
|
991
|
-
// --
|
|
1119
|
+
// -- Tool: list_chart_files (model-visible) --
|
|
1120
|
+
// Lists chart preview HTML files currently on disk in the temp folder.
|
|
1121
|
+
// Returns name, ID, size (KB), and modified timestamp for each. Read-only.
|
|
1122
|
+
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 () => {
|
|
1123
|
+
try {
|
|
1124
|
+
const entries = await fs.readdir(TEMP_DIR).catch(() => []);
|
|
1125
|
+
const files = [];
|
|
1126
|
+
for (const name of entries) {
|
|
1127
|
+
if (!CHART_FILENAME_RE.test(name))
|
|
1128
|
+
continue;
|
|
1129
|
+
try {
|
|
1130
|
+
const stat = await fs.stat(path.join(TEMP_DIR, name));
|
|
1131
|
+
const id = name.replace(/^chart-/, "").replace(/\.html$/, "");
|
|
1132
|
+
files.push({
|
|
1133
|
+
id,
|
|
1134
|
+
name,
|
|
1135
|
+
sizeKB: Math.round(stat.size / 1024),
|
|
1136
|
+
modifiedAt: new Date(stat.mtimeMs).toISOString(),
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
catch { /* skip locked / permission-denied */ }
|
|
1140
|
+
}
|
|
1141
|
+
// Sort newest first
|
|
1142
|
+
files.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt));
|
|
1143
|
+
const retainDays = Number.parseInt(process.env.MCP_DASHBOARDS_RETAIN_DAYS ?? "7", 10);
|
|
1144
|
+
const summary = files.length === 0
|
|
1145
|
+
? `No chart files in ${TEMP_DIR}`
|
|
1146
|
+
: `${files.length} chart file(s) in ${TEMP_DIR} (auto-cleanup at ${retainDays} days)`;
|
|
1147
|
+
const structured = { dir: TEMP_DIR, retainDays, files };
|
|
1148
|
+
return {
|
|
1149
|
+
content: [
|
|
1150
|
+
{ type: "text", text: summary },
|
|
1151
|
+
{ type: "text", text: JSON.stringify(structured) },
|
|
1152
|
+
],
|
|
1153
|
+
structuredContent: structured,
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
catch (err) {
|
|
1157
|
+
return {
|
|
1158
|
+
content: [{ type: "text", text: `Failed to list chart files: ${err.message}` }],
|
|
1159
|
+
isError: true,
|
|
1160
|
+
};
|
|
1161
|
+
}
|
|
1162
|
+
});
|
|
1163
|
+
// -- Tool: delete_chart_files (destructive) --
|
|
1164
|
+
// Manually deletes chart preview HTML files. User-initiated only (the AI calls
|
|
1165
|
+
// this when the user explicitly asks). Scope is hard-locked to the temp subfolder
|
|
1166
|
+
// and the chart-{hex}.html pattern - it cannot delete anything else.
|
|
1167
|
+
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).", {
|
|
1168
|
+
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."),
|
|
1169
|
+
olderThanDays: z.number().int().positive().optional().describe("Delete chart files older than N days (positive integer)."),
|
|
1170
|
+
all: z.boolean().optional().describe("Explicit confirmation to delete ALL chart files in the temp folder. Defaults to false."),
|
|
1171
|
+
}, { readOnlyHint: false, destructiveHint: true, idempotentHint: false }, async (args) => {
|
|
1172
|
+
const { chartIds, olderThanDays, all } = args;
|
|
1173
|
+
if (!chartIds?.length && olderThanDays === undefined && !all) {
|
|
1174
|
+
return {
|
|
1175
|
+
content: [{
|
|
1176
|
+
type: "text",
|
|
1177
|
+
text: "delete_chart_files requires at least one of: chartIds, olderThanDays, or all=true. Refusing to act on empty input.",
|
|
1178
|
+
}],
|
|
1179
|
+
isError: true,
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
const deleted = [];
|
|
1183
|
+
const failed = [];
|
|
1184
|
+
try {
|
|
1185
|
+
const entries = await fs.readdir(TEMP_DIR).catch(() => []);
|
|
1186
|
+
const now = Date.now();
|
|
1187
|
+
const ageCutoff = olderThanDays !== undefined
|
|
1188
|
+
? now - olderThanDays * 24 * 60 * 60 * 1000
|
|
1189
|
+
: null;
|
|
1190
|
+
const idSet = chartIds?.length ? new Set(chartIds.filter((id) => /^[a-f0-9]{12}$/.test(id))) : null;
|
|
1191
|
+
for (const name of entries) {
|
|
1192
|
+
if (!CHART_FILENAME_RE.test(name))
|
|
1193
|
+
continue;
|
|
1194
|
+
const id = name.replace(/^chart-/, "").replace(/\.html$/, "");
|
|
1195
|
+
// Decide whether this file qualifies for deletion
|
|
1196
|
+
let qualifies = false;
|
|
1197
|
+
if (all)
|
|
1198
|
+
qualifies = true;
|
|
1199
|
+
if (!qualifies && idSet?.has(id))
|
|
1200
|
+
qualifies = true;
|
|
1201
|
+
if (!qualifies && ageCutoff !== null) {
|
|
1202
|
+
try {
|
|
1203
|
+
const stat = await fs.stat(path.join(TEMP_DIR, name));
|
|
1204
|
+
if (stat.mtimeMs < ageCutoff)
|
|
1205
|
+
qualifies = true;
|
|
1206
|
+
}
|
|
1207
|
+
catch { /* fall through, treat as not qualifying */ }
|
|
1208
|
+
}
|
|
1209
|
+
if (!qualifies)
|
|
1210
|
+
continue;
|
|
1211
|
+
// Verify the resolved path stays inside TEMP_DIR (defense in depth)
|
|
1212
|
+
const full = path.join(TEMP_DIR, name);
|
|
1213
|
+
if (path.dirname(full) !== TEMP_DIR) {
|
|
1214
|
+
failed.push({ id, name, reason: "path escapes temp dir" });
|
|
1215
|
+
continue;
|
|
1216
|
+
}
|
|
1217
|
+
try {
|
|
1218
|
+
await fs.unlink(full);
|
|
1219
|
+
// Also evict from in-memory chart store so the localhost URL stops
|
|
1220
|
+
// resolving. Without this, the URL keeps serving the cached chart
|
|
1221
|
+
// even though the file is gone - confusing for users who deleted
|
|
1222
|
+
// the chart and expect the link to be dead.
|
|
1223
|
+
evictChartFromCache(id);
|
|
1224
|
+
deleted.push({ id, name });
|
|
1225
|
+
}
|
|
1226
|
+
catch (err) {
|
|
1227
|
+
failed.push({ id, name, reason: err.code || err.message || "unknown error" });
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
const parts = [`Deleted ${deleted.length} chart file(s) from ${TEMP_DIR}`];
|
|
1231
|
+
if (failed.length)
|
|
1232
|
+
parts.push(`${failed.length} failed (locked or permission issues)`);
|
|
1233
|
+
const structured = { dir: TEMP_DIR, deleted, failed };
|
|
1234
|
+
return {
|
|
1235
|
+
content: [
|
|
1236
|
+
{ type: "text", text: parts.join("; ") },
|
|
1237
|
+
{ type: "text", text: JSON.stringify(structured) },
|
|
1238
|
+
],
|
|
1239
|
+
structuredContent: structured,
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
catch (err) {
|
|
1243
|
+
return {
|
|
1244
|
+
content: [{ type: "text", text: `Failed to delete chart files: ${err.message}` }],
|
|
1245
|
+
isError: true,
|
|
1246
|
+
};
|
|
1247
|
+
}
|
|
1248
|
+
});
|
|
992
1249
|
_registerChartTool(server, "render_bullet_chart", {
|
|
993
1250
|
title: "Bullet Chart",
|
|
994
1251
|
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.",
|
|
@@ -1341,7 +1598,7 @@ export function createServer() {
|
|
|
1341
1598
|
headers: z.record(z.string(), z.string()).optional().describe("Extra HTTP headers (public APIs only - NEVER put API keys here)"),
|
|
1342
1599
|
method: z.enum(["GET", "POST"]).optional().describe("HTTP method. Default: GET"),
|
|
1343
1600
|
body: z.string().optional().describe("Request body for POST requests"),
|
|
1344
|
-
}, async (args) => {
|
|
1601
|
+
}, { readOnlyHint: true, destructiveHint: false, openWorldHint: true }, async (args) => {
|
|
1345
1602
|
try {
|
|
1346
1603
|
let fetchUrl;
|
|
1347
1604
|
let fetchHeaders = {};
|
|
@@ -1369,7 +1626,19 @@ export function createServer() {
|
|
|
1369
1626
|
if (args.headers) {
|
|
1370
1627
|
fetchHeaders = { ...args.headers, ...fetchHeaders };
|
|
1371
1628
|
}
|
|
1372
|
-
|
|
1629
|
+
// SSRF protection: validate target before fetch. Presets are trusted
|
|
1630
|
+
// (operator-configured at env-var level), so they skip the check; raw
|
|
1631
|
+
// URLs from the AI go through the full guard.
|
|
1632
|
+
let safeUrl;
|
|
1633
|
+
if (args.preset) {
|
|
1634
|
+
safeUrl = new URL(fetchUrl);
|
|
1635
|
+
}
|
|
1636
|
+
else {
|
|
1637
|
+
safeUrl = await assertSafeUrl(fetchUrl);
|
|
1638
|
+
}
|
|
1639
|
+
// Throttle outbound calls per hostname (covers both presets and raw).
|
|
1640
|
+
await acquireOutbound(safeUrl.hostname);
|
|
1641
|
+
const resp = await fetch(safeUrl, {
|
|
1373
1642
|
method: args.method ?? "GET",
|
|
1374
1643
|
headers: fetchHeaders,
|
|
1375
1644
|
body: args.method === "POST" ? args.body : undefined,
|
|
@@ -1384,8 +1653,11 @@ export function createServer() {
|
|
|
1384
1653
|
return { content: [{ type: "text", text }] };
|
|
1385
1654
|
}
|
|
1386
1655
|
catch (err) {
|
|
1656
|
+
const msg = err instanceof UrlSafetyError
|
|
1657
|
+
? err.message
|
|
1658
|
+
: `poll_http failed: ${err.message}`;
|
|
1387
1659
|
return {
|
|
1388
|
-
content: [{ type: "text", text:
|
|
1660
|
+
content: [{ type: "text", text: msg }],
|
|
1389
1661
|
isError: true,
|
|
1390
1662
|
};
|
|
1391
1663
|
}
|