@bicharts/chart-mcp 0.6.114 → 0.6.115
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/dist/check-chart.mjs +103 -103
- package/dist/index.mjs +145 -141
- package/package.json +3 -3
- package/skills/bic-charts/SKILL.md +102 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bicharts/chart-mcp",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.115",
|
|
4
4
|
"description": "AI Data Components for Power BI, Excel, Fabric Apps and the web, over MCP: working what-if apps, interactive tables and bivariate maps first, plus about 90 everyday chart types, with selection and cross-filtering already wired. It profiles your table locally; the code it writes is yours and runs with no license.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
},
|
|
48
48
|
"// dependencies": "NONE, deliberately. dist/ bundles every module the server uses, so a runtime dependency installs nothing it needs - it only makes npx fetch that package (and its tree) before the first start. Everything the bundle carries is a devDependency; scripts/verify-package.mjs fails the release if a dependency appears or a shipped file imports anything but Node's built-ins.",
|
|
49
49
|
"devDependencies": {
|
|
50
|
-
"@bicharts/chart-host": "^0.6.
|
|
51
|
-
"@bicharts/shape-core": "^0.6.
|
|
50
|
+
"@bicharts/chart-host": "^0.6.115",
|
|
51
|
+
"@bicharts/shape-core": "^0.6.115",
|
|
52
52
|
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
53
53
|
"@types/node": "^26.6.5",
|
|
54
54
|
"@types/papaparse": "^5.3.14",
|
|
@@ -26,6 +26,13 @@ the call. Tell them a browser tab is about to open. If the call comes back with
|
|
|
26
26
|
continue" and a link instead, give them that link, and call again once they've signed in. A key in
|
|
27
27
|
the server's environment is only for machines with no browser, such as CI.
|
|
28
28
|
|
|
29
|
+
**On the hosted server** (`https://bizintelligencechampions.com/mcp`, which the Claude Code plugin and claude.ai
|
|
30
|
+
connect to), the tools can't reach the person's files: there is no `setup_fabric_app`, `query_semantic_model` or
|
|
31
|
+
`suggest_charts`, and no `out_dir` or `csv_path`. Pass each table as `data` or `csv_text`; `generate_chart` returns the
|
|
32
|
+
code, the rows and the binding in its reply, and you write them into the project. For a Fabric App, or charts written
|
|
33
|
+
straight to disk, the person adds the local server (`claude mcp add bic-chart-local -- npx -y @bicharts/chart-mcp`) and
|
|
34
|
+
the steps below apply as written.
|
|
35
|
+
|
|
29
36
|
## 0. Decide what to pull (only if you are starting from a data MODEL)
|
|
30
37
|
|
|
31
38
|
Skip this if you already have a table. If you are holding a semantic model, a warehouse schema or
|
|
@@ -666,6 +673,44 @@ card's words stay as written. Write the card's words and the query's bound toget
|
|
|
666
673
|
projection, the window is the history it starts from, and its horizon is the chart's own control. `check_app` warns on a
|
|
667
674
|
card that names a window over a query with no bound on it.
|
|
668
675
|
|
|
676
|
+
**A query's range comes from the data or the person's words - never a date copied from a profile.** A window is in a
|
|
677
|
+
query only because the person asked for it ("2025", "the last seven years"). Otherwise the range is the data's own: the
|
|
678
|
+
first and last day a profile showed are where the data ran when you looked, and a query bounded to them drops every
|
|
679
|
+
later week, silently, once the model holds one. Leave the dates out, or bound the range with the data's own first and
|
|
680
|
+
last day, and print the range on the card from the rows the query returns, never as typed dates:
|
|
681
|
+
|
|
682
|
+
```dax
|
|
683
|
+
DATESBETWEEN ( 'Calendar'[Date], -- the range the cases themselves cover
|
|
684
|
+
CALCULATE ( MIN ( 'Cases'[Opened Date] ), REMOVEFILTERS () ),
|
|
685
|
+
CALCULATE ( MAX ( 'Cases'[Opened Date] ), REMOVEFILTERS () ) )
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
REMOVEFILTERS keeps the range whole under a page pick, so a quiet week at either end still reads 0. `check_app` flags a
|
|
689
|
+
query bounded by a date written into it: an error when the date is a day no one names a window by (a 25th, neither the
|
|
690
|
+
1st nor the end of a month), and a warning when no card around a chart reading the query names that window.
|
|
691
|
+
|
|
692
|
+
**A series over time returns every period in its range.** SUMMARIZECOLUMNS drops a row whose measures are all blank, so
|
|
693
|
+
under a pick with sparse data (one owner, one product) a week with no cases has no row at all, and a line joins the
|
|
694
|
+
weeks either side of it: a quiet week looks as busy as its neighbors. A count or sum reads 0 for an empty period, and a
|
|
695
|
+
rate or ratio stays blank (there's nothing to divide), so the line drops to 0 for the count and breaks for the rate:
|
|
696
|
+
|
|
697
|
+
```dax
|
|
698
|
+
EVALUATE
|
|
699
|
+
SUMMARIZECOLUMNS (
|
|
700
|
+
'Calendar'[Week],
|
|
701
|
+
DATESBETWEEN ( 'Calendar'[Date], -- the range the cases cover
|
|
702
|
+
CALCULATE ( MIN ( 'Cases'[Opened Date] ), REMOVEFILTERS () ),
|
|
703
|
+
CALCULATE ( MAX ( 'Cases'[Opened Date] ), REMOVEFILTERS () ) ),
|
|
704
|
+
"Cases Opened", COALESCE ( [Case Count], 0 ), -- 0 in a quiet week
|
|
705
|
+
"SLA Compliance", DIVIDE ( [Cases In SLA], [Case Count] ) -- blank in a quiet week
|
|
706
|
+
)
|
|
707
|
+
-- or let the period table drive: ADDMISSINGITEMS ( 'Calendar'[Week], SUMMARIZECOLUMNS ( ... ), 'Calendar'[Week] )
|
|
708
|
+
```
|
|
709
|
+
|
|
710
|
+
A COALESCE keeps every period of the period table, so bound the range with a filter on its date column: the data's own
|
|
711
|
+
first and last day, as above, or the window the person asked for. `check_app` warns on a query a line over time reads
|
|
712
|
+
that can drop a quiet period.
|
|
713
|
+
|
|
669
714
|
**2. Profile and generate from THE WHOLE RESULT of that query - every row, never a few pasted in.**
|
|
670
715
|
Eligibility reads row, region and category counts. A bivariate country map needs at least 24
|
|
671
716
|
countries, so the same 40-country query shown as 8 sample rows hides it, and nothing fails - you are
|
|
@@ -831,9 +876,24 @@ function CountryMap({ table, onPick }) { // table = the live query resu
|
|
|
831
876
|
294 when no width is named), and if it doesn't fit, generate it again with `generate_chart` at `width: 294` (the
|
|
832
877
|
engine's small-screen rules then apply). A minimum wider than the phone's box draws the chart wider than its card,
|
|
833
878
|
so the card scrolls sideways and anything the chart keeps in place (a table's name column) scrolls away with it.
|
|
834
|
-
`check_app` warns on a `minChartWidth` above 294.
|
|
879
|
+
`check_app` warns on a `minChartWidth` above 294, except at or below the chart type's own minimum (next).
|
|
880
|
+
- **The exception: a chart type's own minimum.** Some chart types can't be read narrower than a minimum of their own,
|
|
881
|
+
and it can be wider than a phone. `generate_chart` reports it (a "Minimum size" line, and `chartMinWidth` /
|
|
882
|
+
`chartMinHeight` on its result), and the component it writes beside the chart keeps it for you: given less, the
|
|
883
|
+
component draws the chart at its minimum inside a box that scrolls, so on a phone that one chart scrolls sideways
|
|
884
|
+
and never squashes. Set nothing for it. Never regenerate a chart type below its own minimum to fit a phone - it
|
|
885
|
+
comes back squashed - and `check_chart` draws it at its minimum, as the component does.
|
|
886
|
+
- **From a laptop up, no chart scrolls sideways.** A phone scrolling a chart at its type's minimum is designed; a laptop
|
|
887
|
+
never should: at every width from 1024px, give each chart a card at least its minimum. A two-column grid leaves a card
|
|
888
|
+
about 436px at a 1024px page, and a three-column one about 358px at 1280px (after the page's padding, a `gap-5` and a
|
|
889
|
+
`p-4` card with a border), so a chart whose minimum is wider gets a row of its own at those widths (move its card out of
|
|
890
|
+
the grid, or `lg:col-span-2`), or a grid track at least its minimum plus the card's padding and border
|
|
891
|
+
(`minmax(544px, ...)` for a 510px chart in that card). `check_app` warns on a card narrower than its chart's minimum at
|
|
892
|
+
any width from 1024 to 1920px.
|
|
835
893
|
- Take the actual size from the layout - percent, flex or grid `fr` - and never a fixed height smaller than the chart's
|
|
836
|
-
minimum.
|
|
894
|
+
minimum. A card of fixed height holds its title and subtitle too, at a phone's width: a subtitle that takes one
|
|
895
|
+
line on a desktop wraps to three on a phone, and the chart's cell loses those lines. `check_app` warns on a card whose
|
|
896
|
+
fixed height leaves its chart under its minimum on a phone.
|
|
837
897
|
- Put `min-h-0` on every flex child between a card and its chart (`min-w-0` in a row). A flex item's default
|
|
838
898
|
`min-height: auto` lets it grow past its card, and the card's `overflow-hidden` slices off the rest.
|
|
839
899
|
- A chart with controls drawn inside it (sliders, steppers, a reset) gets its card's full width. Put a side panel that
|
|
@@ -879,12 +939,16 @@ what the pick matches: its query goes through `filterQuery` / `treatAs` (or `fil
|
|
|
879
939
|
KPIs and tables included - a table that follows a click shows only the matching rows. The chart that was clicked keeps
|
|
880
940
|
all its marks and highlights the pick: a chart never filters itself, so the reader can change the pick there. When the
|
|
881
941
|
person names more than one chart to click ("on the map or in the table"), give each its own filter on the same key,
|
|
882
|
-
and pass every panel's query through a `treatAs` for each of those filters but its own - one filter
|
|
883
|
-
clicked charts leaves each unfiltered by the other's click:
|
|
942
|
+
all in ONE `group`, and pass every panel's query through a `treatAs` for each of those filters but its own - one filter
|
|
943
|
+
shared by two clicked charts leaves each unfiltered by the other's click. The group makes them one pick: a pick in one
|
|
944
|
+
clears the others, so the most recent pick is the page's one pick; every member's chart shows it, so clicking it again in
|
|
945
|
+
whichever panel shows it clears it; and only the member holding it is active (one chip, one scope line). Without the
|
|
946
|
+
group they are separate picks, ANDed: a country picked on the map and then in the table is held twice, and clicking it
|
|
947
|
+
again on the map leaves the page filtered by the table's. `check_app` flags filters on one key without a group.
|
|
884
948
|
|
|
885
949
|
```tsx
|
|
886
|
-
const fromMap = useBicFilter("CountryIso3", { label: "Country (map)", display: "CountryName" });
|
|
887
|
-
const fromTable = useBicFilter("CountryIso3", { label: "Country (table)", display: "CountryName" });
|
|
950
|
+
const fromMap = useBicFilter("CountryIso3", { label: "Country (map)", display: "CountryName", group: "country" });
|
|
951
|
+
const fromTable = useBicFilter("CountryIso3", { label: "Country (table)", display: "CountryName", group: "country" });
|
|
888
952
|
const COUNTRY = "Geography[CountryIso3]";
|
|
889
953
|
const mapQuery = filterQuery(MAP_DAX, treatAs(fromTable, COUNTRY)); // the map follows the table's pick
|
|
890
954
|
const tableQuery = filterQuery(TABLE_DAX, treatAs(fromMap, COUNTRY)); // the table follows the map's
|
|
@@ -893,6 +957,12 @@ const kpiQuery = filterQuery(KPI_DAX, treatAs(fromMap, COUNTRY), treatAs(fromTab
|
|
|
893
957
|
// <KpiChart ... scopedBy={[fromMap, fromTable]} />; every panel gets its query={...}
|
|
894
958
|
```
|
|
895
959
|
|
|
960
|
+
**A list beside those panels follows the page's one pick, never the union of filters.** An "open items for the picked
|
|
961
|
+
countries" panel, a count or an "Add" button reads the group members' keys
|
|
962
|
+
(`[fromMap, fromTable].flatMap(f => f.keys)`), which with the group are that one pick. Separate filters' keys are a union
|
|
963
|
+
the page never shows: a scatter's three circled countries and a map pick of one of them list all three beside a page
|
|
964
|
+
narrowed to one.
|
|
965
|
+
|
|
896
966
|
**A panel whose query takes a page filter passes that filter as `scopedBy`, so the chart says it's filtered.** Its rows
|
|
897
967
|
arrive already narrowed, and the chart can't see a filter applied to its query: a KPI deck kept its region's title over
|
|
898
968
|
one country's totals and ranked what the filter left. Given `scopedBy={country}` (or `scopedBy={[fromMap, fromTable]}`),
|
|
@@ -902,7 +972,9 @@ with `filterBy` needs nothing more, its component tells the chart itself. A pane
|
|
|
902
972
|
figure it plots (each measure under `REMOVEFILTERS`, `ALL` or an `ALLEXCEPT` that drops the picked column, as the closed
|
|
903
973
|
deal figures are under a stage pick) isn't narrowed by it: leave that pick out of its `scopedBy`, or apply it, or its
|
|
904
974
|
scope line names a stage its marks never move for. `check_app` flags a `scopedBy` pick the panel's query removes from
|
|
905
|
-
everything it plots.
|
|
975
|
+
everything it plots. A query that takes only part of a pick's key (`treatAs(cell, "'Accounts'[Region]")` on a region x
|
|
976
|
+
stage cell) names that part: `scopedBy={{ filter: cell, takes: ["Region"] }}` says "Europe", never "Europe · 3-Propose",
|
|
977
|
+
and a stage-only pick adds nothing to that chart's line.
|
|
906
978
|
|
|
907
979
|
`check_app` lists each chart's wiring - filtered query, highlight only, or none - and warns on a panel no click
|
|
908
980
|
reaches, and on a filtered-query panel with no `scopedBy`. Say that list in your reply, with any panel you left whole and why, so a gap is seen before deploy.
|
|
@@ -923,7 +995,8 @@ What the library does, so the page doesn't:
|
|
|
923
995
|
filter, and `country.value` is its FIRST key only. Anything that names or acts on the pick (a panel heading, an "Add
|
|
924
996
|
an item" button) reads `country.keys`: with one, name it (`country.text`); with more, say how many ("3 countries")
|
|
925
997
|
and list them, and offer a one-mark action only while `country.keys.length === 1`. When several filters share a key
|
|
926
|
-
(one per clicked chart), read every one of them
|
|
998
|
+
(one per clicked chart), read every one of them; they're in one `group`, so their keys are the page's one pick, never
|
|
999
|
+
a union. `check_app` flags a `.value` read.
|
|
927
1000
|
- **A label picks its own field.** On a chart of region by stage with a filter keyed `["Region", "PipelineStep"]`, a
|
|
928
1001
|
click on a region's axis label holds that region in ANY stage, not the stage tuples drawn at that moment: the key is
|
|
929
1002
|
`["EMEA", ANY_VALUE]` (`isAnyValue` tells a free column; both from `@bicharts/chart-host/react` or `/page`), the chip
|
|
@@ -1082,6 +1155,12 @@ when the form offers every account, list them with `ADDCOLUMNS ( SUMMARIZE ( 'Ac
|
|
|
1082
1155
|
... ), "Open Pipeline", [Revenue Open] )`. `check_app` flags a marker-hosting chart whose query drops rows the form can
|
|
1083
1156
|
target.
|
|
1084
1157
|
|
|
1158
|
+
**Markers go on every page that shows what they mark.** When the request asks for markers "on the map and in the table"
|
|
1159
|
+
and more than one page has a map or a table of those things, every one of them carries the badges, unless the person named
|
|
1160
|
+
the page, and every one of those pages lets the reader add an item and open a marker's items. A reader who adds an item on
|
|
1161
|
+
one page and then opens another, the default page included, looks for the marker there too. Say which pages carry them
|
|
1162
|
+
in your reply.
|
|
1163
|
+
|
|
1085
1164
|
The files use the template's own tokens; restyle them as you like. The table and the permissions are created on
|
|
1086
1165
|
the next `npx rayfin up`, and every later `rayfin up` applies entity changes.
|
|
1087
1166
|
|
|
@@ -1094,7 +1173,10 @@ files do:
|
|
|
1094
1173
|
- **The name shown comes from the caller's token too**, never only from the browser: the name is the signed-in email,
|
|
1095
1174
|
and the policy checks it with the id - `claims.sub.eq(item.createdById).and(claims.email.eq(item.createdBy))` - so a
|
|
1096
1175
|
hand-made call with your own id and someone else's name is refused. (`claims.email` is the token's email claim; a
|
|
1097
|
-
Rayfin function sees the same token as `ctx.accessToken`.)
|
|
1176
|
+
Rayfin function sees the same token as `ctx.accessToken`.) A function that checks the name against that token reads
|
|
1177
|
+
the email the way Rayfin's sign-in reads the session's: the `email` claim, else, on a managed-hosting token, which has
|
|
1178
|
+
none, `xms_attr.<app>.rfn_email`. It never falls back to `preferred_username` or `upn`: the session's email never
|
|
1179
|
+
comes from them, so where they differ the person's own email is refused, or a name passes that the policy refuses.
|
|
1098
1180
|
- **When** is the server's clock, never the browser's: every write that records a time (an item created, an item
|
|
1099
1181
|
marked done) goes through a Rayfin function that stamps it on the server. Rayfin's data layer (checked on 1.36.2) has
|
|
1100
1182
|
no server-time default for a field (a `default` is a fixed value), so the browser never sends the time at all. The
|
|
@@ -1164,9 +1246,18 @@ const markDone = useCallback(async (taskId: string) => {
|
|
|
1164
1246
|
("Nothing open") before the rows are in.
|
|
1165
1247
|
- A form's default that comes from data loading elsewhere (the item's owner) is adopted when it arrives, unless the
|
|
1166
1248
|
reader has already chosen; a lookup behind a choice list that fails says so in the form.
|
|
1167
|
-
- A due date
|
|
1249
|
+
- A due date is a calendar day with no time zone, and both sides read "today" one way. The form gives the field
|
|
1250
|
+
`min={today}` in the reader's own date (YYYY-MM-DD from `getFullYear()`, `getMonth()` and `getDate()`, never
|
|
1251
|
+
`toISOString()`, which is UTC's). The server function refuses only a day already past everywhere: one before the date
|
|
1252
|
+
at UTC-12 (`new Date(Date.now() - 12 * 3600_000).toISOString().slice(0, 10)`), the earliest calendar day still
|
|
1253
|
+
current anywhere, so never UTC's today or its own. The server can't know the reader's time zone and mustn't trust one
|
|
1254
|
+
the browser sends, and a server that refuses by UTC's today refuses the reader's today every evening west of UTC: the
|
|
1255
|
+
form offers it, and the save fails with "can't be in the past".
|
|
1168
1256
|
- Read what the page shows, not every row ever saved: bound the query with `.where(...)` and show the first page as it
|
|
1169
|
-
lands
|
|
1257
|
+
lands on the FIRST load. **A reload keeps what's on screen until the whole reload has landed**, then swaps it in
|
|
1258
|
+
one step: a reload (after a save, on window focus) that hands each page to the list as it arrives cuts the list to
|
|
1259
|
+
its newest page, and the markers and open items beyond it vanish and come back. A reload that fails keeps the list it
|
|
1260
|
+
had and says the refresh failed; it never replaces the list with an error. Bound the DONE items, by when they were done; never bound the open ones, least of all by their due date: the
|
|
1170
1261
|
most overdue open item is the one the reader most needs, and a due-date window drops it first.
|
|
1171
1262
|
- A panel whose query failed offers Retry: setup's `BicChartPanel` does, from the query hook's `refetch` (pass the
|
|
1172
1263
|
hook's whole result).
|