@svgrid/mcp 2.6.7 → 3.0.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.
@@ -0,0 +1,362 @@
1
+ /**
2
+ * The always-on tools: find SvGrid information, read it, verify code against it.
3
+ *
4
+ * These four replace nine. The nine were one-per-endpoint - `list_examples`,
5
+ * `get_example_source`, `list_docs`, `get_doc`, `search_docs`,
6
+ * `get_api_reference` - which is the shape you get from wrapping an API rather
7
+ * than a workflow. An agent looking for "how do I pin a column" had to guess
8
+ * whether that lived in docs, in a demo, or in the API surface, and usually
9
+ * paid two or three calls to find out.
10
+ *
11
+ * `svgrid_search` answers that question in one call across all three corpora,
12
+ * and returns enough that `svgrid_get` is often unnecessary. Every listing
13
+ * response is capped and paged, because returning all 375 examples cost ~31k
14
+ * tokens on the call the old description invited a model to start with.
15
+ */
16
+ import { apiReference, apiSurface, docs, examples } from './data.js';
17
+ import { meaningfulTerms, rankDocs } from './search.js';
18
+ const DOCS_FOOTER = '\n\nSvGrid reference: full docs & 375 live demos at https://svgrid.com/docs';
19
+ const text = (body) => ({ content: [{ type: 'text', text: body }] });
20
+ const withDocs = (body) => text(body + DOCS_FOOTER);
21
+ const fail = (message) => ({
22
+ isError: true,
23
+ content: [{ type: 'text', text: message }],
24
+ });
25
+ const json = (body) => JSON.stringify(body, null, 2);
26
+ function trimBlurb(value, max = 120) {
27
+ const flat = value.replace(/\s+/g, ' ').trim();
28
+ return flat.length <= max ? flat : flat.slice(0, max - 1).trimEnd() + '…';
29
+ }
30
+ function countBy(rows, key) {
31
+ const out = {};
32
+ for (const row of rows) {
33
+ const k = key(row);
34
+ out[k] = (out[k] ?? 0) + 1;
35
+ }
36
+ return out;
37
+ }
38
+ /**
39
+ * Every API name, flattened once and deduplicated by name.
40
+ *
41
+ * A name can legitimately appear in more than one place - `columns` is both a
42
+ * `<SvGrid>` prop and a column option - and listing it twice wasted a result
43
+ * slot and made the output read like a bug. Merged into one entry that names
44
+ * every place it lives, which is more useful than either row alone.
45
+ */
46
+ const apiNames = (() => {
47
+ const merged = new Map();
48
+ const add = (name, group) => {
49
+ const groups = merged.get(name) ?? new Set();
50
+ groups.add(group);
51
+ merged.set(name, groups);
52
+ };
53
+ for (const [group, names] of Object.entries(apiReference))
54
+ for (const name of names)
55
+ add(name, group);
56
+ for (const p of apiSurface.props)
57
+ add(p.name, 'SvGrid prop');
58
+ for (const c of apiSurface.columnDef)
59
+ add(c.name, 'column option');
60
+ for (const m of apiSurface.apiMethods)
61
+ add(m, 'grid api method');
62
+ return [...merged].map(([name, groups]) => ({ name, group: [...groups].join(', ') }));
63
+ })();
64
+ export const CORE_TOOLS = [
65
+ {
66
+ name: 'svgrid_search',
67
+ title: 'Search SvGrid',
68
+ description: 'Search SvGrid docs, example demos and the API surface in ONE call. Start here for any "how do I ..." question - it covers all three, so you do not have to guess which one holds the answer. Call with no arguments for an index of doc sections, demo categories and API groups. Returns ids/slugs you can pass to svgrid_get.',
69
+ inputSchema: {
70
+ type: 'object',
71
+ properties: {
72
+ query: {
73
+ type: 'string',
74
+ description: 'What you are trying to do, e.g. "pin a column", "server side pagination", "kanban swimlanes". Omit for the index.',
75
+ },
76
+ kind: {
77
+ type: 'string',
78
+ enum: ['all', 'docs', 'examples', 'api'],
79
+ description: 'Restrict the search. Default "all".',
80
+ default: 'all',
81
+ },
82
+ detail: {
83
+ type: 'string',
84
+ enum: ['concise', 'full'],
85
+ description: '"concise" (default) returns titles plus a short excerpt. "full" returns the matching doc excerpts at length - more tokens, fewer follow-up calls.',
86
+ default: 'concise',
87
+ },
88
+ section: {
89
+ type: 'string',
90
+ description: 'Restrict docs to one section, exactly, e.g. "Help". Call with no arguments to see the sections.',
91
+ },
92
+ category: {
93
+ type: 'string',
94
+ description: 'Restrict demos to one category, exactly, e.g. "Kanban". Call with no arguments to see the categories.',
95
+ },
96
+ limit: { type: 'number', description: 'Max results per corpus. Default 10, max 50.', default: 10 },
97
+ },
98
+ required: [],
99
+ },
100
+ },
101
+ {
102
+ name: 'svgrid_get',
103
+ title: 'Read a doc or demo',
104
+ description: 'Fetch one thing in full by reference: a doc slug ("help/columns/column-definitions"), a demo id ("11-stock-market"), or "api" for the curated API reference. Use svgrid_search first to find the reference.',
105
+ inputSchema: {
106
+ type: 'object',
107
+ properties: {
108
+ ref: {
109
+ type: 'string',
110
+ description: 'A doc slug, a demo id, or "api". The kind is inferred; pass `kind` to force it.',
111
+ },
112
+ kind: {
113
+ type: 'string',
114
+ enum: ['auto', 'doc', 'example', 'api'],
115
+ description: 'Override the inferred kind. Default "auto".',
116
+ default: 'auto',
117
+ },
118
+ detail: {
119
+ type: 'string',
120
+ enum: ['concise', 'full'],
121
+ description: '"full" (default) returns the whole thing; "concise" truncates long content.',
122
+ default: 'full',
123
+ },
124
+ },
125
+ required: ['ref'],
126
+ },
127
+ },
128
+ ];
129
+ /** Rank examples by a term match over id/title/blurb, optionally within a category. */
130
+ function searchExamples(query, limit, category) {
131
+ const pool = category
132
+ ? examples.filter((e) => e.category.toLowerCase() === category.toLowerCase())
133
+ : examples;
134
+ const terms = meaningfulTerms(query);
135
+ if (!terms.length) {
136
+ // No query at all is a browse - list the category. A query that reduces to
137
+ // nothing usable ("how do I") is NOT a browse: returning the first ten
138
+ // demos would look like an answer and be pure coincidence.
139
+ return query ? { total: 0, hits: [] } : { total: pool.length, hits: pool.slice(0, limit) };
140
+ }
141
+ // Rank by how much of the query a demo matches, rather than demanding all of
142
+ // it. Requiring every term looked precise and quietly returned NOTHING for
143
+ // "how do I sort by two columns" - no demo contains "two". A title match
144
+ // still outweighs a passing mention, so "kanban board" stays on
145
+ // 343-kanban-board rather than drifting to whatever mentions boards.
146
+ const scored = pool
147
+ .map((e) => {
148
+ const title = `${e.id} ${e.title}`.toLowerCase();
149
+ const hay = `${title} ${e.blurb} ${e.category}`.toLowerCase();
150
+ const inTitle = terms.filter((t) => title.includes(t)).length;
151
+ const anywhere = terms.filter((t) => hay.includes(t)).length;
152
+ if (!anywhere)
153
+ return null;
154
+ return { e, score: inTitle * 10 + anywhere };
155
+ })
156
+ .filter((x) => x !== null)
157
+ .sort((a, b) => b.score - a.score || a.e.id.localeCompare(b.e.id));
158
+ return { total: scored.length, hits: scored.slice(0, limit).map((x) => x.e) };
159
+ }
160
+ function searchApi(query, limit) {
161
+ const terms = meaningfulTerms(query);
162
+ if (!terms.length)
163
+ return { total: 0, hits: [] };
164
+ // Ranked, not just filtered: an exact name beats a prefix, which beats a
165
+ // substring, and matching more of the query beats matching less of it.
166
+ const scored = apiNames
167
+ .map((a) => {
168
+ const name = a.name.toLowerCase();
169
+ let score = 0;
170
+ for (const t of terms) {
171
+ if (name === t)
172
+ score += 100;
173
+ else if (name.startsWith(t))
174
+ score += 10;
175
+ else if (name.includes(t))
176
+ score += 3;
177
+ }
178
+ return score > 0 ? { a, score } : null;
179
+ })
180
+ .filter((x) => x !== null)
181
+ .sort((x, y) => y.score - x.score || x.a.name.length - y.a.name.length);
182
+ return { total: scored.length, hits: scored.slice(0, limit).map((x) => x.a) };
183
+ }
184
+ export function handleCoreTool(name, args) {
185
+ if (name === 'svgrid_search')
186
+ return search(args);
187
+ if (name === 'svgrid_get')
188
+ return get(args);
189
+ return undefined;
190
+ }
191
+ function search(args) {
192
+ const query = String(args.query ?? '').trim();
193
+ const kind = String(args.kind ?? 'all');
194
+ const detail = String(args.detail ?? 'concise');
195
+ const section = String(args.section ?? '').trim();
196
+ const category = String(args.category ?? '').trim();
197
+ const rawLimit = Number(args.limit);
198
+ const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(50, Math.floor(rawLimit)) : 10;
199
+ // A bare call is an index request, not an error. It is the cheapest way for
200
+ // a model to orient itself, and it costs a fraction of listing everything.
201
+ // A section or category with no query is a legitimate browse, so only a
202
+ // completely bare call is an index request.
203
+ if (!query && !section && !category) {
204
+ return withDocs(json({
205
+ docSections: countBy(docs, (d) => d.section),
206
+ exampleCategories: countBy(examples, (e) => e.category),
207
+ apiGroups: Object.fromEntries(Object.entries(apiReference).map(([g, names]) => [g, names.length])),
208
+ totals: { docs: docs.length, examples: examples.length, apiNames: apiNames.length },
209
+ hint: 'Pass `query` to search. Then svgrid_get with a doc slug, a demo id, or "api".',
210
+ }));
211
+ }
212
+ const body = { query };
213
+ if (section)
214
+ body.section = section;
215
+ if (category)
216
+ body.category = category;
217
+ if (kind === 'all' || kind === 'docs') {
218
+ const pool = section
219
+ ? docs.filter((d) => d.section.toLowerCase() === section.toLowerCase())
220
+ : docs;
221
+ // An exact section with no query is a browse: list the section rather than
222
+ // running a search for the empty string.
223
+ const ranked = query
224
+ ? rankDocs(pool, query, limit)
225
+ : {
226
+ hits: pool.slice(0, limit).map((d) => ({ slug: d.slug, title: d.title, section: d.section })),
227
+ total: pool.length,
228
+ partial: false,
229
+ };
230
+ // `partial` means NO page matched all the terms - these are pages that
231
+ // matched some. `rankDocs` has always computed it and this tool used to
232
+ // throw it away, so "kubernetes ingress controller" came back as 36
233
+ // confident-looking doc hits (matching only "controller") with nothing to
234
+ // say they were loose. Plausible noise reads like an answer, and a model
235
+ // will cite it.
236
+ //
237
+ // Surfaced, and trimmed: a partial match is a lead, not a result set.
238
+ const partial = 'partial' in ranked && ranked.partial === true;
239
+ const shown = partial ? ranked.hits.slice(0, 3) : ranked.hits;
240
+ body.docs = {
241
+ total: ranked.total,
242
+ partial: partial || undefined,
243
+ hits: shown.map((h) => detail === 'full' ? h : { ...h, excerpt: trimBlurb(String(h.excerpt ?? ''), 200) }),
244
+ };
245
+ if (partial) {
246
+ body.hint = `No page matched all of "${query}". These matched some of it - treat them as leads, not answers.`;
247
+ }
248
+ if (section && !pool.length) {
249
+ body.hint = `No section "${section}". Call with no arguments to see the sections.`;
250
+ }
251
+ }
252
+ if (kind === 'all' || kind === 'examples') {
253
+ const { hits, total } = searchExamples(query, limit, category);
254
+ body.examples = {
255
+ total,
256
+ hits: hits.map((e) => ({
257
+ id: e.id,
258
+ title: e.title,
259
+ category: e.category,
260
+ blurb: detail === 'full' ? e.blurb : trimBlurb(e.blurb),
261
+ })),
262
+ };
263
+ }
264
+ if (kind === 'all' || kind === 'api') {
265
+ const { hits, total } = searchApi(query, limit);
266
+ body.api = { total, hits };
267
+ }
268
+ const empty = !body.docs?.total &&
269
+ !body.examples?.total &&
270
+ !body.api?.total;
271
+ if (!empty) {
272
+ // A partial-match warning outranks the generic next-step hint: it is the
273
+ // one thing the caller must not miss, and it is set above.
274
+ const docs = body.docs;
275
+ if (!docs?.partial) {
276
+ body.hint = 'Pass a doc slug, demo id, or "api" to svgrid_get for the full text.';
277
+ }
278
+ return withDocs(json(body));
279
+ }
280
+ // Say WHICH input was wrong, and what the valid values are. "Try fewer terms"
281
+ // is useless advice when the caller passed no terms at all - it sends a model
282
+ // round the same loop instead of correcting the one thing it got wrong.
283
+ const knownSections = Object.keys(countBy(docs, (d) => d.section));
284
+ const knownCategories = Object.keys(countBy(examples, (e) => e.category));
285
+ const badSection = section && !knownSections.some((s) => s.toLowerCase() === section.toLowerCase());
286
+ const badCategory = category && !knownCategories.some((c) => c.toLowerCase() === category.toLowerCase());
287
+ if (badSection) {
288
+ body.hint = `No doc section "${section}". Sections: ${knownSections.join(', ')}.`;
289
+ }
290
+ else if (badCategory) {
291
+ body.hint = `No demo category "${category}". Categories: ${knownCategories.join(', ')}.`;
292
+ }
293
+ else if (!query) {
294
+ body.hint = 'That filter matched nothing. Call with no arguments to see what exists.';
295
+ }
296
+ else {
297
+ const usable = meaningfulTerms(query);
298
+ body.hint = usable.length
299
+ ? `Nothing matched all of: ${usable.join(', ')}. Every term must appear - try fewer, or more general ones.`
300
+ : `"${query}" has no terms specific enough to search on. Use a feature or API name, e.g. "pinned columns".`;
301
+ }
302
+ return withDocs(json(body));
303
+ }
304
+ const MAX_CONCISE = 4000;
305
+ /**
306
+ * Hard ceiling on any single response, ~20k tokens.
307
+ *
308
+ * `detail:"full"` had no cap at all, so `svgrid_get` on the largest demo
309
+ * returned 51,549 chars - about 12,900 tokens - and nothing stopped a bigger
310
+ * one from being added tomorrow. A tool that can silently eat a fifth of the
311
+ * context window is a tool an agent learns to avoid.
312
+ */
313
+ const MAX_RESPONSE = 80_000;
314
+ /** Truncate on a line boundary: cutting mid-token leaves `ret` and a puzzle. */
315
+ function truncate(body, max, hint) {
316
+ if (body.length <= max)
317
+ return body;
318
+ const cut = body.slice(0, max);
319
+ const lastBreak = cut.lastIndexOf('\n');
320
+ const kept = lastBreak > max * 0.8 ? cut.slice(0, lastBreak) : cut;
321
+ return `${kept}\n\n… truncated at ${kept.length} of ${body.length} chars. ${hint}`;
322
+ }
323
+ function get(args) {
324
+ const ref = String(args.ref ?? '').trim();
325
+ const kind = String(args.kind ?? 'auto');
326
+ const detail = String(args.detail ?? 'full');
327
+ if (!ref)
328
+ return fail('ref is required: a doc slug, a demo id, or "api".');
329
+ const clip = (body) => detail === 'concise'
330
+ ? truncate(body, MAX_CONCISE, 'Call again with detail:"full" for the rest.')
331
+ : truncate(body, MAX_RESPONSE, 'This is the whole of what fits in one response.');
332
+ if (kind === 'api' || (kind === 'auto' && /^api(:|$)/.test(ref))) {
333
+ const group = ref.includes(':') ? ref.split(':')[1] : '';
334
+ if (group) {
335
+ const names = apiReference[group];
336
+ if (!names) {
337
+ return fail(`No API group "${group}". Available: ${Object.keys(apiReference).join(', ')}.`);
338
+ }
339
+ return withDocs(json({ group, names }));
340
+ }
341
+ return withDocs(json(apiReference));
342
+ }
343
+ if (kind === 'doc' || kind === 'auto') {
344
+ const doc = docs.find((d) => d.slug === ref);
345
+ if (doc)
346
+ return withDocs(clip(doc.markdown));
347
+ if (kind === 'doc') {
348
+ return fail(`No doc with slug "${ref}". Use svgrid_search to find one.`);
349
+ }
350
+ }
351
+ const example = examples.find((e) => e.id === ref);
352
+ if (example) {
353
+ return text(clip(`// ${example.path}\n// ${example.title} - ${example.blurb}\n\n${example.source}`));
354
+ }
355
+ // A miss is a chance to point somewhere useful rather than just say no.
356
+ const near = [
357
+ ...docs.filter((d) => d.slug.includes(ref)).slice(0, 3).map((d) => d.slug),
358
+ ...examples.filter((e) => e.id.includes(ref)).slice(0, 3).map((e) => e.id),
359
+ ];
360
+ return fail(`No doc, demo or API group matches "${ref}".` +
361
+ (near.length ? ` Did you mean: ${near.join(', ')}?` : ' Call svgrid_search to find one.'));
362
+ }