@slickfast/mcp 0.1.0 → 0.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.
Files changed (3) hide show
  1. package/README.md +30 -2
  2. package/dist/index.js +31 -15
  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);
@@ -715,7 +716,7 @@ function renderLollipop(spec) {
715
716
  const values = spec.data.series[0].values.map((v) => Number(v) || 0);
716
717
  const u = spec.valueUnit ? " " + spec.valueUnit : "";
717
718
  const showValues = spec.showValues !== false;
718
- const showTotal = spec.showTotal !== false;
719
+ const showTotal = spec.showTotal !== false && spec.valueUnit !== "%";
719
720
  const watermark = spec.watermark !== false;
720
721
  const explicit = spec.data.series[0].colors;
721
722
  const colors = Array.isArray(explicit) && explicit.length >= labels.length ? explicit : resolveFlatPalette(spec.palette || "Clean Corporate", labels.length);
@@ -1281,7 +1282,7 @@ function svgToPng(svg, opts = {}) {
1281
1282
 
1282
1283
  // server.mjs
1283
1284
  var here = dirname(fileURLToPath(import.meta.url));
1284
- var SPEC_PATH = join(here, "../../packages/render-core/SPEC.md");
1285
+ 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## 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 (two series around a zero line)\nDefault size `800 \xD7 450`. Exactly two series: **Series A is forced positive (up),\nSeries B forced negative (down)**, regardless of input sign \u2014 each category is one\nup/down pair. One solid color per series (A=palette[0], B=palette[mid]). The zero line\nis emphasized; negative value labels sit below their bars. A centered legend names the\ntwo series. Great for sentiment (agree/disagree), inflow/outflow, etc.\n\nMinimal: `{ "type": "diverging", "data": { "labels": ["A","B"], "series": [{ "name":"Agree","values":[62,48] }, { "name":"Disagree","values":[38,52] }] } }`\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
1286
  var seriesShape = z.object({
1286
1287
  name: z.string().optional().describe("series label (legend)"),
1287
1288
  values: z.array(z.number()).describe("the numbers, aligned to data.labels"),
@@ -1329,21 +1330,36 @@ var inputSchema = {
1329
1330
  deltaUnit: z.string().optional().describe('kpi: delta unit, default "%"'),
1330
1331
  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
1332
  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)")
1333
+ scale: z.number().optional().describe("png pixel-density multiplier (default 2 = retina)"),
1334
+ 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
1335
  };
1334
- var server = new McpServer({ name: "slickfast", version: "0.1.0" });
1336
+ var server = new McpServer({ name: "slickfast", version: "0.2.0" });
1335
1337
  server.registerTool("render_chart", {
1336
1338
  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". Read the chart-spec resource for the full field contract.`,
1339
+ 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. 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. Read the chart-spec resource for the full field contract.`,
1338
1340
  inputSchema
1339
1341
  }, async (spec) => {
1340
1342
  try {
1341
1343
  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" }] };
1344
+ const wantsSvg = spec.format === "svg" || spec.outputPath && /\.svg$/i.test(spec.outputPath);
1345
+ const png = wantsSvg ? null : svgToPng(svg, { scale: spec.scale });
1346
+ let savedNote = "";
1347
+ if (spec.outputPath) {
1348
+ let abs = spec.outputPath;
1349
+ if (abs === "~" || abs.startsWith("~/")) abs = join(homedir(), abs.slice(1));
1350
+ abs = resolve(abs);
1351
+ try {
1352
+ mkdirSync(dirname(abs), { recursive: true });
1353
+ writeFileSync(abs, wantsSvg ? svg : png);
1354
+ savedNote = `Saved ${wantsSvg ? "SVG" : "PNG"} \u2192 ${abs}`;
1355
+ } catch (e) {
1356
+ return { isError: true, content: [{ type: "text", text: `Rendered OK, but couldn't write "${abs}": ${e?.message || String(e)}` }] };
1357
+ }
1345
1358
  }
1346
- return { content: [{ type: "text", text: svg }] };
1359
+ if (wantsSvg) return { content: [{ type: "text", text: savedNote || svg }] };
1360
+ const content = [{ type: "image", data: png.toString("base64"), mimeType: "image/png" }];
1361
+ if (savedNote) content.push({ type: "text", text: savedNote });
1362
+ return { content };
1347
1363
  } catch (e) {
1348
1364
  return { isError: true, content: [{ type: "text", text: "render error: " + (e?.message || String(e)) }] };
1349
1365
  }
@@ -1353,7 +1369,7 @@ server.registerResource("chart-spec", "spec://chart-spec", {
1353
1369
  description: "Full field reference for render_chart.",
1354
1370
  mimeType: "text/markdown"
1355
1371
  }, async (uri) => ({
1356
- contents: [{ uri: uri.href, mimeType: "text/markdown", text: readFileSync(SPEC_PATH, "utf8") }]
1372
+ contents: [{ uri: uri.href, mimeType: "text/markdown", text: CHART_SPEC_MD }]
1357
1373
  }));
1358
1374
  var transport = new StdioServerTransport();
1359
1375
  await server.connect(transport);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slickfast/mcp",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
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",