mcp-dashboards 2.1.0 → 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/dist/server.js CHANGED
@@ -1,11 +1,12 @@
1
- import { getUiCapability, registerAppResource, registerAppTool, RESOURCE_MIME_TYPE, } from "@modelcontextprotocol/ext-apps/server";
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
- // Tracks whether each server's connected client supports MCP Apps inline rendering.
171
- // Set via oninitialized callback in createServer.
172
- const _clientSupportsInline = new WeakMap();
173
- // Builds the standard tool response. If the client doesn't support MCP Apps
174
- // inline rendering, appends browser preview links (localhost HTTP + standalone file)
175
- // to the text content. MCP Apps clients only get the structuredContent - they
176
- // already render the chart inline, so preview URLs would be redundant clutter.
177
- async function _buildChartResult(server, chartData, summary) {
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: summary },
203
+ { type: "text", text: enrichedSummary },
180
204
  ];
181
- if (!_clientSupportsInline.get(server)) {
182
- const urls = await getPreviewUrls(chartData);
183
- if (urls) {
184
- content.push({
185
- type: "text",
186
- text: `\n## View this chart\n` +
187
- `Your AI client doesn't render MCP Apps inline, so use one of these links to see the interactive chart in your browser:\n\n` +
188
- `**Click to open (recommended):** ${urls.httpUrl}\n` +
189
- ` - Opens instantly in your default browser\n` +
190
- ` - Only works while this MCP server is running\n\n` +
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(chartData) });
198
- return { content, structuredContent: chartData };
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.0.0",
225
+ version: "2.2.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
  },
@@ -592,7 +599,7 @@ export function createServer() {
592
599
  ];
593
600
  const kpis = [
594
601
  { label: "Total Chart Tools", value: 31, suffix: " tools" },
595
- { label: "Themes", value: 20, suffix: " presets" },
602
+ { label: "Themes", value: 21, suffix: " presets" },
596
603
  ];
597
604
  const chartData = {
598
605
  type: "dashboard",
@@ -603,25 +610,117 @@ export function createServer() {
603
610
  theme: args.theme,
604
611
  footer: { text: "mcp-dashboards", lastUpdated: "Also available: render_table, render_from_json, render_from_url, render_live_chart, poll_http" },
605
612
  };
606
- return await _buildChartResult(server, chartData, "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.");
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.`);
607
614
  });
608
- // -- Tool: render_theme_catalog --
609
615
  registerAppTool(server, "render_theme_catalog", {
610
616
  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.",
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.",
612
618
  inputSchema: {},
613
619
  _meta: { ui: { resourceUri: RESOURCE_URI } },
614
620
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
615
621
  }, async () => {
616
- return {
617
- content: [
618
- { type: "text", text: "Theme Catalog: 20 theme previews with color swatches, typography, and effects. Click any card to use it." },
619
- { type: "text", text: JSON.stringify({ type: "theme_catalog" }) },
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" },
620
649
  ],
621
- structuredContent: { type: "theme_catalog" },
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)." },
622
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" },
716
+ ],
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." },
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).`);
623
723
  });
624
- // -- Tool: render_radar_chart --
625
724
  _registerChartTool(server, "render_radar_chart", {
626
725
  title: "Radar Chart",
627
726
  description: "Render a radar (spider/web) chart - 'How do items compare across multiple dimensions?' Great for skill profiles, product comparisons, competitive analysis.",
@@ -646,7 +745,6 @@ export function createServer() {
646
745
  const ds = args.datasets;
647
746
  return `${args.title}: ${ds.map((d) => d.label).join(", ")} across ${args.labels.length} dimensions`;
648
747
  });
649
- // -- Tool: render_treemap_chart --
650
748
  _registerChartTool(server, "render_treemap_chart", {
651
749
  title: "Treemap",
652
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.",
@@ -670,7 +768,6 @@ export function createServer() {
670
768
  const items = args.data;
671
769
  return `${args.title}: ${items.length} items, largest: ${items.sort((a, b) => b.value - a.value)[0]?.label}`;
672
770
  });
673
- // -- Tool: render_sankey_chart --
674
771
  _registerChartTool(server, "render_sankey_chart", {
675
772
  title: "Sankey Diagram",
676
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.",
@@ -695,7 +792,6 @@ export function createServer() {
695
792
  const nodes = [...new Set(flows.flatMap((f) => [f.from, f.to]))];
696
793
  return `${args.title}: ${flows.length} flows across ${nodes.length} nodes`;
697
794
  });
698
- // -- Tool: render_wordcloud_chart --
699
795
  _registerChartTool(server, "render_wordcloud_chart", {
700
796
  title: "Word Cloud",
701
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.",
@@ -718,7 +814,6 @@ export function createServer() {
718
814
  const top = words.sort((a, b) => b.value - a.value).slice(0, 5).map((w) => w.text);
719
815
  return `${args.title}: ${words.length} words, top: ${top.join(", ")}`;
720
816
  });
721
- // -- Tool: render_boxplot_chart --
722
817
  _registerChartTool(server, "render_boxplot_chart", {
723
818
  title: "Boxplot / Violin",
724
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.",
@@ -744,7 +839,6 @@ export function createServer() {
744
839
  const ds = args.datasets;
745
840
  return `${args.title}: ${ds.map((d) => d.label).join(", ")} across ${args.labels.length} categories`;
746
841
  });
747
- // -- Tool: render_live_chart --
748
842
  _registerChartTool(server, "render_live_chart", {
749
843
  title: "Live Chart",
750
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.",
@@ -778,7 +872,6 @@ export function createServer() {
778
872
  const series = args.values.map((v) => v.label).join(", ");
779
873
  return `Live chart "${args.title}" - polling ${args.pollTool} every ${args.interval ?? 2}s: ${series}`;
780
874
  });
781
- // -- Tool: render_scatter_chart --
782
875
  registerAppTool(server, "render_scatter_chart", {
783
876
  title: "Scatter Chart",
784
877
  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 +902,6 @@ export function createServer() {
809
902
  .join("; ");
810
903
  return await _buildChartResult(server, chartData, `${args.title} - ${summary}`);
811
904
  });
812
- // -- Tool: render_candlestick_chart --
813
905
  registerAppTool(server, "render_candlestick_chart", {
814
906
  title: "Candlestick Chart",
815
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.",
@@ -840,7 +932,6 @@ export function createServer() {
840
932
  const change = last ? ((last.c - first.o) / first.o * 100).toFixed(2) : "0";
841
933
  return await _buildChartResult(server, chartData, `${args.title}: ${args.data.length} bars, ${first?.date ?? "?"} to ${last?.date ?? "?"}, change: ${change}%`);
842
934
  });
843
- // -- Tool: render_table --
844
935
  registerAppTool(server, "render_table", {
845
936
  title: "Data Table",
846
937
  description: "Render a sortable, interactive data table. Click column headers to sort. Supports themes for styled visuals.",
@@ -873,7 +964,6 @@ export function createServer() {
873
964
  };
874
965
  return await _buildChartResult(server, chartData, `${args.title}: ${args.rows.length} rows, ${args.columns.length} columns`);
875
966
  });
876
- // -- Tool: render_from_json --
877
967
  registerAppTool(server, "render_from_json", {
878
968
  title: "Auto Chart",
879
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.",
@@ -895,7 +985,6 @@ export function createServer() {
895
985
  };
896
986
  return await _buildChartResult(server, chartData, `Auto-visualizing: ${args.title}`);
897
987
  });
898
- // -- Tool: render_from_url --
899
988
  registerAppTool(server, "render_from_url", {
900
989
  title: "Chart from URL",
901
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.",
@@ -910,7 +999,12 @@ export function createServer() {
910
999
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
911
1000
  }, async (args) => {
912
1001
  try {
913
- const response = await fetch(args.url, {
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, {
914
1008
  headers: { "Accept": "application/json", "User-Agent": "MCP-Dashboard/1.0" },
915
1009
  signal: AbortSignal.timeout(15000),
916
1010
  });
@@ -930,33 +1024,89 @@ export function createServer() {
930
1024
  return await _buildChartResult(server, chartData, `Fetched and visualizing: ${args.title} (from ${args.url})`);
931
1025
  }
932
1026
  catch (err) {
1027
+ const msg = err instanceof UrlSafetyError
1028
+ ? err.message
1029
+ : `Error fetching ${args.url}: ${err.message}`;
933
1030
  return {
934
- content: [{ type: "text", text: `Error fetching ${args.url}: ${err.message}` }],
1031
+ content: [{ type: "text", text: msg }],
935
1032
  isError: true,
936
1033
  };
937
1034
  }
938
1035
  });
939
- // -- Tool: save_file (app-only, invisible to AI model) --
940
- // Used by the UI to save exports since iframe sandbox blocks direct downloads.
941
- server.tool("save_file", "Save a file to the user's Downloads folder. Used internally by the dashboard UI for PNG/CSV export.", {
942
- filename: z.string().describe("Filename with extension (e.g. chart.png)"),
943
- data: z.string().describe("File contents: base64-encoded binary or plain text"),
944
- encoding: z.enum(["base64", "utf-8"]).describe("How data is encoded"),
945
- }, { readOnlyHint: false, destructiveHint: false, idempotentHint: true }, async (args) => {
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 },
1064
+ }, async (args) => {
946
1065
  try {
947
- const sanitized = path.basename(args.filename);
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
+ }
948
1090
  const downloadsDir = path.join(os.homedir(), "Downloads");
949
- // Ensure Downloads folder exists
950
1091
  await fs.mkdir(downloadsDir, { recursive: true });
951
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
+ }
952
1102
  if (args.encoding === "base64") {
953
- await fs.writeFile(filePath, Buffer.from(args.data, "base64"));
1103
+ await fs.writeFile(resolved, Buffer.from(args.data, "base64"));
954
1104
  }
955
1105
  else {
956
- await fs.writeFile(filePath, args.data, "utf-8");
1106
+ await fs.writeFile(resolved, args.data, "utf-8");
957
1107
  }
958
1108
  return {
959
- content: [{ type: "text", text: `Saved to ${filePath}` }],
1109
+ content: [{ type: "text", text: `Saved to ${resolved}` }],
960
1110
  };
961
1111
  }
962
1112
  catch (err) {
@@ -966,7 +1116,136 @@ export function createServer() {
966
1116
  };
967
1117
  }
968
1118
  });
969
- // -- New chart tools (Phase 2) --
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
+ });
970
1249
  _registerChartTool(server, "render_bullet_chart", {
971
1250
  title: "Bullet Chart",
972
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.",
@@ -1347,7 +1626,19 @@ export function createServer() {
1347
1626
  if (args.headers) {
1348
1627
  fetchHeaders = { ...args.headers, ...fetchHeaders };
1349
1628
  }
1350
- const resp = await fetch(fetchUrl, {
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, {
1351
1642
  method: args.method ?? "GET",
1352
1643
  headers: fetchHeaders,
1353
1644
  body: args.method === "POST" ? args.body : undefined,
@@ -1362,8 +1653,11 @@ export function createServer() {
1362
1653
  return { content: [{ type: "text", text }] };
1363
1654
  }
1364
1655
  catch (err) {
1656
+ const msg = err instanceof UrlSafetyError
1657
+ ? err.message
1658
+ : `poll_http failed: ${err.message}`;
1365
1659
  return {
1366
- content: [{ type: "text", text: `poll_http failed: ${err.message}` }],
1660
+ content: [{ type: "text", text: msg }],
1367
1661
  isError: true,
1368
1662
  };
1369
1663
  }