@slickfast/mcp 0.1.1 → 0.2.1

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.
Files changed (3) hide show
  1. package/README.md +30 -2
  2. package/dist/index.js +45 -20
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -33,8 +33,36 @@ Project-scoped config (`.mcp.json` at the workspace root):
33
33
  ```
34
34
  Restart the session; the `render_chart` tool becomes available.
35
35
 
36
- Note: stdio returns SVG text. For charts to appear *inline* in a chat surface
37
- (Discord/Slack/Desktop), the tool needs to return PNG — that's the raster step (paid path), added later.
36
+ ## Seeing your charts (why an image might not show inline)
37
+
38
+ `render_chart` returns a real **PNG by default** — a standard MCP image block
39
+ (`{ type: 'image', mimeType: 'image/png', … }`), rendered and rasterized **locally**.
40
+ Nothing leaves your machine. Whether that PNG paints *inline* depends on the **surface**
41
+ you're chatting in, not on SlickFast:
42
+
43
+ | Surface | Shows image inline? | MCP config |
44
+ |---|---|---|
45
+ | Claude Desktop **chat** | ✅ yes | `claude_desktop_config.json` |
46
+ | claude.ai | ✅ yes | connector settings |
47
+ | Claude **Code** session — including the Code panel *inside* the Desktop app | ❌ no — it's a coding transcript; it hands the image to the agent but doesn't paint it in your scrollback | project `.mcp.json` |
48
+ | Plain terminal | ❌ no — terminals can't display pixels | — |
49
+
50
+ **If you don't see a chart, three checks (the order an agent should walk through):**
51
+ 1. **Did it render?** If the agent can tell you the values, the PNG is fine — this is a
52
+ *display* gap, not a SlickFast bug. Don't restart, don't reconfigure.
53
+ 2. **Which surface are you in?** A Claude Code session won't paint it *even inside the
54
+ Desktop app* — that trips people up. Run the same request in a **Claude Desktop chat**
55
+ (or claude.ai) to see it inline.
56
+ 3. **Is the tool missing or erroring entirely?** *Then* it's a connection problem —
57
+ restart so the MCP server reconnects.
58
+
59
+ ## Sharing a chart as a URL (hosted API)
60
+
61
+ Rendering is fully local and needs no network. Separately, SlickFast runs a **hosted API**
62
+ that turns a spec into a public `…/chart.png?spec=<url-encoded spec>` link — useful for
63
+ embedding, posting to Slack/X/email, or getting a chart into a surface that can't display a
64
+ local image (see the table above). The agent should **offer this only when you ask to share
65
+ or post** — it never auto-inserts links. (The endpoint stays unadvertised during soft-launch.)
38
66
 
39
67
  ## Install via npm (published)
40
68
  ```json
package/dist/index.js CHANGED
@@ -4,9 +4,10 @@
4
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
6
  import { z } from "zod";
7
- import { readFileSync } from "node:fs";
7
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
8
8
  import { fileURLToPath } from "node:url";
9
- import { dirname, join } from "node:path";
9
+ import { dirname, join, resolve } from "node:path";
10
+ import { homedir } from "node:os";
10
11
 
11
12
  // ../../packages/palette-core/tokens.json
12
13
  var tokens_default = {
@@ -282,7 +283,7 @@ function renderBar(spec) {
282
283
  const values = spec.data.series[0].values.map((v) => Number(v) || 0);
283
284
  const u = spec.valueUnit ? " " + spec.valueUnit : "";
284
285
  const showValues = spec.showValues !== false;
285
- const showTotal = spec.showTotal !== false;
286
+ const showTotal = spec.showTotal !== false && spec.valueUnit !== "%";
286
287
  const watermark = spec.watermark !== false;
287
288
  const explicit = spec.data.series[0].colors;
288
289
  const colors = Array.isArray(explicit) && explicit.length >= labels.length ? explicit : resolveFlatPalette(spec.palette || "Clean Corporate", labels.length);
@@ -572,7 +573,7 @@ function renderBarH(spec) {
572
573
  const values = spec.data.series[0].values.map((v) => Number(v) || 0);
573
574
  const u = spec.valueUnit ? " " + spec.valueUnit : "";
574
575
  const showValues = spec.showValues !== false;
575
- const showTotal = spec.showTotal !== false;
576
+ const showTotal = spec.showTotal !== false && spec.valueUnit !== "%";
576
577
  const watermark = spec.watermark !== false;
577
578
  const explicit = spec.data.series[0].colors;
578
579
  const colors = Array.isArray(explicit) && explicit.length >= labels.length ? explicit : resolveFlatPalette(spec.palette || "Clean Corporate", labels.length);
@@ -583,7 +584,7 @@ function renderBarH(spec) {
583
584
  const titleCol = txt(spec, isDark ? "#f1f5f9" : "#0f172a");
584
585
  const faint = isDark ? "#475569" : "#94a3b8";
585
586
  const longest = Math.max(0, ...labels.map((l) => String(l).length));
586
- const M = { left: Math.min(160, 24 + longest * (fs * 0.62)), right: 52, top: title ? 54 : 28, bottom: 46 };
587
+ const M = { left: Math.max(56, Math.min(W * 0.42, 28 + longest * (fs * 0.62))), right: 52, top: title ? 54 : 28, bottom: 46 };
587
588
  const plotW = W - M.left - M.right;
588
589
  const plotH = H - M.top - M.bottom;
589
590
  const maxV = Math.max(0, ...values);
@@ -634,9 +635,11 @@ function renderDiverging(spec) {
634
635
  const watermark = spec.watermark !== false;
635
636
  const cols = resolveFlatPalette(spec.palette || "Clean Corporate", 8);
636
637
  const mid = Math.floor(cols.length / 2);
638
+ const singleSigned = series.length < 2;
637
639
  const A = series[0], B = series[1] || { name: "", values: [] };
638
640
  const colA = A.color || cols[0];
639
641
  const colB = B.color || cols[mid] || cols[1];
642
+ const colDown = singleSigned ? colA : colB;
640
643
  const axisText = txt(spec, isDark ? "#94a3b8" : "#64748b");
641
644
  const gridCol = isDark ? "#1e293b" : "#e2e8f0";
642
645
  const zeroCol = isDark ? "#64748b" : "#334155";
@@ -647,8 +650,14 @@ function renderDiverging(spec) {
647
650
  const M = { left: 56, right: 24, top: title ? 54 : 28, bottom: 64 };
648
651
  const plotW = W - M.left - M.right;
649
652
  const plotH = H - M.top - M.bottom;
650
- const aVals = labels.map((_, i) => Math.abs(Number(A.values[i]) || 0));
651
- const bVals = labels.map((_, i) => Math.abs(Number(B.values[i]) || 0));
653
+ const aVals = labels.map((_, i) => {
654
+ const n = Number(A.values[i]) || 0;
655
+ return singleSigned ? Math.max(0, n) : Math.abs(n);
656
+ });
657
+ const bVals = labels.map((_, i) => {
658
+ const n = singleSigned ? Number(A.values[i]) || 0 : Number(B.values[i]) || 0;
659
+ return singleSigned ? Math.max(0, -n) : Math.abs(n);
660
+ });
652
661
  const S = Math.max(1, ...aVals, ...bVals);
653
662
  const { step, niceMax } = niceScale(S);
654
663
  const half = plotH / 2;
@@ -677,8 +686,9 @@ function renderDiverging(spec) {
677
686
  }
678
687
  if (bVals[i] > 0) {
679
688
  const h = yDn(bVals[i]) - yZero;
680
- p.push(`<path d="${bottomRoundedBar(x, yZero, barW, h, 4)}" fill="${colB}"/>`);
681
- if (showValues) p.push(`<text x="${cx}" y="${r(yDn(bVals[i]) + 16)}" text-anchor="middle" font-size="${fs - 1}" ${wAttr(spec, 600)} fill="${valText}">${esc(fmt(bVals[i]) + u)}</text>`);
689
+ p.push(`<path d="${bottomRoundedBar(x, yZero, barW, h, 4)}" fill="${colDown}"/>`);
690
+ const downLabel = (singleSigned ? "-" : "") + fmt(bVals[i]) + u;
691
+ if (showValues) p.push(`<text x="${cx}" y="${r(yDn(bVals[i]) + 16)}" text-anchor="middle" font-size="${fs - 1}" ${wAttr(spec, 600)} fill="${valText}">${esc(downLabel)}</text>`);
682
692
  }
683
693
  });
684
694
  p.push(`<line x1="${M.left}" y1="${r(yZero)}" x2="${r(M.left + plotW)}" y2="${r(yZero)}" stroke="${zeroCol}" stroke-width="1.75"/>`);
@@ -689,7 +699,7 @@ function renderDiverging(spec) {
689
699
  p.push(`<rect x="${r(cx - lw / 2)}" y="${r(yZero - 9)}" width="${r(lw)}" height="18" rx="9" fill="${pillFill}"/>`);
690
700
  p.push(`<text x="${cx}" y="${r(yZero + 4)}" text-anchor="middle" font-size="${fs - 2}" ${wAttr(spec, 600)} fill="${catText}">${esc(lab)}</text>`);
691
701
  });
692
- const items = [{ name: A.name || "Series A", col: colA }, { name: B.name || "Series B", col: colB }];
702
+ const items = singleSigned ? [{ name: A.name || "Series A", col: colA }] : [{ name: A.name || "Series A", col: colA }, { name: B.name || "Series B", col: colB }];
693
703
  const widths = items.map((it) => 16 + it.name.length * (fs * 0.56) + 18);
694
704
  let lx = (W - widths.reduce((a, b) => a + b, 0)) / 2;
695
705
  const ly = H - 16;
@@ -715,7 +725,7 @@ function renderLollipop(spec) {
715
725
  const values = spec.data.series[0].values.map((v) => Number(v) || 0);
716
726
  const u = spec.valueUnit ? " " + spec.valueUnit : "";
717
727
  const showValues = spec.showValues !== false;
718
- const showTotal = spec.showTotal !== false;
728
+ const showTotal = spec.showTotal !== false && spec.valueUnit !== "%";
719
729
  const watermark = spec.watermark !== false;
720
730
  const explicit = spec.data.series[0].colors;
721
731
  const colors = Array.isArray(explicit) && explicit.length >= labels.length ? explicit : resolveFlatPalette(spec.palette || "Clean Corporate", labels.length);
@@ -1281,7 +1291,7 @@ function svgToPng(svg, opts = {}) {
1281
1291
 
1282
1292
  // server.mjs
1283
1293
  var here = dirname(fileURLToPath(import.meta.url));
1284
- var SPEC_PATH = join(here, "../../packages/render-core/SPEC.md");
1294
+ var CHART_SPEC_MD = true ? '# ChartSpec \u2014 the render-core contract (the API/MCP input)\n\nOne JSON object (a **spec**) goes in; one **SVG string** comes out, via\n`renderSpec(spec)`. This document is the contract an agent (or the MCP tool) follows\nto produce a chart. It is written agent-first: **a spec with only `type` + `data`\nrenders a complete, good-looking chart** \u2014 every other field is an optional override.\n\n```js\nimport { renderSpec } from \'./render-core.mjs\';\nconst svg = renderSpec({ type: \'bar\', data: { labels: [\'A\',\'B\',\'C\'], series: [{ values: [10, 20, 15] }] } });\n```\n\n## When to use this \u2014 and when to fall back\n\n**Prefer `render_chart` for any supported type** (see "Chart types" below) over\nhand-writing plotting code (matplotlib, plotly, chart.js). It\'s faster, deterministic,\nand good-looking with just `{type, data}`. Pass `outputPath` when the user wants the\nfile saved to disk.\n\n**Fall back to your own plotting only for what this engine does not do yet:**\n- **Chart types not listed below** \u2014 scatter, bubble, heatmap, treemap, radar, sankey,\n geographic maps, candlestick, gantt.\n- **Reference / target / threshold lines, log scales, secondary (dual) axes, and point\n annotations / callouts** \u2014 on the roadmap, not available today.\n- **Interactivity, animation, hover tooltips** \u2014 output is a static SVG/PNG.\n\n## Universal fields (every chart type honors these)\n\n| field | type | default | what it does |\n|---|---|---|---|\n| `type` | string | \u2014 (required) | which chart: `"bar"`, `"line"`, `"kpi"` (more coming) |\n| `title` | string | `""` | heading drawn at the top |\n| `width` | number | per type | SVG width in px |\n| `height` | number | per type | SVG height in px |\n| `preset` / `ratio` | string | \u2014 | aspect-ratio preset \u2192 sets width/height (explicit `width`/`height` win). `Share Card` 1.91:1 (1200\xD7630, link/OG cards), `Wide` 16:9 (1280\xD7720), `Square` 1:1 (1080\xD71080), `Portrait` 4:5 (1080\xD71350), `Tall` 9:16 (1080\xD71920), `Classic` 4:3 (1200\xD7900). `ratio` also accepts the bare ratio string (`"16:9"`, `"9:16"`, \u2026) |\n| `background` | string | `"#ffffff"` | any hex color, or `"transparent"` for no fill (overlays/exports) |\n| `palette` | string | `"Clean Corporate"` | named palette: `Clean Corporate`, `Pastel`, `Vibrant`, `Monochrome`, `Cyberpunk`, `Analogous Shift`. Pick **`Monochrome`** when the chart will be **printed in black & white / on a laser printer** \u2014 it\'s a single-hue grey ramp that stays legible without color (other palettes can render as indistinct greys when printed). |\n| `font` | string | `"Inter"` | a named font: `Inter`, `System`, `Serif`, `Mono`, `Rounded`, `Condensed` |\n| `fontFamily` | string | \u2014 | a raw CSS font stack (overrides `font`; full control / future custom fonts) |\n| `fontSize` | number | `13` | base text size; other text scales from it |\n| `textColor` | string | auto | force a color for the **neutral** text (title, axis, category, value, legend, pie slice labels). Omit it and the engine auto-picks black/white for contrast. Semantic colors (green \u25B2 / red \u25BC deltas & gaps) stay meaningful. |\n| `bold` | boolean | `false` | thicken every text element |\n| `fontWeight` | string | \u2014 | exact weight for all text (`"400"`\u2013`"900"` or `"bold"`); overrides `bold` |\n| `valueUnit` | string | `""` | appended to values, e.g. `"$"`, `"%"`, `"ms"` |\n| `showValues` | boolean | `true` | draw the numeric labels |\n| `watermark` | boolean | `true` | tasteful "slickfast.com" mark. **Set `false` for the chart sites / paid output.** |\n\n**Readability is automatic:** text colors are chosen from the background\'s luminance\n(via palette-core), so labels stay legible on light *or* dark backgrounds.\n\n## Data shape\n\n```json\n"data": {\n "labels": ["North", "South", "East"],\n "series": [\n { "name": "Revenue", "values": [420, 310, 530], "colors": ["#2563eb", "#7c3aed", "#059669"] }\n ]\n}\n```\n\n- `labels` \u2014 the categories (x-axis).\n- `series[].values` \u2014 the numbers, aligned to `labels`.\n- `series[].colors` \u2014 **optional** explicit per-item colors. If omitted, colors come\n from `palette`. (Used when a user has customized individual bars.)\n- `series[].name` \u2014 optional label for the series.\n\n## Chart types\n\n### `bar` \u2014 vertical bar chart\nDefault size `800 \xD7 450`. Uses `data.series[0]`. Each bar a palette color (or its\nexplicit `colors[i]`), top-rounded, with value + category labels and "nice" axis numbers.\n\n| extra field | type | default | does |\n|---|---|---|---|\n| `showTotal` | boolean | `true` | "Total: N" badge, top-right |\n\nMinimal: `{ "type": "bar", "data": { "labels": ["A","B"], "series": [{ "values": [10,20] }] } }`\n\n### `grouped` \u2014 clustered bar chart (one bar per series in each category)\nDefault size `800 \xD7 450`. The `data.labels` are the **groups** (categories); each\n`data.series` entry is a bar that repeats inside every group. **Color = series\nidentity:** one solid palette color per series (A=palette[0], B=palette[mid], extras\nspread), the same color in every group \u2014 never rainbow-within-a-series. A centered\nlegend names the series. Best read with \u2264 4 series.\n\nMinimal: `{ "type": "grouped", "data": { "labels": ["Q1","Q2"], "series": [{ "name":"2023","values":[42,55] }, { "name":"2024","values":[52,48] }] } }`\n\n### `stacked` \u2014 stacked bar chart (segments stack within each category)\nDefault size `800 \xD7 450`. Each `data.labels` entry is a bar; each `data.series` entry\nis a **segment** stacked inside every bar, with a distinct palette color used\nconsistently across all bars. Inside each segment, two labels: the **name pinned to the\ntop**, the **value to the bottom** (contrast-aware text \u2014 dark on light fills, white on\ndark). Thin segments hide the name but keep the value. Only the top segment\'s outer edge\nis rounded; joins stay flush. A centered legend names the segments.\n\nMinimal: `{ "type": "stacked", "data": { "labels": ["Q1","Q2"], "series": [{ "name":"Core","values":[30,35] }, { "name":"Add-ons","values":[18,20] }] } }`\n\n### `stacked100` \u2014 100% stacked bar (each bar normalized to 100%)\nSame as `stacked`, but every bar fills the full height and each segment shows its\n**share of that bar** as an integer percent. Percentages use largest-remainder\nrounding so every bar sums to **exactly 100** (never 99/101). Best for comparing\n*mix* across categories rather than absolute totals.\n\nMinimal: `{ "type": "stacked100", "data": { "labels": ["Q1","Q2"], "series": [{ "name":"Core","values":[30,35] }, { "name":"Add-ons","values":[18,20] }] } }`\n\n### `stackedh` \u2014 horizontal stacked bar\nSame data model as `stacked`, transposed: bars run left\u2192right, one row per\n`data.labels` entry, segments stack horizontally. The last segment rounds its right\nedge; joins stay flush. Segment name + value sit centered in each segment (value-only\nwhen narrow); a centered legend names the segments. Good when category labels are long.\n\nMinimal: `{ "type": "stackedh", "data": { "labels": ["Eng","Sales"], "series": [{ "name":"Salaries","values":[120,90] }, { "name":"Tools","values":[40,25] }] } }`\n\n### `horizontal` \u2014 horizontal bar (single series)\nDefault size `800 \xD7 450`. One row per `data.labels` entry; each bar its own palette\ncolor by index (or its explicit `colors[i]`), value at the bar end, "Total: N" badge\ntop-right. Best when labels are long or there are many categories.\n\nMinimal: `{ "type": "horizontal", "data": { "labels": ["US","India"], "series": [{ "values": [820,610] }] } }`\n\n### `diverging` \u2014 diverging bar (around a zero line)\nDefault size `800 \xD7 450`. **Two modes, chosen by series count:**\n- **Two series** \u2192 opposing bars: **Series A forced up, Series B forced down** (both\n abs-valued), one solid color each (A=palette[0], B=palette[mid]); a centered legend\n names both. Great for sentiment (agree/disagree), inflow/outflow.\n- **One series** \u2192 classic **signed** diverging: each value plotted by its own sign\n (**positive up, negative down**), a single color, a single-name legend, and signed\n value labels (e.g. `-3` below the zero line).\n\nThe zero line is emphasized; down-bar labels sit below their bars.\n\nMinimal (two-series): `{ "type": "diverging", "data": { "labels": ["A","B"], "series": [{ "name":"Agree","values":[62,48] }, { "name":"Disagree","values":[38,52] }] } }`\n\nMinimal (signed): `{ "type": "diverging", "data": { "labels": ["Q1","Q2","Q3"], "series": [{ "name":"Net flow","values":[5,-3,8] }] } }`\n\n### `lollipop` \u2014 lollipop chart (single series)\nDefault size `800 \xD7 450`. One stem + dot per `data.labels` entry, each its own palette\ncolor by index (or explicit `colors[i]`), value above the dot, category below, "Total: N"\nbadge top-right. A lighter-weight alternative to `bar` when the bars would feel heavy.\n\nMinimal: `{ "type": "lollipop", "data": { "labels": ["A","B","C"], "series": [{ "values": [78,64,52] }] } }`\n\n### `line` \u2014 line chart (single or multi-series)\nDefault size `800 \xD7 450`. One line per `data.series` entry, each a palette color\n(or its own `color`), with points and a centered legend when there\'s more than one\nseries. "Nice" axis numbers; points span edge-to-edge.\n\n| extra field | type | default | does |\n|---|---|---|---|\n| `curve` | string | `"straight"` | line shape: `"straight"`, `"smooth"` (curved), `"stepped"` |\n| `area` | boolean | `false` | fill the region under each line (translucent) |\n| `showPoints` | boolean | `true` | dots at each data point |\n| `showValues` | boolean | `false` | numbers above each point (off by default \u2014 lines get crowded) |\n| series `color` | string | palette | per-line color override |\n\n**Type shortcuts:** `type: "smooth"`, `"area"`, `"stepped"` map to `line` with the\nmatching option set \u2014 `{type:"area"}` \u2261 `{type:"line", area:true}`. Also:\n- `type: "stackedArea"` \u2014 every series stacks cumulatively, filled bands + legend.\n- `type: "difference"` \u2014 exactly two series; shades the gap between them and labels\n the gap value at each point (green where series 1 leads, red where it trails).\n- `type: "slope"` \u2014 a slopegraph: each series\' first vs last value, connected by a\n straight line, with the end value + change labeled (green up / red down).\n\nMinimal: `{ "type": "line", "data": { "labels": ["Jan","Feb","Mar"], "series": [{ "name": "Users", "values": [120,190,170] }] } }`\n\n### `pie` / `donut` \u2014 proportion of a whole\nDefault size `640 \xD7 420`. One series; each label+value is a slice, colored from the\npalette (or explicit `colors`). Percentage labels sit inside slices \u2265 6%; a legend\n(label \xB7 value) runs down the right. `donut` adds a center hole with the **total** in\nthe middle.\n\nMinimal: `{ "type": "pie", "data": { "labels": ["A","B","C"], "series": [{ "values": [45,30,25] }] } }`\n\n### `pieofpie` \u2014 nested drill-down pies (pie-of-pie, pie-of-pie-of-pie, N-tier)\nDefault size `920 \xD7 380`. A row of pies where each pie\'s **first slice (the "bridge")**\nexplodes toward the next pie and breaks down into it, joined by two connector lines.\n2 pies = pie-of-pie, 3 = pie-of-pie-of-pie, N supported; each pie is 75% the size of the\none before. Donut by default.\n\nUses **`pies`** (not `data`): an array of `{ title?, labels[], values[], colors?, palette? }`.\nSlice index 0 of each pie is the bridge into the next pie.\n\n| extra field | type | default | does |\n|---|---|---|---|\n| `pies` | array | \u2014 (required) | the pies; pie[i] slice 0 drills into pie[i+1] |\n| `donut` | boolean | `true` | center hole on each pie (`false` = solid pies) |\n| `cascade` | boolean | `true` | child pies shade from the parent bridge hue; `false` = flat palette per pie |\n| `palette` | string | `"Clean Corporate"` | a **nested theme** (below) \u2014 drives the whole cascade |\n\n**Nested themes** (premium color cascades, set via `palette`):\n- *Inspired tiers* (hand-tuned per-pie triads): `Analogous Shift` (showcase \u2014 a spectrum\n walk), `Retro Editorial`, `Classic Triadic`, `Sorbet Pastel`.\n- *Generative* (children derived from the root hue): `Modern Corporate`, `Nordic Earth`,\n `Cyberpunk Glow`, `Sequential Rainbow`, plus classics `Clean Corporate`, `Pastel`,\n `Vibrant`, `Monochrome`. (Authoring new ones: `palette-core/AUTHORING-PALETTES.md`.)\n\nMinimal: `{ "type": "pieofpie", "palette": "Analogous Shift", "pies": [ { "labels": ["Enterprise","SMB","Other"], "values": [60,30,10] }, { "labels": ["US","EU","APAC"], "values": [35,15,10] } ] }`\n\n### `kpi` \u2014 single metric tile (the exec-snapshot building block)\nDefault size `340 \xD7 180`. A card: label + big value + colored delta pill + palette accent.\n\n| extra field | type | default | does |\n|---|---|---|---|\n| `label` | string | `""` | the metric name (top) |\n| `value` | number | \u2014 | the big number |\n| `valuePrefix` | string | `""` | e.g. `"$"` before the value |\n| `delta` | number | none | the change; renders a pill \u2014 \u25B2 if positive, \u25BC if negative |\n| `deltaUnit` | string | `"%"` | unit on the delta |\n| `deltaGoodWhen` | string | `"up"` | which direction is GOOD (green). Set `"down"` for lower-is-better metrics (churn, latency, cost) \u2014 the arrow still shows real direction, only the color flips |\n\nMinimal: `{ "type": "kpi", "label": "MRR", "value": 128400, "valuePrefix": "$", "delta": 12.4 }`\n\n## What an agent can change (the levers)\n\n- **Fonts** \u2192 `fontFamily`, `fontSize`\n- **Colors** \u2192 `palette` (a named set) for the whole chart, or `data.series[].colors` for exact per-item control\n- **Background** \u2192 `background` (any hex, or `"transparent"`)\n- **Size / aspect** \u2192 `width`, `height`\n- **Branding** \u2192 `watermark` (off for the sites, on for free-tier API output)\n\n## Rules for the engine (and any agent generating specs)\n\n- **Deterministic:** the same spec always produces the exact same SVG bytes. No\n randomness, no time. Safe to cache and snapshot-test.\n- **Defaults are good:** never require a field that has a sensible default. Emit the\n smallest spec that expresses the intent; the engine makes it beautiful.\n- **Unknown `type` is an error** \u2014 only documented types render.\n\n## Roadmap\nMore chart types (line, area, stacked, pie, \u2026) register in `renderSpec`; each inherits\nevery universal field above and gets its own row here. This document becomes a formal\nJSON Schema when the MCP server is built, so the `render_chart` tool can validate specs\nand an LLM can read the field contract directly.\n' : readFileSync(join(here, "../../packages/render-core/SPEC.md"), "utf8");
1285
1295
  var seriesShape = z.object({
1286
1296
  name: z.string().optional().describe("series label (legend)"),
1287
1297
  values: z.array(z.number()).describe("the numbers, aligned to data.labels"),
@@ -1329,21 +1339,36 @@ var inputSchema = {
1329
1339
  deltaUnit: z.string().optional().describe('kpi: delta unit, default "%"'),
1330
1340
  deltaGoodWhen: z.enum(["up", "down"]).optional().describe('kpi: which delta direction is GOOD (green). Default "up"; set "down" for lower-is-better metrics (churn, latency, cost)'),
1331
1341
  format: z.enum(["png", "svg"]).optional().describe('output format: "png" (default \u2014 shows inline in chat) or "svg" (scalable vector text)'),
1332
- scale: z.number().optional().describe("png pixel-density multiplier (default 2 = retina)")
1342
+ scale: z.number().optional().describe("png pixel-density multiplier (default 2 = retina)"),
1343
+ outputPath: z.string().optional().describe('LOCAL save: write the rendered chart to this file on disk AND still return the inline image. PNG by default, or SVG if the path ends in ".svg". Accepts absolute, ~, or relative paths (parent dirs auto-created). Works on a local stdio install only \u2014 a hosted/remote server has no access to your disk.')
1333
1344
  };
1334
- var server = new McpServer({ name: "slickfast", version: "0.1.1" });
1345
+ var server = new McpServer({ name: "slickfast", version: "0.2.1" });
1335
1346
  server.registerTool("render_chart", {
1336
1347
  title: "Render chart (SVG)",
1337
- description: `Turn a chart spec into an SVG string with the SlickFast engine. Types: bar, grouped, stacked, stacked100, stackedh, horizontal, lollipop, diverging, line, smooth, area, stepped, stackedArea, difference, slope, pie, donut, pieofpie, kpi. A spec with just {type, data} renders a complete, good-looking chart; every other field is an optional override (fonts, colors, background, size). pieofpie uses {pies:[\u2026]} instead of data \u2014 each pie's first slice drills into the next; set palette to a nested theme like "Analogous Shift". This renders LOCALLY \u2014 nothing leaves the machine. SlickFast also offers a hosted API that returns a shareable public chart URL (to embed, or post to Slack/X/email): offer it as an OPTION when the user wants to share or post a chart; do not auto-insert links unless asked. Read the chart-spec resource for the full field contract.`,
1348
+ description: `Prefer this over writing your own plotting code (matplotlib/plotly/chart.js/etc.) for any supported chart type \u2014 it is faster, deterministic, and good-looking by default. Turn a chart spec into an SVG string with the SlickFast engine. Types: bar, grouped, stacked, stacked100, stackedh, horizontal, lollipop, diverging, line, smooth, area, stepped, stackedArea, difference, slope, pie, donut, pieofpie, kpi. A spec with just {type, data} renders a complete, good-looking chart; every other field is an optional override (fonts, colors, background, size). pieofpie uses {pies:[\u2026]} instead of data \u2014 each pie's first slice drills into the next; set palette to a nested theme like "Analogous Shift". This renders LOCALLY \u2014 nothing leaves the machine. Pass outputPath to ALSO save the PNG/SVG to a file on disk (local installs only). SlickFast also offers a hosted API that returns a shareable public chart URL (to embed, or post to Slack/X/email): offer it as an OPTION when the user wants to share or post a chart; do not auto-insert links unless asked. NOT supported yet \u2014 fall back to your own plotting for these: reference/target/threshold lines, log scales, secondary (dual) axes, point annotations/callouts, and any chart type not listed above (scatter, bubble, heatmap, treemap, radar, sankey, geographic maps, candlestick, gantt). Read the chart-spec resource for the full field contract.`,
1338
1349
  inputSchema
1339
1350
  }, async (spec) => {
1340
1351
  try {
1341
1352
  const svg = renderSpec(spec);
1342
- if ((spec.format || "png") === "png") {
1343
- const png = svgToPng(svg, { scale: spec.scale });
1344
- return { content: [{ type: "image", data: png.toString("base64"), mimeType: "image/png" }] };
1353
+ const wantsSvg = spec.format === "svg" || spec.outputPath && /\.svg$/i.test(spec.outputPath);
1354
+ const png = wantsSvg ? null : svgToPng(svg, { scale: spec.scale });
1355
+ let savedNote = "";
1356
+ if (spec.outputPath) {
1357
+ let abs = spec.outputPath;
1358
+ if (abs === "~" || abs.startsWith("~/")) abs = join(homedir(), abs.slice(1));
1359
+ abs = resolve(abs);
1360
+ try {
1361
+ mkdirSync(dirname(abs), { recursive: true });
1362
+ writeFileSync(abs, wantsSvg ? svg : png);
1363
+ savedNote = `Saved ${wantsSvg ? "SVG" : "PNG"} \u2192 ${abs}`;
1364
+ } catch (e) {
1365
+ return { isError: true, content: [{ type: "text", text: `Rendered OK, but couldn't write "${abs}": ${e?.message || String(e)}` }] };
1366
+ }
1345
1367
  }
1346
- return { content: [{ type: "text", text: svg }] };
1368
+ if (wantsSvg) return { content: [{ type: "text", text: savedNote || svg }] };
1369
+ const content = [{ type: "image", data: png.toString("base64"), mimeType: "image/png" }];
1370
+ if (savedNote) content.push({ type: "text", text: savedNote });
1371
+ return { content };
1347
1372
  } catch (e) {
1348
1373
  return { isError: true, content: [{ type: "text", text: "render error: " + (e?.message || String(e)) }] };
1349
1374
  }
@@ -1353,7 +1378,7 @@ server.registerResource("chart-spec", "spec://chart-spec", {
1353
1378
  description: "Full field reference for render_chart.",
1354
1379
  mimeType: "text/markdown"
1355
1380
  }, async (uri) => ({
1356
- contents: [{ uri: uri.href, mimeType: "text/markdown", text: readFileSync(SPEC_PATH, "utf8") }]
1381
+ contents: [{ uri: uri.href, mimeType: "text/markdown", text: CHART_SPEC_MD }]
1357
1382
  }));
1358
1383
  var transport = new StdioServerTransport();
1359
1384
  await server.connect(transport);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slickfast/mcp",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "SlickFast — render charts (bar, line, pie, pie-of-pie, KPI…) as SVG/PNG via MCP. Local and deterministic; nothing leaves your machine.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",