@sakaniui/react 0.8.7 → 0.8.8

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.
@@ -2,37 +2,126 @@
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
5
+ * npx -y -p @sakaniui/react sakaniui-mcp [--project <dir>]
6
6
  *
7
- * Zero dependencies: speaks newline-delimited JSON-RPC (MCP stdio transport) and answers from the
8
- * files shipped in this package -- dist/manifest.json (generated from the source on every build)
9
- * and AGENTS.md -- so it always matches the installed version.
7
+ * Zero dependencies: speaks newline-delimited JSON-RPC (MCP stdio transport).
10
8
  *
11
- * Tools: search_components, get_component, list_components, get_tokens, check_usage, get_guide.
9
+ * Read-only by construction. It reads four fixed files (package.json, dist/manifest.json,
10
+ * AGENTS.md, CHANGELOG.md) from a @sakaniui/react install and writes nothing, runs nothing and
11
+ * opens no network connection. No tool takes a path; every tool argument is validated.
12
+ *
13
+ * Version-aware. `npx` may fetch a newer release than the project uses, so the server looks for
14
+ * the project's own installed @sakaniui/react (walking up from --project or the working
15
+ * directory). If it has a manifest, answers come from that copy. If it is older than the
16
+ * manifest era, answers come from this copy but every component is checked against its `since`
17
+ * release and flagged when the project does not have it yet.
12
18
  */
13
19
  import fs from 'node:fs';
14
20
  import path from 'node:path';
15
21
  import readline from 'node:readline';
16
22
  import { fileURLToPath } from 'node:url';
17
23
 
18
- const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
19
- const readPkgFile = (p) => fs.readFileSync(path.join(pkgRoot, p), 'utf8');
24
+ const ownRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
25
+
26
+ /* ---------- reading an install (fixed file names only) ---------- */
27
+ const readText = (root, rel) => { try { return fs.readFileSync(path.join(root, rel), 'utf8'); } catch { return null; } };
28
+ const readJson = (root, rel) => { try { return JSON.parse(readText(root, rel)); } catch { return null; } };
20
29
 
21
- let manifest;
22
- try {
23
- manifest = JSON.parse(readPkgFile('dist/manifest.json'));
24
- } catch {
30
+ function loadInstall(root) {
31
+ const pkg = readJson(root, 'package.json');
32
+ if (!pkg || pkg.name !== '@sakaniui/react') return null;
33
+ return { root, version: pkg.version, manifest: readJson(root, 'dist/manifest.json'), guide: readText(root, 'AGENTS.md'), changelog: readText(root, 'CHANGELOG.md') };
34
+ }
35
+
36
+ function findProjectInstall() {
37
+ const i = process.argv.indexOf('--project');
38
+ let dir = path.resolve(i > -1 && process.argv[i + 1] ? process.argv[i + 1] : process.cwd());
39
+ for (let depth = 0; depth < 12; depth++) {
40
+ const candidate = path.join(dir, 'node_modules', '@sakaniui', 'react');
41
+ if (fs.existsSync(path.join(candidate, 'package.json'))) return loadInstall(candidate);
42
+ const up = path.dirname(dir);
43
+ if (up === dir) break;
44
+ dir = up;
45
+ }
46
+ return null;
47
+ }
48
+
49
+ const bundled = loadInstall(ownRoot);
50
+ if (!bundled || !bundled.manifest) {
25
51
  process.stderr.write('sakaniui-mcp: dist/manifest.json not found. Run `npm run build:lib` (or install a published version).\n');
26
52
  process.exit(1);
27
53
  }
28
- const guide = (() => { try { return readPkgFile('AGENTS.md'); } catch { return ''; } })();
29
- const byName = new Map(manifest.components.map((c) => [c.name.toLowerCase(), c]));
54
+ const project = findProjectInstall();
55
+
56
+ /**
57
+ * Answer from the project's own copy when it can (it is what the code will run against) -- but only
58
+ * if its manifest is at least as complete as this one. Older manifest schemas are known to omit
59
+ * things that exist (components, tokens), and "not in the manifest" must mean "does not exist".
60
+ * In that case the newer data is used and checked against the project's version instead.
61
+ */
62
+ const schemaOf = (i) => (i && i.manifest && Number.isInteger(i.manifest.schemaVersion) ? i.manifest.schemaVersion : 0);
63
+ const active = project && project.manifest && schemaOf(project) >= schemaOf(bundled) ? project : bundled;
64
+ const manifest = active.manifest;
65
+ const guide = active.guide || bundled.guide || '';
66
+ const changelog = active.changelog || bundled.changelog || '';
67
+ const projectVersion = project ? project.version : null;
68
+
69
+ /* ---------- versions ---------- */
70
+ const SEMVER = /^\d+\.\d+\.\d+/;
71
+ const parse = (v) => String(v).split('-')[0].split('.').map(Number);
72
+ const cmp = (a, b) => { const x = parse(a), y = parse(b); for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] - y[i]; return 0; };
73
+
74
+ const refVersion = manifest.version;
75
+ const answeringFromProject = active === project;
76
+
77
+ function versionNote(installed = projectVersion) {
78
+ if (answeringFromProject) return null;
79
+ if (!installed) {
80
+ return `The project's installed @sakaniui/react version could not be detected, so this describes ${refVersion}. Confirm it (npm ls @sakaniui/react) and call check_compatibility with installedVersion before suggesting a component.`;
81
+ }
82
+ if (installed !== refVersion) {
83
+ return `The project has @sakaniui/react ${installed} installed; this reference describes ${refVersion}. Entries marked availableInInstalled: false were added after ${installed}. Props can also differ between releases: check get_changelog.`;
84
+ }
85
+ return null;
86
+ }
87
+
88
+ /** Is this component in the project's install? null = cannot tell. */
89
+ function inInstalled(c, installed = projectVersion) {
90
+ if (!installed || !c.since) return null;
91
+ return cmp(c.since, installed) <= 0;
92
+ }
30
93
 
31
94
  /* ---------- helpers ---------- */
32
- const brief = (c) => ({ name: c.name, kind: c.kind, category: c.category, import: c.import, summary: c.summary, requiredProps: c.contract.requiredProps });
95
+ const byName = new Map(manifest.components.map((c) => [c.name.toLowerCase(), c]));
96
+ const brief = (c) => {
97
+ const out = { name: c.name, kind: c.kind, category: c.category, import: c.import, since: c.since, summary: c.summary, requiredProps: c.contract.requiredProps };
98
+ if (inInstalled(c) === false) out.availableInInstalled = false;
99
+ return out;
100
+ };
33
101
  const text = (obj) => ({ content: [{ type: 'text', text: typeof obj === 'string' ? obj : JSON.stringify(obj, null, 2) }] });
34
102
  const fail = (msg) => ({ isError: true, content: [{ type: 'text', text: msg }] });
35
103
 
104
+ const MAX_STR = 200;
105
+ function strArg(args, key, { required = false } = {}) {
106
+ const v = args[key];
107
+ if (v === undefined || v === null) return required ? { err: `"${key}" is required.` } : { value: undefined };
108
+ if (typeof v !== 'string') return { err: `"${key}" must be a string.` };
109
+ if (v.length > MAX_STR) return { err: `"${key}" is too long (max ${MAX_STR} characters).` };
110
+ return { value: v };
111
+ }
112
+ function enumArg(args, key, allowed) {
113
+ const v = args[key];
114
+ if (v === undefined || v === null) return { value: undefined };
115
+ if (!allowed.includes(v)) return { err: `"${key}" must be one of: ${allowed.join(', ')}.` };
116
+ return { value: v };
117
+ }
118
+ const verArg = (args, key) => {
119
+ const s = strArg(args, key);
120
+ if (s.err || s.value === undefined) return s;
121
+ return SEMVER.test(s.value) ? s : { err: `"${key}" must be a version like 0.8.7.` };
122
+ };
123
+ const firstError = (...rs) => rs.find((r) => r.err);
124
+
36
125
  function score(c, terms) {
37
126
  const hay = `${c.name} ${c.category ?? ''} ${c.summary} ${c.props.map((p) => p.name).join(' ')}`.toLowerCase();
38
127
  let s = 0;
@@ -65,88 +154,165 @@ function closest(name) {
65
154
 
66
155
  const NATIVE = /^(className|style|id|ref|key|children|role|tabIndex|title|hidden|lang|dir|slot|on[A-Z].*|aria-.*|data-.*)$/;
67
156
 
157
+ /** Release notes: [{version, body}] newest first, from the CHANGELOG that matches the answering install. */
158
+ function releases() {
159
+ const parts = changelog.split(/^## (\d+\.\d+\.\d+[^\n]*)\n/m);
160
+ const out = [];
161
+ for (let i = 1; i < parts.length; i += 2) out.push({ version: parts[i].trim(), body: (parts[i + 1] || '').trim() });
162
+ return out.sort((a, b) => cmp(b.version, a.version));
163
+ }
164
+
165
+ const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
166
+
68
167
  /* ---------- tools ---------- */
69
168
  const tools = {
70
169
  search_components: {
71
- description: 'Find SakaniUI components/blocks by what you need (e.g. "date picker", "sidebar navigation", "pricing"). Returns the best matches with import path and required props.',
170
+ description: 'Find SakaniUI components/blocks by what you need (e.g. "date picker", "sidebar navigation", "pricing"). Returns the best matches with import path, the release that added them and required props.',
72
171
  inputSchema: { type: 'object', properties: { query: { type: 'string' }, kind: { type: 'string', enum: ['component', 'block'] }, limit: { type: 'number' } }, required: ['query'] },
73
- run({ query, kind, limit = 8 }) {
74
- const terms = String(query).toLowerCase().split(/\s+/).filter(Boolean);
172
+ run(args) {
173
+ const q = strArg(args, 'query', { required: true }); const k = enumArg(args, 'kind', ['component', 'block']);
174
+ const e = firstError(q, k); if (e) return fail(e.err);
175
+ const limit = Number.isInteger(args.limit) ? Math.min(Math.max(args.limit, 1), 25) : 8;
176
+ const terms = q.value.toLowerCase().split(/\s+/).filter(Boolean);
75
177
  const hits = manifest.components
76
- .filter((c) => !kind || c.kind === kind)
178
+ .filter((c) => !k.value || c.kind === k.value)
77
179
  .map((c) => [score(c, terms), c])
78
180
  .filter(([s]) => s > 0)
79
181
  .sort((a, b) => b[0] - a[0])
80
182
  .slice(0, limit)
81
183
  .map(([, c]) => brief(c));
82
- return hits.length ? text(hits) : text(`No match for "${query}". If nothing fits, follow AGENTS.md section 3 ("When nothing fits"): compose from tokens and say so. Try list_components to browse.`);
184
+ const note = versionNote();
185
+ const body = hits.length ? hits : `No match for "${q.value}". If nothing fits, follow AGENTS.md section 3 ("When nothing fits"): compose from tokens and say so. Try list_components to browse.`;
186
+ return text(note && hits.length ? { versionNote: note, results: hits } : body);
83
187
  },
84
188
  },
85
189
  get_component: {
86
- description: 'Full contract for one component or block: import path, every prop with type / allowed values / default / required, and the accessible-name rule. Use this before writing JSX.',
190
+ description: 'Full contract for one component or block: import path, the release that added it, every prop with type / allowed values / default / required, and the accessible-name rule. Use this before writing JSX.',
87
191
  inputSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] },
88
- run({ name }) {
89
- const c = byName.get(String(name).toLowerCase());
90
- if (!c) return fail(`No component named "${name}". Did you mean: ${closest(String(name)).join(', ') || '(none close)'}? Use search_components.`);
91
- return text({ ...c, note: 'Native HTML attributes (className, style, aria-*, event handlers) are also accepted on the root element.' });
192
+ run(args) {
193
+ const n = strArg(args, 'name', { required: true }); if (n.err) return fail(n.err);
194
+ const c = byName.get(n.value.toLowerCase());
195
+ if (!c) return fail(`No component named "${n.value}". Did you mean: ${closest(n.value).join(', ') || '(none close)'}? Use search_components.`);
196
+ const out = { ...c, note: 'Native HTML attributes (className, style, aria-*, event handlers) are also accepted on the root element.' };
197
+ if (inInstalled(c) === false) out.availableInInstalled = false;
198
+ const note = versionNote();
199
+ if (note) out.versionNote = note;
200
+ 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).`;
201
+ return text(out);
92
202
  },
93
203
  },
94
204
  list_components: {
95
205
  description: 'List all components and blocks (name + one-line summary), optionally filtered by kind or category (e.g. Forms, Core, Marketing).',
96
206
  inputSchema: { type: 'object', properties: { kind: { type: 'string', enum: ['component', 'block'] }, category: { type: 'string' } } },
97
- run({ kind, category }) {
207
+ run(args) {
208
+ const k = enumArg(args, 'kind', ['component', 'block']); const cat = strArg(args, 'category');
209
+ const e = firstError(k, cat); if (e) return fail(e.err);
98
210
  const list = manifest.components
99
- .filter((c) => (!kind || c.kind === kind) && (!category || (c.category ?? '').toLowerCase().includes(String(category).toLowerCase())))
100
- .map((c) => ({ name: c.name, kind: c.kind, category: c.category, summary: c.summary }));
211
+ .filter((c) => (!k.value || c.kind === k.value) && (!cat.value || (c.category ?? '').toLowerCase().includes(cat.value.toLowerCase())))
212
+ .map((c) => { const o = { name: c.name, kind: c.kind, category: c.category, since: c.since, summary: c.summary }; if (inInstalled(c) === false) o.availableInInstalled = false; return o; });
101
213
  const categories = [...new Set(manifest.components.map((c) => c.category).filter(Boolean))].sort();
102
- return text({ count: list.length, categories, components: list });
214
+ const out = { version: refVersion, count: list.length, categories, components: list };
215
+ const note = versionNote(); if (note) out.versionNote = note;
216
+ return text(out);
103
217
  },
104
218
  },
105
219
  get_tokens: {
106
- description: 'Design tokens (CSS custom properties). Pass a group (color, space, radius, shadow, font, motion, ...) and/or a substring to filter. Only these tokens exist.',
220
+ description: 'Design tokens (CSS custom properties). Pass a group (color, space, radius, shadow, font, motion, glass, surface, liquid, ...) and/or a substring to filter. Only these tokens exist.',
107
221
  inputSchema: { type: 'object', properties: { group: { type: 'string' }, query: { type: 'string' } } },
108
- run({ group, query }) {
222
+ run(args) {
223
+ const g = strArg(args, 'group'); const q = strArg(args, 'query');
224
+ const e = firstError(g, q); if (e) return fail(e.err);
109
225
  const groups = manifest.tokens.groups;
110
- if (group && !groups[group]) return fail(`Unknown token group "${group}". Groups: ${Object.keys(groups).join(', ')}`);
226
+ if (g.value && !Object.hasOwn(groups, g.value)) return fail(`Unknown token group "${g.value}". Groups: ${Object.keys(groups).join(', ')}`);
227
+ const needle = q.value?.toLowerCase();
111
228
  const out = {};
112
- for (const [g, toks] of Object.entries(groups)) {
113
- if (group && g !== group) continue;
114
- const sel = Object.entries(toks).filter(([k, v]) => !query || k.includes(String(query).toLowerCase()) || String(v).toLowerCase().includes(String(query).toLowerCase()));
115
- if (sel.length) out[g] = Object.fromEntries(sel);
229
+ for (const [name, toks] of Object.entries(groups)) {
230
+ if (g.value && name !== g.value) continue;
231
+ const sel = Object.entries(toks).filter(([k, v]) => !needle || k.includes(needle) || String(v).toLowerCase().includes(needle));
232
+ if (sel.length) out[name] = Object.fromEntries(sel);
116
233
  }
117
234
  return text(Object.keys(out).length ? out : `No tokens match. Groups: ${Object.keys(groups).join(', ')}`);
118
235
  },
119
236
  },
120
237
  check_usage: {
121
- description: 'Validate props you are about to use against the contract: reports missing required props, values outside an enum, and props that do not exist. Run it on each component you write.',
122
- inputSchema: { type: 'object', properties: { component: { type: 'string' }, props: { type: 'object', description: 'prop name -> value (use true/false/strings; for non-literal values pass any placeholder)' } }, required: ['component'] },
123
- run({ component, props = {} }) {
124
- const c = byName.get(String(component).toLowerCase());
125
- if (!c) return fail(`No component named "${component}". Did you mean: ${closest(String(component)).join(', ') || '(none close)'}?`);
238
+ description: 'Validate props you are about to use against the contract: reports missing required props, values outside an enum, props that do not exist, and components the project\'s installed version does not have. Run it on each component you write.',
239
+ inputSchema: { type: 'object', properties: { component: { type: 'string' }, props: { type: 'object', description: 'prop name -> value (use true/false/numbers/strings; for non-literal values pass any placeholder)' } }, required: ['component'] },
240
+ run(args) {
241
+ const n = strArg(args, 'component', { required: true }); if (n.err) return fail(n.err);
242
+ const props = args.props === undefined || args.props === null ? {} : args.props;
243
+ if (typeof props !== 'object' || Array.isArray(props)) return fail('"props" must be an object of prop name -> value.');
244
+ if (Object.keys(props).length > 200) return fail('"props" has too many entries (max 200).');
245
+ const c = byName.get(n.value.toLowerCase());
246
+ if (!c) return fail(`No component named "${n.value}". Did you mean: ${closest(n.value).join(', ') || '(none close)'}?`);
126
247
  const known = new Map(c.props.map((p) => [p.name, p]));
127
248
  const errors = [];
128
249
  const warnings = [];
129
- for (const r of c.contract.requiredProps) if (!(r in props)) errors.push(`Missing required prop "${r}".`);
250
+ if (inInstalled(c) === false) errors.push(`${c.name} was added in ${c.since}; the project has ${projectVersion}. It does not exist there.`);
251
+ for (const r of c.contract.requiredProps) if (!Object.hasOwn(props, r)) errors.push(`Missing required prop "${r}".`);
130
252
  for (const [k, v] of Object.entries(props)) {
131
253
  const p = known.get(k);
132
254
  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; }
133
- if (p.values && typeof v === 'string' && !p.values.includes(v)) errors.push(`"${k}" = "${v}" is not allowed. Use one of: ${p.values.join(', ')}.`);
255
+ if (p.values && (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') && !p.values.includes(v)) {
256
+ errors.push(`"${k}" = ${JSON.stringify(v)} is not allowed. Use one of: ${p.values.map((x) => JSON.stringify(x)).join(', ')}.`);
257
+ }
134
258
  }
135
259
  if (c.contract.requiresAccessibleName && !errors.length) warnings.push('This control has no visible text of its own: keep its accessible-name prop set.');
136
- return text({ component: c.name, ok: errors.length === 0, errors, warnings });
260
+ const out = { component: c.name, ok: errors.length === 0, errors, warnings };
261
+ const note = versionNote(); if (note) out.versionNote = note;
262
+ return text(out);
263
+ },
264
+ },
265
+ check_compatibility: {
266
+ description: 'Compare the version this reference describes with the version installed in the project, and list what the project does not have yet. Call this first when you cannot tell which @sakaniui/react version the project uses (pass installedVersion, from `npm ls @sakaniui/react` or package.json).',
267
+ inputSchema: { type: 'object', properties: { installedVersion: { type: 'string', description: 'e.g. 0.7.0' } } },
268
+ run(args) {
269
+ const v = verArg(args, 'installedVersion'); if (v.err) return fail(v.err.err);
270
+ const installed = v.value ? v.value.match(SEMVER)[0] : projectVersion;
271
+ const source = v.value ? 'provided' : projectVersion ? 'detected' : 'unknown';
272
+ if (!installed) {
273
+ return text({ reference: refVersion, installed: null, source, advice: 'The project version is unknown. Run `npm ls @sakaniui/react`, then call this tool again with installedVersion.' });
274
+ }
275
+ const missing = manifest.components.filter((c) => c.since && cmp(c.since, installed) > 0).map((c) => ({ name: c.name, since: c.since }));
276
+ const newer = releases().filter((r) => cmp(r.version, installed) > 0).map((r) => r.version);
277
+ const relation = cmp(installed, refVersion) === 0 ? 'same' : cmp(installed, refVersion) < 0 ? 'project is older' : 'project is newer than this reference';
278
+ const advice = relation === 'same' ? 'The reference matches the installed version.'
279
+ : relation === 'project is older' ? `Do not suggest the components listed under missingInInstalled, and check get_changelog (since ${installed}) before relying on newer props. Or upgrade: npm install @sakaniui/react@latest.`
280
+ : 'The project has a newer release than this reference. Run the server from the project root (or pass --project) so it answers from the installed copy.';
281
+ return text({ reference: refVersion, installed, source, relation, missingInInstalled: missing, releasesAfterInstalled: newer, advice });
282
+ },
283
+ },
284
+ get_changelog: {
285
+ description: 'Release notes that match a version. With `version`: that release. With `since`: every release after it, up to the version this reference describes. With neither: the releases between the project\'s installed version and this reference (or the latest release when they match or the project version is unknown).',
286
+ inputSchema: { type: 'object', properties: { version: { type: 'string' }, since: { type: 'string' } } },
287
+ run(args) {
288
+ const ver = verArg(args, 'version'); const since = verArg(args, 'since');
289
+ const e = firstError(ver, since); if (e) return fail(e.err);
290
+ const all = releases();
291
+ if (!all.length) return fail('No release notes are bundled with this install.');
292
+ let pick;
293
+ if (ver.value) {
294
+ pick = all.filter((r) => cmp(r.version, ver.value) === 0 && r.version.startsWith(ver.value.match(SEMVER)[0]));
295
+ if (!pick.length) return fail(`No release notes for ${ver.value}. Releases: ${all.map((r) => r.version).join(', ')}`);
296
+ } else {
297
+ const from = since.value ?? (projectVersion && cmp(projectVersion, refVersion) < 0 ? projectVersion : null);
298
+ pick = from ? all.filter((r) => cmp(r.version, from) > 0 && cmp(r.version, refVersion) <= 0) : all.slice(0, 1);
299
+ if (!pick.length) return text(`No releases after ${from}. This reference describes ${refVersion}.`);
300
+ }
301
+ return text(pick.map((r) => `## ${r.version}\n\n${r.body}`).join('\n\n'));
137
302
  },
138
303
  },
139
304
  get_guide: {
140
305
  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.',
141
306
  inputSchema: { type: 'object', properties: { section: { type: 'string' } } },
142
- run({ section }) {
307
+ run(args) {
308
+ const s0 = strArg(args, 'section'); if (s0.err) return fail(s0.err);
143
309
  if (!guide) return fail('AGENTS.md is not available in this install.');
144
- const parts = guide.split(/^(?=## )/m);
145
- const heads = parts.filter((p) => p.startsWith('## ')).map((p) => p.split('\n')[0].replace(/^## /, ''));
146
- if (!section) return text({ sections: heads, hint: 'Call again with a number (e.g. "5") or heading text.' });
147
- const s = String(section).toLowerCase().trim();
148
- const hit = parts.filter((p) => p.startsWith('## ')).filter((p) => { const h = p.split('\n')[0].replace(/^## /, '').toLowerCase(); return h === s || h.startsWith(s + '.') || h.startsWith(s + ' ') || h.includes(s); });
149
- return hit.length ? text(hit.join('\n')) : fail(`No section "${section}". Sections: ${heads.join(' | ')}`);
310
+ const parts = guide.split(/^(?=## )/m).filter((p) => p.startsWith('## '));
311
+ const heads = parts.map((p) => p.split('\n')[0].replace(/^## /, ''));
312
+ if (!s0.value) return text({ version: refVersion, sections: heads, hint: 'Call again with a number (e.g. "5") or heading text.' });
313
+ const s = s0.value.toLowerCase().trim();
314
+ const hit = parts.filter((p) => { const h = p.split('\n')[0].replace(/^## /, '').toLowerCase(); return h === s || h.startsWith(s + '.') || h.startsWith(s + ' ') || h.includes(s); });
315
+ return hit.length ? text(hit.join('\n')) : fail(`No section "${s0.value}". Sections: ${heads.join(' | ')}`);
150
316
  },
151
317
  },
152
318
  };
@@ -155,28 +321,33 @@ const tools = {
155
321
  const send = (msg) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', ...msg }) + '\n');
156
322
 
157
323
  function handle(msg) {
324
+ if (!msg || typeof msg !== 'object' || Array.isArray(msg)) return send({ id: null, error: { code: -32600, message: 'Invalid request' } });
158
325
  const { id, method, params } = msg;
159
326
  const isRequest = id !== undefined && id !== null;
160
327
  try {
161
328
  switch (method) {
162
- case 'initialize':
329
+ case 'initialize': {
330
+ const note = versionNote();
163
331
  return send({ id, result: {
164
332
  protocolVersion: params?.protocolVersion || '2025-06-18',
165
333
  capabilities: { tools: {} },
166
- serverInfo: { name: 'sakaniui', version: manifest.version },
167
- instructions: `SakaniUI ${manifest.version}. 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.`,
334
+ serverInfo: { name: 'sakaniui', version: refVersion },
335
+ 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}` : ''}`,
168
336
  } });
337
+ }
169
338
  case 'ping':
170
339
  return send({ id, result: {} });
171
340
  case 'tools/list':
172
- return send({ id, result: { tools: Object.entries(tools).map(([name, t]) => ({ name, description: t.description, inputSchema: t.inputSchema })) } });
341
+ return send({ id, result: { tools: Object.entries(tools).map(([name, t]) => ({ name, description: t.description, inputSchema: t.inputSchema, annotations: READ_ONLY })) } });
173
342
  case 'tools/call': {
174
- const t = tools[params?.name];
175
- if (!t) return send({ id, error: { code: -32602, message: `Unknown tool: ${params?.name}` } });
176
- return send({ id, result: t.run(params.arguments || {}) });
343
+ const t = typeof params?.name === 'string' && Object.hasOwn(tools, params.name) ? tools[params.name] : null;
344
+ if (!t) return send({ id, error: { code: -32602, message: `Unknown tool: ${String(params?.name).slice(0, 60)}` } });
345
+ const args = params.arguments;
346
+ if (args !== undefined && args !== null && (typeof args !== 'object' || Array.isArray(args))) return send({ id, result: fail('Tool arguments must be an object.') });
347
+ return send({ id, result: t.run(args || {}) });
177
348
  }
178
349
  default:
179
- if (isRequest) send({ id, error: { code: -32601, message: `Method not found: ${method}` } });
350
+ if (isRequest) send({ id, error: { code: -32601, message: `Method not found: ${String(method).slice(0, 60)}` } });
180
351
  }
181
352
  } catch (e) {
182
353
  if (isRequest) send({ id, error: { code: -32603, message: String(e?.message || e) } });