@starklab/stark-mcp 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -31,14 +31,15 @@ Add to your MCP client config (e.g. `.mcp.json`):
31
31
 
32
32
  | Tool | Wraps | Returns |
33
33
  |---|---|---|
34
- | `list_components` | `prop-mapping/components/*.mapping.json` + `usage/components/*.usage.json` | name, slug, status, description, platforms |
34
+ | `list_components` | `catalog.json` exports ∪ `prop-mapping/components/*.mapping.json` + `usage/components/{,rn/}*.usage.json` | name, slug, status, description, platforms |
35
35
  | `get_component_usage` | `usage/loader.js` (`loadUsage`) | dos/donts, props, figmaUrl, status |
36
36
  | `get_component_props` | `prop-mapping/components/{c}.mapping.json` (+ `tokens/components/{c}.json` when `includeTokens` is set) | React↔Figma `propMap` for a platform, optionally the component's token JSON |
37
- | `get_manifest` | all of the above, aggregated | usage + props (per platform) + tokens for every component in one call |
37
+ | `get_manifest` | all of the above, aggregated | usage (web) + nativeUsage (React Native) + props (per platform) + tokens for every component in one call |
38
38
  | `get_layout_catalog` | `conformance/catalog.js` (`buildCatalogFromDir`) | every layout-capable component's props/slots |
39
39
  | `get_layout_schema` | same, filtered to one component | one component's layoutSchema |
40
40
  | `validate_layout` | `conformance/index.js` (`runConformance`) | deterministic findings (`Critical`/`Warning`/`Info`), no LLM involved |
41
41
  | `get_generation_protocol` | `generation-protocol.json` | the enforced step checklist Vecna runs |
42
+ | `whisperer_context` | `manifest.json` + `catalog.json` + `component-props.json` + `json-meta` + `json-dark`, through `src/whisperer.js` | a system prompt (prose, not JSON): the rules to answer under and every component and token to answer from, with the ones the question names expanded in full |
42
43
 
43
44
  ## CLI
44
45
 
@@ -58,13 +59,50 @@ The package name and the `stark-mcp` bin match, so plain `npx
58
59
  is what selects the second (`stark-cli`) bin instead. Once installed locally
59
60
  or globally, just run `stark-cli <command>`. Run `stark-cli --help` for the
60
61
  full command list. Output is always JSON on stdout; errors go to stderr with
61
- a non-zero exit code.
62
+ a non-zero exit code — except `whisper`, whose output is prose.
63
+
64
+ ## Whisperer
65
+
66
+ `whisperer_context` is the brain behind Whisperer, the customization
67
+ concierge in Stark Dominion, published so that an editor agent — Cursor,
68
+ Claude Code, anything holding this server — answers a design-system question
69
+ from the same facts Dominion's chat panel does, through the same code path
70
+ (`src/whisperer.js`; Dominion imports it rather than keeping a copy). Give it
71
+ the question and, for a follow-up, the prior turns newest first; put what
72
+ comes back in the system prompt and answer from it alone. It carries every
73
+ component with its props per platform, every semantic token and scale with
74
+ its light and dark values, the token it aliases and its React Native export
75
+ name, and the full detail — usage, props, examples, component tokens — of the
76
+ components the question names. The colour-shade primitives are deliberately
77
+ withheld from the list, because a component token must alias a semantic
78
+ token, never a shade; a question that names one outright still gets it
79
+ resolved.
80
+
81
+ From a terminal:
82
+
83
+ ```bash
84
+ npx --package=@starklab/stark-mcp stark-cli whisper "Does Card exist on React Native?"
85
+ npx --package=@starklab/stark-mcp stark-cli whisper "and the sizes?" --context="What variants does Button have?"
86
+ npx --package=@starklab/stark-mcp stark-cli whisper "What is --stk-radius-md?" --json
87
+ ```
88
+
89
+ `--json` gives the parts apart — `systemPrompt`, `grounding`, and the
90
+ `components`, `named` and `tokens` the grounding expanded — for a caller
91
+ that fills its own slots. The rules the prompt carries are the design
92
+ system's; a surface adds its own (Dominion adds how its panel renders and
93
+ that it cannot see the questioner's code).
62
94
 
63
95
  ## Public manifest
64
96
 
65
97
  `npm run generate-manifest` writes the full catalog (usage + props + tokens
66
98
  for every component) to `manifest.json` inside the installed
67
- `@starklab/stk` package. Run it before publishing a new version of
99
+ `@starklab/stk` package. The list of components is `catalog.json`'s exports
100
+ (every `component`, `primitive` and `subpart`, on either platform) unioned
101
+ with the mapping files, so a component that ships without a mapping file
102
+ (`MoveMenu`, `StatusPage`, every React Native component) is still in it. Per
103
+ component, `usage` is the web usage file and `nativeUsage` the React Native
104
+ one from `usage/components/rn/` — its own rules, prop table and code example —
105
+ or `null` where the platform does not apply. Run it before publishing a new version of
68
106
  `stk` so the manifest ships with it and stays fetchable as a static file —
69
107
  e.g. via a CDN mirror of the npm package — without needing an MCP session at
70
108
  all. It's not checked into git (same as `stk`'s `build/` output); regenerate
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starklab/stark-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "MCP server exposing the Stark design system catalog, usage rules, prop mappings, and layout conformance checks to any MCP-compatible coding agent — without needing the stark-workspace monorepo checked out.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -21,9 +21,20 @@
21
21
  "start": "node src/index.js",
22
22
  "generate-manifest": "node scripts/generate-manifest.js",
23
23
  "generate-foreign-systems": "node scripts/generate-foreign-systems.js",
24
+ "canary:foreign": "node scripts/foreign-canary.js",
24
25
  "validate:pack": "node ../../scripts/validate-pack.mjs",
25
26
  "prepublishOnly": "npm run validate:pack"
26
27
  },
28
+ "peerDependencies": {
29
+ "axe-core": ">=4",
30
+ "playwright": ">=1.40",
31
+ "playwright-core": ">=1.40"
32
+ },
33
+ "peerDependenciesMeta": {
34
+ "axe-core": { "optional": true },
35
+ "playwright": { "optional": true },
36
+ "playwright-core": { "optional": true }
37
+ },
27
38
  "dependencies": {
28
39
  "@starklab/stk": "^1.2.0",
29
40
  "@babel/parser": "^7.29.7",
@@ -0,0 +1,397 @@
1
+ /**
2
+ * The accessibility half of `stark-cli adopt`, and the only part of the
3
+ * command that needs a browser.
4
+ *
5
+ * Why it exists at all: accessibility rules are statements about a *rendered*
6
+ * page — a contrast ratio is a fact about two resolved colors, a missing
7
+ * accessible name is a fact about the element the browser actually built. A
8
+ * static scan of source has neither, which is why lib/adoptionScan.ts's
9
+ * `scoreFrom` drops the a11y term rather than inventing it. The missing
10
+ * ingredient is a running product, not a connector: it happens that Dominion's
11
+ * own crawler is the only place one existed, and that is a fact about where
12
+ * the browser was, not about repos.
13
+ *
14
+ * So this runs the browser where a running product already exists and no
15
+ * credential problem does — inside the consumer's own CI, against the app
16
+ * their pipeline just booted, with whatever logged-in session their own tests
17
+ * already set up. Dominion never sees the URLs; it sees the numbers.
18
+ *
19
+ * Three properties are inherited wholesale from adoptScanReport.js, because
20
+ * this runs in the same place under the same rules:
21
+ *
22
+ * 1. **It must never break the consumer's CI.** Every failure path returns
23
+ * `{ ran: false, reason }`. Nothing throws, nothing sets process.exitCode,
24
+ * and a missing browser is an ordinary outcome rather than an error — the
25
+ * adoption scan itself already succeeded and has numbers to report.
26
+ * 2. **It must not leak anything the consumer did not name.** The only URLs
27
+ * visited are the ones passed on the command line. There is no crawl here
28
+ * and deliberately so: following links inside a CI network is how a
29
+ * scanner ends up on an internal host nobody meant to hand it.
30
+ * 3. **The penalty scale is shared, not chosen.** AXE_SCORE_MAP below is the
31
+ * same map packages/stk/scripts/a11y/axe-runner.js and dominion's
32
+ * lib/url-crawl.ts use. An a11y number measured here has to be comparable
33
+ * with one measured by the crawler or the label "includes accessibility"
34
+ * would mean two different things.
35
+ *
36
+ * One rule is this module's own, because only this module runs against a page
37
+ * the consumer's CI just booted rather than a public site: **a page axe could
38
+ * read but the browser never filled is not a measurement.** See SETTLE_CAP_MS
39
+ * and isBlankRender below — between them they are the difference between
40
+ * scoring the product and scoring the empty div it mounts into.
41
+ */
42
+
43
+ /**
44
+ * ds-governance.md's scoring matrix, byte-identical to the two other places it
45
+ * appears. Only `critical` and `serious` are scored: `moderate` and `minor`
46
+ * are advisory in axe's own vocabulary, and letting them move a number would
47
+ * make the score depend on axe's taxonomy drift rather than on the product.
48
+ */
49
+ const AXE_SCORE_MAP = { critical: -20, serious: -10 };
50
+
51
+ /** Same ceiling as lib/url-crawl.ts's SITE_PAGE_CAP, for the same reason. */
52
+ const MAX_PAGES = 25;
53
+
54
+ /** Per page. A CI job is not a place to hang on a slow route. */
55
+ const DEFAULT_PAGE_TIMEOUT_MS = 20000;
56
+
57
+ /** A snippet is a hover-sized excerpt — dominion bounds these again on ingest. */
58
+ const MAX_HTML_LENGTH = 400;
59
+
60
+ /**
61
+ * How long a page may keep changing after `load` before axe reads it, and how
62
+ * much stillness counts as finished.
63
+ *
64
+ * `load` fires once the document and its subresources are in. On a
65
+ * server-rendered page that is the moment the markup is final; on a
66
+ * client-rendered one it is roughly the moment the first script starts
67
+ * running. This pass is pointed at a page of the consumer's own product,
68
+ * booted by their own pipeline, so the second case is the common one and axe
69
+ * was reading the shell before the product existed — no violations found,
70
+ * score 100, and the "includes accessibility" label on a measurement of
71
+ * nothing.
72
+ *
73
+ * Waiting on the network is the usual reflex and the wrong one here.
74
+ * `networkidle` wants 500ms with no connections at all, and the app under
75
+ * test in CI is very often a dev server holding an open hot-reload socket, so
76
+ * that wait would expire on every page and spend its whole budget doing it.
77
+ * What axe needs is a DOM that has stopped moving *and has something in it*,
78
+ * so that is what this waits for directly. Both halves are load-bearing, and
79
+ * the first one alone is wrong in a way that looks right: at `load` a
80
+ * client-rendered page is perfectly still — it has not started yet — so a
81
+ * plain quiet-period check passes immediately and measures the shell it was
82
+ * written to avoid. Measured against a page that renders after 800ms, the
83
+ * quiet-only version returned "nothing rendered" and the pass reported no
84
+ * measurement at all.
85
+ *
86
+ * The cost lands where it should: a page already finished at `load` leaves
87
+ * after SETTLE_QUIET_MS, a page still building leaves SETTLE_QUIET_MS after
88
+ * its last change, and only a page that never renders anything pays the full
89
+ * cap — which is exactly the page whose measurement is about to be thrown
90
+ * away anyway.
91
+ */
92
+ const SETTLE_QUIET_MS = 500;
93
+ const SETTLE_CAP_MS = 5000;
94
+
95
+ /**
96
+ * Loads playwright without making it a dependency of this package.
97
+ *
98
+ * `playwright` is tried before `playwright-core` because only the former
99
+ * brings a browser with it; a consumer who installed the core package alone is
100
+ * expected to point at their own binary. Neither is declared as anything but
101
+ * an optional peer: most people running `stark-cli adopt` never pass an a11y
102
+ * URL, and a browser download is not a reasonable cost to put on them.
103
+ */
104
+ async function loadChromium() {
105
+ for (const specifier of ['playwright', 'playwright-core']) {
106
+ try {
107
+ const mod = await import(specifier);
108
+ if (mod?.chromium) return { chromium: mod.chromium, specifier };
109
+ } catch {
110
+ // Not installed, or installed broken. Try the next one; the caller gets
111
+ // one actionable message below rather than a stack per attempt.
112
+ }
113
+ }
114
+ return null;
115
+ }
116
+
117
+ async function loadAxe() {
118
+ try {
119
+ const mod = await import('axe-core');
120
+ // axe-core ships CJS; the default interop is where `source` lands.
121
+ const axe = mod?.default ?? mod;
122
+ return axe?.source ? axe : null;
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
127
+
128
+ /**
129
+ * The element's own markup, bounded. Unlike a source finding there is no file
130
+ * and no line to point at, so the markup and the selector are the whole of the
131
+ * evidence — the same trade dominion's URL findings make, and the reason those
132
+ * show "what to write" rather than a diff to apply.
133
+ */
134
+ function snippetFor(url, node, violation) {
135
+ const html = String(node?.html ?? '').slice(0, MAX_HTML_LENGTH);
136
+ if (!html) return null;
137
+ return {
138
+ file: url,
139
+ before: [html],
140
+ after: null,
141
+ highlightIndex: 0,
142
+ beforeAnnotation: String(violation?.help ?? violation?.id ?? '').slice(0, 200) || null,
143
+ afterAnnotation: null,
144
+ };
145
+ }
146
+
147
+ function selectorOf(node) {
148
+ const target = Array.isArray(node?.target) ? node.target : [];
149
+ // axe nests a frame path as an array; flattened here so one string is one
150
+ // element wherever it lives.
151
+ return target.flat().filter((t) => typeof t === 'string').join(' >>> ');
152
+ }
153
+
154
+ /**
155
+ * Wait for the page to stop building itself, and to have built something.
156
+ *
157
+ * Best-effort by contract: a page that loaded gets measured whether or not
158
+ * this settles, so every failure here is swallowed rather than costing the
159
+ * consumer a measurement. The cap is what makes it safe to point at a page
160
+ * that never comes to rest — a ticking clock, a carousel, a poller, or one
161
+ * that renders nothing at all — and the quiet period is what keeps it cheap
162
+ * against a page that was finished before it arrived.
163
+ *
164
+ * The readiness test is isBlankRender's, inverted, and it has to be: this
165
+ * decides when to stop waiting for content, that decides whether content ever
166
+ * came, and two different answers to "is there anything here" would let a
167
+ * page fail one and pass the other.
168
+ */
169
+ async function settleDom(page, { quietMs = SETTLE_QUIET_MS, capMs = SETTLE_CAP_MS } = {}) {
170
+ try {
171
+ await page.evaluate(
172
+ ({ quiet, cap }) =>
173
+ new Promise((resolve) => {
174
+ let idle;
175
+ const observer = new MutationObserver(restart);
176
+
177
+ const rendered = () => {
178
+ const body = document.body;
179
+ if (!body) return false;
180
+ if (body.innerText.trim().length > 0) return true;
181
+ return body.querySelectorAll('img, svg, canvas, video, iframe').length > 0;
182
+ };
183
+
184
+ function stop() {
185
+ clearTimeout(idle);
186
+ clearTimeout(ceiling);
187
+ observer.disconnect();
188
+ resolve();
189
+ }
190
+ // Quiet is only the end of the wait once there is something to have
191
+ // gone quiet about. Until then this re-arms rather than finishing,
192
+ // which also keeps a page that renders with no DOM change of its
193
+ // own — an <img> that simply took its time — from being read early.
194
+ function settled() {
195
+ if (rendered()) stop();
196
+ else restart();
197
+ }
198
+ function restart() {
199
+ clearTimeout(idle);
200
+ idle = setTimeout(settled, quiet);
201
+ }
202
+
203
+ observer.observe(document.documentElement, {
204
+ childList: true,
205
+ subtree: true,
206
+ attributes: true,
207
+ characterData: true,
208
+ });
209
+ // Declared here rather than above so it can be const: nothing can
210
+ // call stop() before this line runs, since this is the first timer
211
+ // armed and the observer only ever re-arms `idle`.
212
+ const ceiling = setTimeout(stop, cap);
213
+ restart();
214
+ }),
215
+ { quiet: quietMs, cap: capMs },
216
+ );
217
+ } catch {
218
+ // The page went away, or navigated under us. Either way the next step
219
+ // reads whatever is actually there, and says so if it is nothing.
220
+ }
221
+ }
222
+
223
+ /** What the browser ended up showing, reduced to the two facts below. */
224
+ async function inspectRender(page) {
225
+ return page.evaluate(() => ({
226
+ text: (document.body ? document.body.innerText : '').trim().slice(0, 200),
227
+ visuals: document.body
228
+ ? document.body.querySelectorAll('img, svg, canvas, video, iframe').length
229
+ : 0,
230
+ }));
231
+ }
232
+
233
+ /**
234
+ * Whether the browser rendered nothing a person could have read or seen.
235
+ *
236
+ * axe is honest about an empty page: it reports no violations, because there
237
+ * is nothing there to violate anything. Scored, that reads as a flawless
238
+ * page — the same "reward a broken route" mistake the load-failure path
239
+ * refuses to make, arriving through a different door, and the one failure
240
+ * this pass could otherwise report as a success. A client-rendered app whose
241
+ * bundle 404s in the consumer's CI lands here every time.
242
+ *
243
+ * Deliberately blunt: any text at all, or any image, icon, canvas, video or
244
+ * frame, counts as rendered. A real page clears that bar without trying, and
245
+ * the alternative — deciding how much content is enough — would start
246
+ * throwing away real measurements of pages that are merely sparse.
247
+ */
248
+ export function isBlankRender({ text = '', visuals = 0 } = {}) {
249
+ return String(text).trim().length === 0 && visuals === 0;
250
+ }
251
+
252
+ const BLANK_RENDER_REASON =
253
+ 'The page loaded but rendered nothing — no text and no images. An empty page has no violations, which would score as a perfect one, so it is reported as not measured instead.';
254
+
255
+ /**
256
+ * Runs axe against each URL and folds the results into one a11y score.
257
+ *
258
+ * Never throws. `ran: false` means the pass could not run at all and the caller
259
+ * must report a scan *without* accessibility rather than one with a zero in it
260
+ * — a page that failed to load and a page with no violations are not the same
261
+ * measurement, and only one of them may carry the "includes accessibility"
262
+ * label.
263
+ */
264
+ export async function runA11yPass(urls, {
265
+ timeoutMs = DEFAULT_PAGE_TIMEOUT_MS,
266
+ maxPages = MAX_PAGES,
267
+ executablePath = process.env.STARK_A11Y_BROWSER_PATH || undefined,
268
+ } = {}) {
269
+ const wanted = [...new Set((urls ?? []).filter(Boolean))];
270
+ if (wanted.length === 0) return { ran: false, reason: 'No --a11y-url was given.' };
271
+
272
+ const loaded = await loadChromium();
273
+ if (!loaded) {
274
+ return {
275
+ ran: false,
276
+ reason:
277
+ 'Accessibility pass skipped: playwright is not installed. Add it where this scan runs — "npm i -D playwright && npx playwright install chromium" — or drop --a11y-url to score without accessibility.',
278
+ };
279
+ }
280
+ const axe = await loadAxe();
281
+ if (!axe) {
282
+ return {
283
+ ran: false,
284
+ reason: 'Accessibility pass skipped: axe-core is not installed. Add it where this scan runs: "npm i -D axe-core".',
285
+ };
286
+ }
287
+
288
+ const requested = wanted.length;
289
+ const pageUrls = wanted.slice(0, maxPages);
290
+
291
+ let browser;
292
+ try {
293
+ browser = await loaded.chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}) });
294
+ } catch (err) {
295
+ return {
296
+ ran: false,
297
+ reason: `Accessibility pass skipped: could not launch a browser (${err.message}). If you installed playwright-core only, set STARK_A11Y_BROWSER_PATH or pass --a11y-browser-path.`,
298
+ };
299
+ }
300
+
301
+ const pages = [];
302
+ const findings = [];
303
+ const failed = [];
304
+ try {
305
+ const context = await browser.newContext();
306
+ for (const url of pageUrls) {
307
+ const page = await context.newPage();
308
+ try {
309
+ await page.goto(url, { waitUntil: 'load', timeout: timeoutMs });
310
+ await settleDom(page);
311
+ if (isBlankRender(await inspectRender(page))) {
312
+ failed.push({ url, reason: BLANK_RENDER_REASON });
313
+ continue;
314
+ }
315
+ await page.addScriptTag({ content: axe.source });
316
+ const results = await page.evaluate(() => window.axe.run(document.documentElement));
317
+
318
+ // One entry per failing node, not per rule: the score is a count of
319
+ // occurrences, and so is the queue a person works through.
320
+ const nodes = (results?.violations ?? [])
321
+ .filter((v) => v.impact === 'critical' || v.impact === 'serious')
322
+ .flatMap((v) => (v.nodes ?? []).map((node) => ({ violation: v, node })));
323
+
324
+ let critical = 0;
325
+ let serious = 0;
326
+ const seen = new Set();
327
+ for (const { violation, node } of nodes) {
328
+ const selector = selectorOf(node);
329
+ // Two axe checks can report the same element under the same rule
330
+ // through different frame paths; the pair is the occurrence.
331
+ const key = `${violation.id}:${selector}`;
332
+ if (seen.has(key)) continue;
333
+ seen.add(key);
334
+
335
+ if (violation.impact === 'critical') critical += 1;
336
+ else serious += 1;
337
+
338
+ findings.push({
339
+ rule: violation.id,
340
+ // The page's own URL stands in for a file path — the same
341
+ // substitution lib/url-crawl.ts makes, and what the finding's
342
+ // header shows.
343
+ file: url,
344
+ selector,
345
+ severity: violation.impact === 'critical' ? 'critical' : 'warning',
346
+ snippet: snippetFor(url, node, violation),
347
+ });
348
+ }
349
+
350
+ const penalty = critical * AXE_SCORE_MAP.critical + serious * AXE_SCORE_MAP.serious;
351
+ pages.push({ url, score: Math.max(0, 100 + penalty), critical, serious });
352
+ } catch (err) {
353
+ // A page that did not load was not measured. It is recorded as such
354
+ // rather than scored 100 (which would reward a broken route) or 0
355
+ // (which would punish the product for a CI networking problem).
356
+ failed.push({ url, reason: err.message });
357
+ } finally {
358
+ await page.close().catch(() => {});
359
+ }
360
+ }
361
+ } catch (err) {
362
+ return { ran: false, reason: `Accessibility pass failed: ${err.message}` };
363
+ } finally {
364
+ await browser.close().catch(() => {});
365
+ }
366
+
367
+ if (pages.length === 0) {
368
+ return {
369
+ ran: false,
370
+ // "measured", not "loaded": a page can now fail here by loading
371
+ // perfectly and showing nothing, and the reason below says which.
372
+ reason: `Accessibility pass skipped: none of the ${requested} page(s) could be measured.${failed[0] ? ` First reason: ${failed[0].reason}` : ''}`,
373
+ };
374
+ }
375
+
376
+ // Averaged across pages, not summed: a11y is a rate, and lib/url-crawl.ts's
377
+ // aggregateSite averages it for the same reason component and token counts
378
+ // are summed and it is not — twenty clean pages do not pay for one broken
379
+ // one, and a site is not more accessible for having more pages.
380
+ const score = Math.round(pages.reduce((s, p) => s + p.score, 0) / pages.length);
381
+
382
+ return {
383
+ ran: true,
384
+ score,
385
+ pages,
386
+ requested,
387
+ scanned: pages.length,
388
+ skipped: failed,
389
+ counts: {
390
+ critical: pages.reduce((s, p) => s + p.critical, 0),
391
+ serious: pages.reduce((s, p) => s + p.serious, 0),
392
+ },
393
+ findings,
394
+ };
395
+ }
396
+
397
+ export { AXE_SCORE_MAP };