@svgrid/mcp 2.6.4 → 2.6.5

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/dist/index.js CHANGED
@@ -43,6 +43,59 @@ const DOCS_FOOTER = '\n\nSvGrid reference: full docs & 370+ live demos at https:
43
43
  function withDocs(text) {
44
44
  return { content: [{ type: 'text', text: text + DOCS_FOOTER }] };
45
45
  }
46
+ /**
47
+ * Shorten a blurb for a listing. Demo blurbs run to ~240 chars and 373 of them
48
+ * is most of a context window, so the listings carry a one-line version and
49
+ * get_example_source still returns the full text.
50
+ */
51
+ function trimBlurb(text, max = 120) {
52
+ const s = String(text ?? '').replace(/\s+/g, ' ').trim();
53
+ if (s.length <= max)
54
+ return s;
55
+ const cut = s.slice(0, max);
56
+ const space = cut.lastIndexOf(' ');
57
+ return (space > 40 ? cut.slice(0, space) : cut) + '...';
58
+ }
59
+ /** { value: count } over a key, ordered by descending count. */
60
+ function countBy(rows, key) {
61
+ const counts = new Map();
62
+ for (const row of rows) {
63
+ const k = key(row) || 'Other';
64
+ counts.set(k, (counts.get(k) ?? 0) + 1);
65
+ }
66
+ return Object.fromEntries([...counts].sort((a, b) => b[1] - a[1]));
67
+ }
68
+ function occurrences(haystack, needle) {
69
+ if (!needle)
70
+ return 0;
71
+ let n = 0;
72
+ let i = haystack.indexOf(needle);
73
+ while (i !== -1) {
74
+ n += 1;
75
+ i = haystack.indexOf(needle, i + needle.length);
76
+ }
77
+ return n;
78
+ }
79
+ /** Split a query into distinct lowercase terms, dropping one-character noise. */
80
+ function queryTokens(query) {
81
+ const tokens = [...new Set(query.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length > 1))];
82
+ return tokens.length ? tokens : [query.toLowerCase().trim()];
83
+ }
84
+ /** A window of text around the first needle that appears, for search results. */
85
+ function excerptAround(markdown, needles) {
86
+ const lower = markdown.toLowerCase();
87
+ let idx = -1;
88
+ for (const n of needles) {
89
+ idx = lower.indexOf(n);
90
+ if (idx >= 0)
91
+ break;
92
+ }
93
+ if (idx < 0)
94
+ idx = 0;
95
+ const start = Math.max(0, idx - 60);
96
+ const end = Math.min(markdown.length, idx + 180);
97
+ return markdown.slice(start, end).replace(/\s+/g, ' ').trim();
98
+ }
46
99
  // Report the real package version to MCP clients (read from package.json, which
47
100
  // ships in the tarball at ../package.json relative to the built dist/index.js),
48
101
  // so serverInfo.version never drifts from the published version.
@@ -67,8 +120,21 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
67
120
  tools: [
68
121
  {
69
122
  name: 'list_examples',
70
- description: 'List every SvGrid example demo with id, title, and short blurb. Use to discover what is available before fetching source.',
71
- inputSchema: { type: 'object', properties: {} },
123
+ description: 'Find SvGrid example demos. Returns id, title, category and a one-line blurb (not source). Call with no arguments for a category index plus the first page; filter with `query` and/or `category` to find a specific demo, then call get_example_source with its id.',
124
+ inputSchema: {
125
+ type: 'object',
126
+ properties: {
127
+ query: {
128
+ type: 'string',
129
+ description: 'Free-text filter over id, title, blurb and category, e.g. "kanban" or "server side".',
130
+ },
131
+ category: {
132
+ type: 'string',
133
+ description: 'Exact category, e.g. "Kanban" or "Inputs". Call with no arguments to see the available categories.',
134
+ },
135
+ limit: { type: 'number', description: 'Max results, default 25, max 100.', default: 25 },
136
+ },
137
+ },
72
138
  },
73
139
  {
74
140
  name: 'get_example_source',
@@ -81,8 +147,21 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
81
147
  },
82
148
  {
83
149
  name: 'list_docs',
84
- description: 'List every documentation page with slug and title. Slugs use forward slashes, e.g. "help/columns/column-definitions".',
85
- inputSchema: { type: 'object', properties: {} },
150
+ description: 'Find SvGrid documentation pages. Returns slug, title and section. Call with no arguments for a section index plus the first page; filter with `query` and/or `section`, then call get_doc with a slug. Slugs use forward slashes, e.g. "help/columns/column-definitions". To search page CONTENT rather than titles, use search_docs.',
151
+ inputSchema: {
152
+ type: 'object',
153
+ properties: {
154
+ query: {
155
+ type: 'string',
156
+ description: 'Free-text filter over slug, title and section, e.g. "column" or "export".',
157
+ },
158
+ section: {
159
+ type: 'string',
160
+ description: 'Exact section, e.g. "Columns" or "Server data". Call with no arguments to see the available sections.',
161
+ },
162
+ limit: { type: 'number', description: 'Max results, default 30, max 100.', default: 30 },
163
+ },
164
+ },
86
165
  },
87
166
  {
88
167
  name: 'get_doc',
@@ -95,7 +174,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
95
174
  },
96
175
  {
97
176
  name: 'search_docs',
98
- description: 'Case-insensitive substring search across all SvGrid docs. Returns matching slugs with a one-line excerpt around the first hit.',
177
+ description: 'Ranked full-text search across all SvGrid docs. Matches the query term by term (so "row virtualization" finds a page phrasing it either way) and returns the best pages first, each with a relevance score and an excerpt around the hit. Use this to find grounding before writing SvGrid code.',
99
178
  inputSchema: {
100
179
  type: 'object',
101
180
  properties: {
@@ -160,9 +239,41 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
160
239
  if (projectResult)
161
240
  return projectResult;
162
241
  switch (name) {
242
+ // Returning all 373 demos cost ~31k tokens on the call this tool's own
243
+ // description invites a model to start with. Filtered and capped instead,
244
+ // and a bare call answers with the category index to drill into.
163
245
  case 'list_examples': {
164
- const items = examples.map((e) => ({ id: e.id, title: e.title, blurb: e.blurb, path: e.path }));
165
- return withDocs(JSON.stringify(items, null, 2));
246
+ const a = (args ?? {});
247
+ const limit = Math.max(1, Math.min(100, Number(a.limit ?? 25)));
248
+ const q = String(a.query ?? '').trim().toLowerCase();
249
+ const category = String(a.category ?? '').trim().toLowerCase();
250
+ const pool = examples.filter((e) => {
251
+ if (category && e.category.toLowerCase() !== category)
252
+ return false;
253
+ if (q && !`${e.id} ${e.title} ${e.blurb} ${e.category}`.toLowerCase().includes(q))
254
+ return false;
255
+ return true;
256
+ });
257
+ const shown = pool.slice(0, limit);
258
+ const body = {
259
+ total: pool.length,
260
+ shown: shown.length,
261
+ examples: shown.map((e) => ({
262
+ id: e.id,
263
+ title: e.title,
264
+ category: e.category,
265
+ blurb: trimBlurb(e.blurb),
266
+ })),
267
+ };
268
+ if (!q && !category)
269
+ body.categories = countBy(examples, (e) => e.category);
270
+ if (shown.length < pool.length) {
271
+ body.hint = `Showing ${shown.length} of ${pool.length}. Narrow with \`query\` or \`category\`, or raise \`limit\` (max 100).`;
272
+ }
273
+ if (!pool.length) {
274
+ body.hint = 'No match. Drop `category`, or try a broader `query`.';
275
+ }
276
+ return withDocs(JSON.stringify(body, null, 2));
166
277
  }
167
278
  case 'get_example_source': {
168
279
  const id = String(args?.id ?? '');
@@ -179,9 +290,36 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
179
290
  ],
180
291
  };
181
292
  }
293
+ // Same shape as list_examples, for the same reason: all 370 pages was
294
+ // ~12.7k tokens. `path` is dropped from the listing because it is always
295
+ // "docs/<slug>.md" and get_doc takes the slug.
182
296
  case 'list_docs': {
183
- const items = docs.map((d) => ({ slug: d.slug, title: d.title, path: d.path }));
184
- return withDocs(JSON.stringify(items, null, 2));
297
+ const a = (args ?? {});
298
+ const limit = Math.max(1, Math.min(100, Number(a.limit ?? 30)));
299
+ const q = String(a.query ?? '').trim().toLowerCase();
300
+ const section = String(a.section ?? '').trim().toLowerCase();
301
+ const pool = docs.filter((d) => {
302
+ if (section && d.section.toLowerCase() !== section)
303
+ return false;
304
+ if (q && !`${d.slug} ${d.title} ${d.section}`.toLowerCase().includes(q))
305
+ return false;
306
+ return true;
307
+ });
308
+ const shown = pool.slice(0, limit);
309
+ const body = {
310
+ total: pool.length,
311
+ shown: shown.length,
312
+ docs: shown.map((d) => ({ slug: d.slug, title: d.title, section: d.section })),
313
+ };
314
+ if (!q && !section)
315
+ body.sections = countBy(docs, (d) => d.section);
316
+ if (shown.length < pool.length) {
317
+ body.hint = `Showing ${shown.length} of ${pool.length}. Narrow with \`query\` or \`section\`, raise \`limit\` (max 100), or use search_docs to search page content.`;
318
+ }
319
+ if (!pool.length) {
320
+ body.hint = 'No match. Drop `section`, or try search_docs to search page content instead of titles.';
321
+ }
322
+ return withDocs(JSON.stringify(body, null, 2));
185
323
  }
186
324
  case 'get_doc': {
187
325
  const slug = String(args?.slug ?? '');
@@ -201,21 +339,71 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
201
339
  if (!query) {
202
340
  return { isError: true, content: [{ type: 'text', text: 'query is required' }] };
203
341
  }
204
- const q = query.toLowerCase();
205
- const hits = [];
342
+ // Previously this matched the query only as one contiguous substring and
343
+ // returned hits in directory order, so the canonical page for a topic
344
+ // routinely lost to an incidental mention. Score per doc, rank, and treat
345
+ // the query as terms so word order and joining words stop mattering.
346
+ const phrase = query.toLowerCase();
347
+ const tokens = queryTokens(query);
348
+ const scored = [];
206
349
  for (const d of docs) {
207
- const lower = d.markdown.toLowerCase();
208
- const idx = lower.indexOf(q);
209
- if (idx >= 0) {
210
- const start = Math.max(0, idx - 60);
211
- const end = Math.min(d.markdown.length, idx + q.length + 120);
212
- const excerpt = d.markdown.slice(start, end).replace(/\s+/g, ' ').trim();
213
- hits.push({ slug: d.slug, title: d.title, excerpt });
214
- if (hits.length >= limit)
215
- break;
350
+ const title = d.title.toLowerCase();
351
+ const markdown = d.markdown.toLowerCase();
352
+ const headings = (d.markdown.match(/^#{1,6}\s+.*$/gm) ?? []).join('\n').toLowerCase();
353
+ // "help/rows/kanban-board" -> "help rows kanban board". The slug is the
354
+ // strongest canonical signal there is: a page named after the topic is
355
+ // the reference page for it, where a recipe merely mentioning it is not.
356
+ const slugWords = d.slug.toLowerCase().replace(/[/-]/g, ' ');
357
+ let score = 0;
358
+ // Whole-phrase hits are the strongest signal, title strongest of all.
359
+ if (title.includes(phrase))
360
+ score += 100;
361
+ if (slugWords.includes(phrase))
362
+ score += 60;
363
+ if (headings.includes(phrase))
364
+ score += 30;
365
+ if (markdown.includes(phrase))
366
+ score += 20;
367
+ let matched = 0;
368
+ for (const t of tokens) {
369
+ const inTitle = title.includes(t);
370
+ const inHeading = headings.includes(t);
371
+ const count = occurrences(markdown, t);
372
+ if (inTitle || inHeading || count > 0)
373
+ matched += 1;
374
+ if (inTitle)
375
+ score += 25;
376
+ if (slugWords.includes(t))
377
+ score += 10;
378
+ if (inHeading)
379
+ score += 8;
380
+ // Capped so a long page cannot outrank a precise one on bulk alone.
381
+ score += Math.min(count, 5);
216
382
  }
383
+ if (score > 0)
384
+ scored.push({ d, score, complete: matched === tokens.length });
217
385
  }
218
- return withDocs(JSON.stringify({ query, total: hits.length, hits }, null, 2));
386
+ // Prefer pages containing every term; fall back to partial matches only
387
+ // when nothing covers the whole query.
388
+ const complete = scored.filter((s) => s.complete);
389
+ const ranked = (complete.length ? complete : scored)
390
+ .sort((a, b) => b.score - a.score || a.d.slug.localeCompare(b.d.slug))
391
+ .slice(0, limit);
392
+ const hits = ranked.map((s) => ({
393
+ slug: s.d.slug,
394
+ title: s.d.title,
395
+ section: s.d.section,
396
+ score: s.score,
397
+ excerpt: excerptAround(s.d.markdown, [phrase, ...tokens]),
398
+ }));
399
+ const matchedTotal = complete.length || scored.length;
400
+ return withDocs(JSON.stringify({
401
+ query,
402
+ total: matchedTotal,
403
+ shown: hits.length,
404
+ partial: complete.length === 0 && scored.length > 0 ? true : undefined,
405
+ hits,
406
+ }, null, 2));
219
407
  }
220
408
  case 'get_api_reference': {
221
409
  return withDocs(JSON.stringify(apiReference, null, 2));
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "type": "commercial",
6
6
  "url": "https://svgrid.com/pricing"
7
7
  },
8
- "version": "2.6.4",
8
+ "version": "2.6.5",
9
9
  "description": "Model Context Protocol (MCP) server for SvGrid. Exposes example sources, docs, and API reference to AI assistants.",
10
10
  "license": "MIT",
11
11
  "author": "jQWidgets <sales@jqwidgets.com>",
package/server.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "name": "com.svgrid/svgrid",
4
4
  "title": "SvGrid",
5
5
  "description": "Version-pinned Svelte 5 data grid APIs, 373 demo sources, and SvelteKit app scaffolding.",
6
- "version": "2.6.1",
6
+ "version": "2.6.5",
7
7
  "websiteUrl": "https://svgrid.com/docs/help/mcp-server/",
8
8
  "repository": {
9
9
  "url": "https://github.com/sv-grid/sv-grid",
@@ -15,7 +15,7 @@
15
15
  "registryType": "npm",
16
16
  "registryBaseUrl": "https://registry.npmjs.org",
17
17
  "identifier": "@svgrid/mcp",
18
- "version": "2.6.1",
18
+ "version": "2.6.5",
19
19
  "runtimeHint": "npx",
20
20
  "transport": {
21
21
  "type": "stdio"