@sakaniui/react 0.8.9 → 0.8.10

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/AGENTS.md CHANGED
@@ -16,7 +16,7 @@ import manifest from '@sakaniui/react/manifest.json';
16
16
  const button = manifest.components.find((c) => c.name === 'Button'); // props[], contract.requiredProps
17
17
  ```
18
18
  Editors can query the same data through the bundled MCP server
19
- (`npx -y -p @sakaniui/react sakaniui-mcp`): `search_components`, `get_component`, `search_icons`,
19
+ (`npx -y @sakaniui/mcp`): `search_components`, `get_component`, `search_icons`,
20
20
  `check_code`, `check_usage`, `get_tokens`, `get_guide`, `check_compatibility`, `get_changelog`.
21
21
 
22
22
  **Start from the example, check before you answer.** Every component has `example`: its imports and the
package/CHANGELOG.md CHANGED
@@ -2,6 +2,47 @@
2
2
 
3
3
  All notable changes to `@sakaniui/react` are documented here.
4
4
 
5
+ ## 0.8.10
6
+
7
+ Readiness release: everything below was found by testing the package the way a new user, an editor
8
+ and an AI agent would, and every fix is now covered by a check that fails the build.
9
+
10
+ **Installing the library**
11
+ - **Fixed: installing `@sakaniui/react` installed Next.js.** The `geist` and `@fontsource-variable/geist`
12
+ font packages were runtime dependencies, though the library never loads them, and `geist` requires
13
+ Next.js as a peer, so every app (Vite included) got Next.js 16. Both are now dev-only; the only runtime
14
+ dependency is `recharts`. Load Geist yourself if you want it (`@fontsource-variable/geist`); otherwise
15
+ the system font is used, as before.
16
+ - **Fixed: missing type definitions.** `dist/lib/` was left out of the generated `.d.ts` files, so
17
+ `LiquidGlass`, `LiquidBackdrop`, `HoverGroup`, `useHoverHighlight`, `useLiquidGlass`, `getContrastColor`
18
+ and `iconStrokeWidth` had no types: `<LiquidBackdrop />` compiled without its required `src`. The build
19
+ now fails if any relative import in a shipped `.d.ts` does not resolve.
20
+ - **Fixed docs: `style.css` is required.** The README and the website's Installation page only imported
21
+ `tokens.css`, which leaves components unstyled. Both now import both stylesheets.
22
+
23
+ **The MCP server**
24
+ - **New package `@sakaniui/mcp`** (about 190 kB, no dependencies): `npx -y @sakaniui/mcp` starts in
25
+ about 2 s on a first run. Through `@sakaniui/react` it took ~30 s and ~675 MB, long enough for an editor
26
+ to give up. The copy in `@sakaniui/react` still works.
27
+ - **`check_code`: no false positives** on 685 real elements across three apps (it reported 77). Aliased
28
+ and default imports, code inside template strings, native attributes and the non-icon exports of
29
+ `@sakaniui/react/icons` are handled; it still catches every deliberate mistake in the test set.
30
+ - **Versions per prop and per value.** Each prop added after its component has its own `since` (for
31
+ example `Switch.variant`, 0.8.4), and values added later are listed in `valuesSince`. `since` now means
32
+ "first release you could import it", taken from the package's public exports at every release (the
33
+ social icons are 0.8.6, not 0.3.0). `check_code`, `check_usage` and `get_component` flag props and
34
+ values the project's installed version does not have.
35
+ - **Invented props are errors.** A prop that is neither the component's own nor an HTML attribute (React's
36
+ own attribute list now ships in `manifest.json` as `nativeAttributes`) fails `check_code` and
37
+ `check_usage`, with the real prop list and a suggestion (`leftIcon` -> "did you mean leadingIcon?").
38
+ Real HTML attributes (`placeholder`, `href`, `name`...) pass silently; `color` gets a note that it does
39
+ not style the component.
40
+ - **Protocol:** negotiates a supported protocol version, answers `resources/list`, `prompts/list` and
41
+ batches, exits when the editor disconnects (flushing replies first), and survives a closed pipe.
42
+ `get_guide` accepts `query`. Tested on Node 18, 20, 22 and 26, with the official MCP SDK client and the
43
+ MCP Inspector, and documented for Windows (`cmd /c npx ...`).
44
+ - Removed an unresolvable `$schema` link from `manifest.json`.
45
+
5
46
  ## 0.8.9
6
47
 
7
48
  Stops assistants from inventing code. Found by testing the MCP server in Claude Code: it validated a
package/README.md CHANGED
@@ -43,12 +43,33 @@ accepts Lucide or Tabler React icons. See `THIRD_PARTY_NOTICES.md` for the MIT l
43
43
  npm install @sakaniui/react
44
44
  ```
45
45
 
46
- Then import the tokens once at your app's entry point, and any component from the package root:
46
+ Then import the two stylesheets once, at your app's entry point (both are required: `tokens.css` defines
47
+ the design tokens, `style.css` styles the components), and use any component from the package root:
47
48
 
48
49
  ```tsx
49
50
  import '@sakaniui/react/tokens.css';
51
+ import '@sakaniui/react/style.css';
52
+
53
+ import { Button } from '@sakaniui/react';
54
+
55
+ export function Example() {
56
+ return <Button variant="primary">Get started</Button>;
57
+ }
58
+ ```
59
+
60
+ Requires React 19. Typography is set in **Geist**; load the font yourself if you want it (otherwise the
61
+ system font is used):
62
+
63
+ ```bash
64
+ npm install @fontsource-variable/geist
50
65
  ```
51
66
 
67
+ ```tsx
68
+ import '@fontsource-variable/geist';
69
+ ```
70
+
71
+ Charts (`AreaChart`, `BarChart`, ...) use `recharts`, which is installed with the package.
72
+
52
73
  > **Building with AI?** `AGENTS.md` (shipped in this package) explains *why* components behave as they do and what to do when nothing fits. Index for agents: https://www.sakaniui.com/llms.txt
53
74
 
54
75
  ## Quick start (contributing / browsing the source)
@@ -108,12 +129,17 @@ up instead of guessing prop names.
108
129
  ```json
109
130
  {
110
131
  "mcpServers": {
111
- "sakaniui": { "command": "npx", "args": ["-y", "-p", "@sakaniui/react", "sakaniui-mcp"] }
132
+ "sakaniui": { "command": "npx", "args": ["-y", "@sakaniui/mcp"] }
112
133
  }
113
134
  }
114
135
  ```
115
136
 
116
- Claude Code: `claude mcp add sakaniui -- npx -y -p @sakaniui/react sakaniui-mcp`
137
+ Claude Code: `claude mcp add sakaniui -- npx -y @sakaniui/mcp`
138
+
139
+ On Windows, launch `npx` through `cmd /c` (`"command": "cmd", "args": ["/c", "npx", "-y", "@sakaniui/mcp"]`,
140
+ or `claude mcp add sakaniui -- cmd /c npx -y @sakaniui/mcp`). Needs Node.js 18 or later.
141
+
142
+ The server is its own tiny package, [`@sakaniui/mcp`](https://www.npmjs.com/package/@sakaniui/mcp) (about 1 MB, starts in a couple of seconds; it does not install the component library). The copy bundled in `@sakaniui/react` (`npx -y -p @sakaniui/react sakaniui-mcp`) still works but downloads far more.
117
143
 
118
144
  Tools: `search_components`, `get_component` (with a type-checked `example`), `search_icons`,
119
145
  `check_code` (checks a whole snippet: imports, required props, values, icon names), `check_usage`,
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * SakaniUI MCP server -- lets an AI editor/agent look up the design system instead of guessing.
4
4
  *
5
- * npx -y -p @sakaniui/react sakaniui-mcp [--project <dir>]
5
+ * npx -y @sakaniui/mcp [--project <dir>] (or: npx -y -p @sakaniui/react sakaniui-mcp)
6
6
  *
7
7
  * Zero dependencies: speaks newline-delimited JSON-RPC (MCP stdio transport).
8
8
  *
@@ -47,7 +47,8 @@ function findProjectInstall() {
47
47
  return null;
48
48
  }
49
49
 
50
- const bundled = loadInstall(ownRoot);
50
+ // inside @sakaniui/react the data sits next to bin/; inside @sakaniui/mcp it is copied into data/
51
+ const bundled = loadInstall(fs.existsSync(path.join(ownRoot, 'dist', 'manifest.json')) ? ownRoot : path.join(ownRoot, 'data'));
51
52
  if (!bundled || !bundled.manifest) {
52
53
  process.stderr.write('sakaniui-mcp: dist/manifest.json not found. Run `npm run build:lib` (or install a published version).\n');
53
54
  process.exit(1);
@@ -68,6 +69,22 @@ const changelog = active.changelog || bundled.changelog || '';
68
69
  const iconIndex = (active.icons && active.icons.icons) || (bundled.icons && bundled.icons.icons) || [];
69
70
  const projectVersion = project ? project.version : null;
70
71
 
72
+ // The HTML attributes React accepts (shipped in the manifest since 0.8.10; older manifests use the fallback list).
73
+ const nativeAttrs = new Set(manifest.nativeAttributes || []);
74
+ const NATIVE = { test: (n) => /^(aria-|data-)/.test(n) || /^on[A-Z]/.test(n) || n === 'key' || n === 'ref' || n === 'children' || (nativeAttrs.size ? nativeAttrs.has(n) : NATIVE_FALLBACK.test(n)) };
75
+ /** What to say about a prop the component does not declare: an error when nothing accepts it, else a note. */
76
+ function unknownProp(c, name) {
77
+ if (NATIVE.test(name)) {
78
+ // a real HTML attribute (placeholder, href, name, defaultChecked...): passed to the element, and fine.
79
+ // `color` is the one people mistake for a styling prop: it does not colour the component.
80
+ return name === 'color' ? { level: 'warning', message: `"color" is not a ${c.name} prop: it is the old HTML colour attribute and does not change the component's colours. Use the component's own props, or design tokens (get_tokens).` } : null;
81
+ }
82
+ // close spellings, plus the usual synonyms (leftIcon -> leadingIcon, onChange vs onValueChange is left to the list)
83
+ const canon = (x) => x.toLowerCase().replace(/left|start|before|prefix/g, 'leading').replace(/right|end|after|suffix/g, 'trailing').replace(/colou?r|tone|kind|type/g, 'variant');
84
+ const near = c.props.map((p) => p.name).filter((p) => distance(p.toLowerCase(), name.toLowerCase()) <= 2 || canon(p) === canon(name));
85
+ return { level: 'error', message: `"${name}" is not a ${c.name} prop${near.length ? ` (did you mean ${near.map((x) => `"${x}"`).join(', ')}?)` : ''}. ${c.name} accepts: ${c.props.map((p) => p.name).join(', ') || 'no props of its own'}.` };
86
+ }
87
+
71
88
  /* ---------- versions ---------- */
72
89
  const SEMVER = /^\d+\.\d+\.\d+/;
73
90
  const parse = (v) => String(v).split('-')[0].split('.').map(Number);
@@ -93,6 +110,15 @@ function inInstalled(c, installed = projectVersion) {
93
110
  return cmp(c.since, installed) <= 0;
94
111
  }
95
112
 
113
+ /** Is this prop (or this value of it) in the project's install? Returns a message when it is not. */
114
+ function propTooNew(c, p, value, installed = projectVersion) {
115
+ if (!installed) return null;
116
+ if (p.since && cmp(p.since, installed) > 0) return `"${p.name}" was added to ${c.name} in ${p.since}; the project has ${installed}, where it does not exist. Upgrade (npm install @sakaniui/react@latest) or leave it out.`;
117
+ const vs = p.valuesSince && typeof value === 'string' ? p.valuesSince[value] : null;
118
+ if (vs && cmp(vs, installed) > 0) return `${p.name}=${JSON.stringify(value)} was added in ${vs}; the project has ${installed}, where it does not exist.`;
119
+ return null;
120
+ }
121
+
96
122
  /* ---------- helpers ---------- */
97
123
  const byName = new Map(manifest.components.map((c) => [c.name.toLowerCase(), c]));
98
124
  const brief = (c) => {
@@ -157,6 +183,7 @@ function closest(name) {
157
183
  /* ---------- icons ---------- */
158
184
  const iconNames = new Set();
159
185
  for (const i of iconIndex) { iconNames.add(i.name); if (i.filled) iconNames.add(i.filled); }
186
+ const iconOtherExports = new Set(((active.icons && active.icons.other) || (bundled.icons && bundled.icons.other)) || []);
160
187
  const iconWords = (name) => name.replace(/^Icon/, '').replace(/Filled$/, '').replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/([A-Za-z])(\d)/g, '$1 $2').toLowerCase();
161
188
 
162
189
  // The conventional icon for common UI intents, ranked first (tags alone rank e.g. IconHttpDelete above IconTrash for "delete").
@@ -207,7 +234,7 @@ function searchIcons(query, limit = 12) {
207
234
  }
208
235
 
209
236
  /** Checks a value given for an icon prop. Returns an error string, or null. */
210
- function iconProblem(propName, raw, importedElsewhere = new Set()) {
237
+ function iconProblem(propName, raw, importedElsewhere = new Set(), localNames = new Set()) {
211
238
  const v = String(raw).trim().replace(/^\{|\}$/g, '').trim();
212
239
  if (/^</.test(v)) {
213
240
  const tag = (/^<\s*([A-Za-z][\w.]*)/.exec(v) || [])[1] || 'Icon';
@@ -216,12 +243,15 @@ function iconProblem(propName, raw, importedElsewhere = new Set()) {
216
243
  return `"${propName}" takes the icon component itself, not an element: write ${propName}={${real || 'IconName'}}, not ${propName}={<${tag} />}.${unknown}${!real ? ' Use search_icons to find the real name.' : ''}`;
217
244
  }
218
245
  if (!/^[A-Za-z_$][\w$]*$/.test(v)) return null; // an expression we cannot judge
219
- if (iconNames.has(v) || importedElsewhere.has(v)) return null;
246
+ if (iconNames.has(v) || importedElsewhere.has(v) || localNames.has(v)) return null;
247
+ // a local name (alias, parameter, constant): the import line was already checked, and we cannot see the rest
248
+ if (!/^Icon[A-Z0-9]/.test(v)) return null;
220
249
  const hint = searchIcons(v, 3).map((i) => i.name);
221
250
  return `"${v}" is not a SakaniUI icon.${hint.length ? ` Did you mean: ${hint.join(', ')}?` : ''} Use search_icons to find the real name.`;
222
251
  }
223
252
 
224
- const NATIVE = /^(className|style|id|ref|key|children|role|tabIndex|title|hidden|lang|dir|slot|on[A-Z].*|aria-.*|data-.*)$/;
253
+ // Attributes every component forwards to its root element. A prop outside this list and the contract is worth a warning.
254
+ const NATIVE_FALLBACK = /^(className|style|id|ref|key|children|role|tabIndex|title|hidden|lang|dir|slot|on[A-Z].*|aria-.*|data-.*|href|target|rel|download|name|type|value|defaultValue|checked|defaultChecked|disabled|required|readOnly|placeholder|htmlFor|form|autoFocus|autoComplete|autoCapitalize|spellCheck|inputMode|pattern|min|max|step|minLength|maxLength|rows|cols|multiple|accept|src|srcSet|alt|width|height|loading|draggable|tabindex|ariaLabel)$/;
225
255
 
226
256
  /** Release notes: [{version, body}] newest first, from the CHANGELOG that matches the answering install. */
227
257
  function releases() {
@@ -333,14 +363,38 @@ function classify(expr) {
333
363
 
334
364
  const TYPE_LIKE = /(Props|Type|Variant|Size|Item|Option|Datum|Column|Link|Plan|Post|Action|Step|Image|Author|Change|Data|Tab|Color|Category|Job|Member|Testimonial|Transaction)$/;
335
365
 
336
- function checkCode(code) {
366
+ /** Replaces the contents of template literals with spaces (newlines kept): code shown inside a string is not code. */
367
+ function blankTemplates(code) {
368
+ let out = '';
369
+ for (let i = 0; i < code.length; i++) {
370
+ const ch = code[i];
371
+ if (ch === '`') {
372
+ let j = i + 1, depth = 0;
373
+ for (; j < code.length; j++) {
374
+ if (code[j] === '\\') { j++; continue; }
375
+ if (code[j] === '$' && code[j + 1] === '{') { depth++; j++; continue; }
376
+ if (code[j] === '}' && depth > 0) { depth--; continue; }
377
+ if (code[j] === '`' && depth === 0) break;
378
+ }
379
+ out += '`' + code.slice(i + 1, j).replace(/[^\n]/g, ' ') + '`';
380
+ i = j;
381
+ } else out += ch;
382
+ }
383
+ return out;
384
+ }
385
+
386
+ function checkCode(source) {
387
+ const code = blankTemplates(source);
337
388
  lineAt = ((idx) => (_c, pos) => idx(pos))(lineIndex(code));
338
389
  const errors = [];
339
390
  const warnings = [];
340
391
  const imports = parseImports(code);
341
392
  const importedFrom = new Map(); // local name -> module
342
- for (const imp of imports) for (const n of imp.names) importedFrom.set(n.local, imp.from);
393
+ for (const imp of imports) { for (const n of imp.names) importedFrom.set(n.local, imp.from); if (imp.def) importedFrom.set(imp.def, imp.from); }
343
394
  const importedElsewhere = new Set([...importedFrom].filter(([, from]) => !from.startsWith('@sakaniui/react')).map(([n]) => n));
395
+ // anything the snippet declares itself (const Icon = ..., function X, class Y) or aliases: not ours to judge
396
+ const localNames = new Set([...code.matchAll(/\b(?:const|let|var|function|class)\s+([A-Za-z_$][\w$]*)/g)].map((m) => m[1]));
397
+ for (const imp of imports) for (const n of imp.names) if (n.local !== n.imported) localNames.add(n.local);
344
398
 
345
399
  for (const imp of imports) {
346
400
  if (!imp.from.startsWith('@sakaniui/react')) continue;
@@ -355,7 +409,7 @@ function checkCode(code) {
355
409
  const c = byName.get(n.imported.toLowerCase());
356
410
  const isUtility = manifest.utilities?.some((u) => u.name === n.imported);
357
411
  if (entry === '@sakaniui/react/icons') {
358
- if (!iconNames.has(n.imported)) {
412
+ if (!iconNames.has(n.imported) && !iconOtherExports.has(n.imported)) {
359
413
  const hint = searchIcons(n.imported, 3).map((i) => i.name);
360
414
  errors.push({ line: imp.line, message: `${n.imported} is not exported by @sakaniui/react/icons.${hint.length ? ` Did you mean: ${hint.join(', ')}?` : ''}` });
361
415
  }
@@ -383,6 +437,7 @@ function checkCode(code) {
383
437
  const c = byName.get(el.tag.toLowerCase());
384
438
  if (!c) continue;
385
439
  const at = (message) => ({ line: el.line, message: `<${el.tag}>: ${message}` });
440
+ if (inInstalled(c) === false && !importedFrom.has(el.tag)) errors.push(at(`${c.name} was added in ${c.since}; the project has ${projectVersion}, where it does not exist.`));
386
441
  if (!importedFrom.has(el.tag)) warnings.push(at(`not imported. Add: import { ${c.name} } from '${c.import}';`));
387
442
  const given = new Map(el.attrs.map((a) => [a.name, a]));
388
443
  if (!el.selfClosing) given.set('children', { kind: 'expression' });
@@ -394,16 +449,17 @@ function checkCode(code) {
394
449
  const known = new Map(c.props.map((p) => [p.name, p]));
395
450
  for (const a of el.attrs) {
396
451
  const p = known.get(a.name);
397
- if (!p) { if (!NATIVE.test(a.name) && a.name !== 'key' && a.name !== 'ref') warnings.push(at(`"${a.name}" is not a ${c.name} prop (it only works if it is a native attribute).`)); continue; }
452
+ if (!p) { const u = unknownProp(c, a.name); if (u) (u.level === 'error' ? errors : warnings).push(at(u.message)); continue; }
398
453
  if (p.values && ['string', 'number', 'boolean'].includes(a.kind) && !p.values.includes(a.value)) {
399
454
  errors.push(at(`${a.name}=${JSON.stringify(a.value)} is not allowed. Use one of: ${p.values.map((x) => JSON.stringify(x)).join(', ')}.`));
400
455
  }
456
+ { const tooNew = propTooNew(c, p, a.kind === 'string' ? a.value : undefined); if (tooNew) errors.push(at(tooNew)); }
401
457
  if (p.type === 'IconComponent') {
402
458
  if (a.kind === 'string') errors.push(at(`"${a.name}" takes an icon component, not a string: ${a.name}={IconName}.`));
403
459
  else if (a.kind === 'element' || a.kind === 'identifier') {
404
- const e = iconProblem(a.name, a.raw, importedElsewhere);
460
+ const e = iconProblem(a.name, a.raw, importedElsewhere, localNames);
405
461
  if (e) errors.push(at(e));
406
- else if (a.kind === 'identifier' && iconNames.has(a.value) && importedFrom.get(a.value) !== '@sakaniui/react/icons') warnings.push(at(`${a.value} is not imported. Add: import { ${a.value} } from '@sakaniui/react/icons';`));
462
+ else if (a.kind === 'identifier' && iconNames.has(a.value) && !importedFrom.has(a.value) && !localNames.has(a.value)) warnings.push(at(`${a.value} is not imported. Add: import { ${a.value} } from '@sakaniui/react/icons';`));
407
463
  }
408
464
  }
409
465
  }
@@ -449,6 +505,17 @@ const tools = {
449
505
  if (c.example) out.exampleNote = 'Start from `example`: it is type-checked against this release. Replace the placeholder text and values; keep the prop names and shapes.';
450
506
  if (c.props.some((p) => p.type === 'IconComponent')) out.iconNote = 'Icon props take the component itself (icon={IconTrash}), not an element. The example uses IconStar as a placeholder: find the real icon with search_icons.';
451
507
  if (inInstalled(c) === false) out.availableInInstalled = false;
508
+ if (projectVersion) {
509
+ out.props = c.props.map((p) => {
510
+ const tooNew = propTooNew(c, p);
511
+ const newerValues = p.valuesSince ? Object.entries(p.valuesSince).filter(([, v]) => cmp(v, projectVersion) > 0).map(([k]) => k) : [];
512
+ if (!tooNew && !newerValues.length) return p;
513
+ const q = { ...p };
514
+ if (tooNew) q.availableInInstalled = false;
515
+ if (newerValues.length) q.valuesNotInInstalled = newerValues;
516
+ return q;
517
+ });
518
+ }
452
519
  const note = versionNote();
453
520
  if (note) out.versionNote = note;
454
521
  if (inInstalled(c) === false) out.warning = `${c.name} was added in ${c.since} but the project has ${projectVersion}. Do not use it without upgrading (npm install @sakaniui/react@latest).`;
@@ -505,11 +572,12 @@ const tools = {
505
572
  for (const r of c.contract.requiredProps) if (!Object.hasOwn(props, r)) errors.push(`Missing required prop "${r}".`);
506
573
  for (const [k, v] of Object.entries(props)) {
507
574
  const p = known.get(k);
508
- if (!p) { if (!NATIVE.test(k)) warnings.push(`"${k}" is not a ${c.name} prop in the manifest (it would only work if it is a native attribute).`); continue; }
575
+ if (!p) { const u = unknownProp(c, k); if (u) (u.level === 'error' ? errors : warnings).push(u.message); continue; }
509
576
  if (p.values && (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') && !p.values.includes(v)) {
510
577
  errors.push(`"${k}" = ${JSON.stringify(v)} is not allowed. Use one of: ${p.values.map((x) => JSON.stringify(x)).join(', ')}.`);
511
578
  }
512
579
  if (p.type === 'IconComponent' && typeof v === 'string') { const e = iconProblem(k, v); if (e) errors.push(e); }
580
+ const tooNew = propTooNew(c, p, v); if (tooNew) errors.push(tooNew);
513
581
  }
514
582
  if (c.contract.requiresAccessibleName && !errors.length) warnings.push('This control has no visible text of its own: keep its accessible-name prop set.');
515
583
  const out = { component: c.name, ok: errors.length === 0, errors, warnings };
@@ -580,9 +648,10 @@ const tools = {
580
648
  },
581
649
  get_guide: {
582
650
  description: 'The AGENTS.md guide (principles, token rules, when nothing fits, glass, icons, per-component "why / when not to use", checklist). Pass a section number or heading text to get just that section; omit for the section list.',
583
- inputSchema: { type: 'object', properties: { section: { type: 'string' } } },
651
+ inputSchema: { type: 'object', properties: { section: { type: 'string', description: 'A section number ("3") or words from its heading ("icons").' } } },
584
652
  run(args) {
585
- const s0 = strArg(args, 'section'); if (s0.err) return fail(s0.err);
653
+ // agents sometimes call it with `query`; treat that as the section
654
+ const s0 = strArg(args.section === undefined && args.query !== undefined ? { section: args.query } : args, 'section'); if (s0.err) return fail(s0.err);
586
655
  if (!guide) return fail('AGENTS.md is not available in this install.');
587
656
  const parts = guide.split(/^(?=## )/m).filter((p) => p.startsWith('## '));
588
657
  const heads = parts.map((p) => p.split('\n')[0].replace(/^## /, ''));
@@ -595,45 +664,71 @@ const tools = {
595
664
  };
596
665
 
597
666
  /* ---------- JSON-RPC over stdio ---------- */
598
- const send = (msg) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', ...msg }) + '\n');
667
+ // Protocol versions this server implements; the client's is used when it is one of them.
668
+ const PROTOCOL_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
669
+
670
+ let stdoutClosed = false;
671
+ process.stdout.on('error', () => { stdoutClosed = true; process.exit(0); }); // the client went away (EPIPE)
672
+ const write = (payload) => { if (!stdoutClosed) process.stdout.write(JSON.stringify(payload) + '\n'); };
673
+ const reply = (id, body) => ({ jsonrpc: '2.0', id, ...body });
599
674
 
675
+ /** Handles one message. Returns the response, or null for notifications. */
600
676
  function handle(msg) {
601
- if (!msg || typeof msg !== 'object' || Array.isArray(msg)) return send({ id: null, error: { code: -32600, message: 'Invalid request' } });
677
+ if (!msg || typeof msg !== 'object' || Array.isArray(msg)) return reply(null, { error: { code: -32600, message: 'Invalid request' } });
602
678
  const { id, method, params } = msg;
603
679
  const isRequest = id !== undefined && id !== null;
680
+ if (typeof method !== 'string') return isRequest ? reply(id, { error: { code: -32600, message: 'Invalid request: method must be a string' } }) : null;
681
+ if (!isRequest) return null; // notifications (initialized, cancelled, ...) need no answer
604
682
  try {
605
683
  switch (method) {
606
684
  case 'initialize': {
607
685
  const note = versionNote();
608
- return send({ id, result: {
609
- protocolVersion: params?.protocolVersion || '2025-06-18',
610
- capabilities: { tools: {} },
611
- serverInfo: { name: 'sakaniui', version: refVersion },
612
- instructions: `SakaniUI ${refVersion}${projectVersion ? ` (project has ${projectVersion})` : ''}. Read-only reference. Look components up with search_components / get_component before writing JSX, validate with check_usage, use only tokens from get_tokens, and read get_guide when nothing fits.${note ? ` ${note}` : ''}`,
686
+ const asked = params && typeof params.protocolVersion === 'string' ? params.protocolVersion : null;
687
+ return reply(id, { result: {
688
+ protocolVersion: asked && PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0],
689
+ capabilities: { tools: { listChanged: false } },
690
+ serverInfo: { name: 'sakaniui', title: 'SakaniUI', version: refVersion },
691
+ instructions: `SakaniUI ${refVersion}${projectVersion ? ` (project has ${projectVersion})` : ''}. Read-only reference. Look components up with search_components / get_component and start from their \`example\`; find icon names with search_icons (never guess them); run check_code on every snippet before you answer; style anything beyond props with the design tokens from get_tokens (CSS variables), not hard-coded colours or utility classes the project may not have; read get_guide when nothing fits.${note ? ` ${note}` : ''}`,
613
692
  } });
614
693
  }
615
694
  case 'ping':
616
- return send({ id, result: {} });
695
+ return reply(id, { result: {} });
617
696
  case 'tools/list':
618
- return send({ id, result: { tools: Object.entries(tools).map(([name, t]) => ({ name, description: t.description, inputSchema: t.inputSchema, annotations: READ_ONLY })) } });
697
+ return reply(id, { result: { tools: Object.entries(tools).map(([name, t]) => ({ name, description: t.description, inputSchema: t.inputSchema, annotations: READ_ONLY })) } });
619
698
  case 'tools/call': {
620
699
  const t = typeof params?.name === 'string' && Object.hasOwn(tools, params.name) ? tools[params.name] : null;
621
- if (!t) return send({ id, error: { code: -32602, message: `Unknown tool: ${String(params?.name).slice(0, 60)}` } });
700
+ if (!t) return reply(id, { error: { code: -32602, message: `Unknown tool: ${String(params?.name).slice(0, 60)}` } });
622
701
  const args = params.arguments;
623
- if (args !== undefined && args !== null && (typeof args !== 'object' || Array.isArray(args))) return send({ id, result: fail('Tool arguments must be an object.') });
624
- return send({ id, result: t.run(args || {}) });
702
+ if (args !== undefined && args !== null && (typeof args !== 'object' || Array.isArray(args))) return reply(id, { result: fail('Tool arguments must be an object.') });
703
+ return reply(id, { result: t.run(args || {}) });
625
704
  }
705
+ // Some clients ask for these even when only tools are offered; an empty list is the polite answer.
706
+ case 'resources/list': return reply(id, { result: { resources: [] } });
707
+ case 'resources/templates/list': return reply(id, { result: { resourceTemplates: [] } });
708
+ case 'prompts/list': return reply(id, { result: { prompts: [] } });
709
+ case 'logging/setLevel': return reply(id, { result: {} });
626
710
  default:
627
- if (isRequest) send({ id, error: { code: -32601, message: `Method not found: ${String(method).slice(0, 60)}` } });
711
+ return reply(id, { error: { code: -32601, message: `Method not found: ${method.slice(0, 60)}` } });
628
712
  }
629
713
  } catch (e) {
630
- if (isRequest) send({ id, error: { code: -32603, message: String(e?.message || e) } });
714
+ return reply(id, { error: { code: -32603, message: String(e?.message || e) } });
631
715
  }
632
716
  }
633
717
 
634
- readline.createInterface({ input: process.stdin }).on('line', (line) => {
718
+ const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
719
+ rl.on('line', (line) => {
635
720
  if (!line.trim()) return;
636
721
  let msg;
637
- try { msg = JSON.parse(line); } catch { return send({ id: null, error: { code: -32700, message: 'Parse error' } }); }
638
- handle(msg);
722
+ try { msg = JSON.parse(line); } catch { return write(reply(null, { error: { code: -32700, message: 'Parse error' } })); }
723
+ if (Array.isArray(msg)) { // a batch (older protocol versions): answer with one array
724
+ if (!msg.length) return write(reply(null, { error: { code: -32600, message: 'Invalid request: empty batch' } }));
725
+ const out = msg.map(handle).filter(Boolean);
726
+ if (out.length) write(out);
727
+ return;
728
+ }
729
+ const r = handle(msg);
730
+ if (r) write(r);
639
731
  });
732
+ // the editor closed the connection: exit instead of lingering as an orphan process
733
+ // (flush first: on macOS and Windows, writes to a pipe are asynchronous)
734
+ rl.on('close', () => { if (stdoutClosed) process.exit(0); else process.stdout.write('', () => process.exit(0)); });