@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.
- package/README.md +215 -195
- package/dist/core-tools.d.ts +74 -0
- package/dist/core-tools.js +362 -0
- package/dist/data.js +680 -531
- package/dist/index.d.ts +11 -5
- package/dist/index.js +244 -354
- package/dist/installed.d.ts +33 -0
- package/dist/installed.js +87 -0
- package/dist/preview.d.ts +88 -0
- package/dist/preview.js +211 -0
- package/dist/prompts.d.ts +31 -0
- package/dist/prompts.js +65 -0
- package/dist/resources.d.ts +19 -0
- package/dist/resources.js +84 -0
- package/dist/search.d.ts +11 -0
- package/dist/search.js +28 -0
- package/dist/studio-tools.d.ts +13 -0
- package/dist/studio-tools.js +227 -0
- package/dist/validate.d.ts +40 -0
- package/dist/validate.js +70 -5
- package/package.json +2 -2
- package/server.json +2 -2
|
@@ -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
|
+
}
|