@svgrid/mcp 2.6.5 → 2.6.6

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,14 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * SvGrid MCP server (stdio).
4
+ *
5
+ * Exposes the SvGrid example sources, docs, and curated API reference as
6
+ * Model Context Protocol tools. Point an MCP-capable client (Claude
7
+ * Desktop, Claude Code, etc.) at this server to give the model accurate,
8
+ * version-pinned answers about SvGrid - no hallucinated APIs, no stale
9
+ * blog-post output.
10
+ *
11
+ * Run with:
12
+ * npx @svgrid/mcp
13
+ */
14
+ export {};
package/dist/index.js CHANGED
@@ -15,8 +15,11 @@ import { createRequire } from 'node:module';
15
15
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
16
16
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
17
17
  import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
18
- import { apiReference, docs, examples } from './data.js';
18
+ import { apiReference, apiSurface, docs, examples } from './data.js';
19
19
  import { projectTools, handleProjectTool } from './project-tools.js';
20
+ import { checkSvGridCode } from './validate.js';
21
+ import { compileWithSvelte } from './compile-svelte.js';
22
+ import { rankDocs } from './search.js';
20
23
  import { checkLicenseKey, introspectDrizzle, introspectJson, scaffold, summarizeVerify, verifyScaffold, } from '@svgrid/enterprise/studio';
21
24
  /**
22
25
  * Soft commercial gate. Uses the SAME classifier as the browser
@@ -65,37 +68,6 @@ function countBy(rows, key) {
65
68
  }
66
69
  return Object.fromEntries([...counts].sort((a, b) => b[1] - a[1]));
67
70
  }
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
- }
99
71
  // Report the real package version to MCP clients (read from package.json, which
100
72
  // ships in the tarball at ../package.json relative to the built dist/index.js),
101
73
  // so serverInfo.version never drifts from the published version.
@@ -189,6 +161,21 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
189
161
  description: 'Return the curated SvGrid public-API surface, grouped by category (components, headless, scheduler, data ops, export, row models, features, virtualization, accessibility, utilities).',
190
162
  inputSchema: { type: 'object', properties: {} },
191
163
  },
164
+ {
165
+ name: 'check_svgrid_code',
166
+ description: 'Verify SvGrid code BEFORE handing it to the user. Checks the source against the real exported surface of the installed version - <SvGrid> prop names, ColumnDef keys, grid API methods, importable symbols and theme files - plus Svelte 5 runes rules, and compiles it with the Svelte compiler when one is reachable. Returns line-numbered diagnostics with the exact replacement for each. Run this on every .svelte or .ts file you write that uses SvGrid, then fix what it reports and run it again.',
167
+ inputSchema: {
168
+ type: 'object',
169
+ properties: {
170
+ source: { type: 'string', description: 'The full file contents to check.' },
171
+ filename: {
172
+ type: 'string',
173
+ description: 'File name, used to pick the rules that apply. Defaults to "Component.svelte". Use the real name when you have one (e.g. "src/routes/+page.svelte", "state.svelte.ts").',
174
+ },
175
+ },
176
+ required: ['source'],
177
+ },
178
+ },
192
179
  {
193
180
  name: 'introspect_source',
194
181
  description: 'SvGrid Studio (commercial): infer an EntitySchema from a data source. Pass a Drizzle schema file (kind:"drizzle", source: the file text) or sample rows (kind:"json", rows, name). Returns a DRAFT EntitySchema to review/refine before scaffolding code.',
@@ -339,75 +326,27 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
339
326
  if (!query) {
340
327
  return { isError: true, content: [{ type: 'text', text: 'query is required' }] };
341
328
  }
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 = [];
349
- for (const d of docs) {
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);
382
- }
383
- if (score > 0)
384
- scored.push({ d, score, complete: matched === tokens.length });
385
- }
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));
329
+ // Ranking lives in ./search.ts so the remote server answers the same
330
+ // query the same way.
331
+ const { hits, total, partial } = rankDocs(docs, query, limit);
332
+ return withDocs(JSON.stringify({ query, total, shown: hits.length, partial: partial || undefined, hits }, null, 2));
407
333
  }
408
334
  case 'get_api_reference': {
409
335
  return withDocs(JSON.stringify(apiReference, null, 2));
410
336
  }
337
+ case 'check_svgrid_code': {
338
+ const a = (args ?? {});
339
+ if (typeof a.source !== 'string' || !a.source.trim()) {
340
+ return errText('source (the file contents to check) is required');
341
+ }
342
+ const result = await checkSvGridCode(a.source, apiSurface, {
343
+ filename: a.filename,
344
+ compile: compileWithSvelte,
345
+ });
346
+ // No docs footer: this output is a work list, and a marketing line at the
347
+ // end of it is noise the model has to read past on every iteration.
348
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
349
+ }
411
350
  case 'introspect_source': {
412
351
  const a = (args ?? {});
413
352
  try {
@@ -0,0 +1,16 @@
1
+ type ToolResult = {
2
+ content: Array<{
3
+ type: 'text';
4
+ text: string;
5
+ }>;
6
+ isError?: boolean;
7
+ };
8
+ export type ProjectTool = {
9
+ name: string;
10
+ description: string;
11
+ inputSchema: Record<string, unknown>;
12
+ };
13
+ export declare const projectTools: ProjectTool[];
14
+ /** Handle a studio_* project tool. Returns undefined if `name` isn't one of ours. */
15
+ export declare function handleProjectTool(name: string, args: Record<string, unknown>): ToolResult | undefined;
16
+ export {};
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Doc ranking, shared by the stdio server and the remote Worker so both answer
3
+ * the same query the same way.
4
+ *
5
+ * Pure and dependency-free: it takes the docs it should rank rather than
6
+ * importing them, because the Worker loads its corpus from static assets at
7
+ * request time while the stdio server has it bundled.
8
+ */
9
+ export type RankableDoc = {
10
+ slug: string;
11
+ title: string;
12
+ section: string;
13
+ markdown: string;
14
+ };
15
+ export type DocHit = {
16
+ slug: string;
17
+ title: string;
18
+ section: string;
19
+ score: number;
20
+ excerpt: string;
21
+ };
22
+ export declare function occurrences(haystack: string, needle: string): number;
23
+ /** Split a query into distinct lowercase terms, dropping one-character noise. */
24
+ export declare function queryTokens(query: string): string[];
25
+ /** A window of text around the first needle that appears, for search results. */
26
+ export declare function excerptAround(markdown: string, needles: string[]): string;
27
+ /**
28
+ * Rank docs for a query. Whole-phrase hits outrank term hits, and the slug is
29
+ * weighted heavily: a page named after the topic is the reference page for it,
30
+ * where a recipe that merely mentions it is not. Pages matching every term win
31
+ * outright; partial matches are only returned when nothing covers the query.
32
+ */
33
+ export declare function rankDocs<T extends RankableDoc>(docs: readonly T[], query: string, limit: number): {
34
+ hits: DocHit[];
35
+ total: number;
36
+ partial: boolean;
37
+ };
package/dist/search.js ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Doc ranking, shared by the stdio server and the remote Worker so both answer
3
+ * the same query the same way.
4
+ *
5
+ * Pure and dependency-free: it takes the docs it should rank rather than
6
+ * importing them, because the Worker loads its corpus from static assets at
7
+ * request time while the stdio server has it bundled.
8
+ */
9
+ export function occurrences(haystack, needle) {
10
+ if (!needle)
11
+ return 0;
12
+ let n = 0;
13
+ let i = haystack.indexOf(needle);
14
+ while (i !== -1) {
15
+ n += 1;
16
+ i = haystack.indexOf(needle, i + needle.length);
17
+ }
18
+ return n;
19
+ }
20
+ /** Split a query into distinct lowercase terms, dropping one-character noise. */
21
+ export function queryTokens(query) {
22
+ const tokens = [...new Set(query.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length > 1))];
23
+ return tokens.length ? tokens : [query.toLowerCase().trim()];
24
+ }
25
+ /** A window of text around the first needle that appears, for search results. */
26
+ export function excerptAround(markdown, needles) {
27
+ const lower = markdown.toLowerCase();
28
+ let idx = -1;
29
+ for (const n of needles) {
30
+ idx = lower.indexOf(n);
31
+ if (idx >= 0)
32
+ break;
33
+ }
34
+ if (idx < 0)
35
+ idx = 0;
36
+ const start = Math.max(0, idx - 60);
37
+ const end = Math.min(markdown.length, idx + 180);
38
+ return markdown.slice(start, end).replace(/\s+/g, ' ').trim();
39
+ }
40
+ /**
41
+ * Rank docs for a query. Whole-phrase hits outrank term hits, and the slug is
42
+ * weighted heavily: a page named after the topic is the reference page for it,
43
+ * where a recipe that merely mentions it is not. Pages matching every term win
44
+ * outright; partial matches are only returned when nothing covers the query.
45
+ */
46
+ export function rankDocs(docs, query, limit) {
47
+ const phrase = query.toLowerCase();
48
+ const tokens = queryTokens(query);
49
+ const scored = [];
50
+ for (const d of docs) {
51
+ const title = d.title.toLowerCase();
52
+ const markdown = d.markdown.toLowerCase();
53
+ const headings = (d.markdown.match(/^#{1,6}\s+.*$/gm) ?? []).join('\n').toLowerCase();
54
+ const slugWords = d.slug.toLowerCase().replace(/[/-]/g, ' ');
55
+ let score = 0;
56
+ if (title.includes(phrase))
57
+ score += 100;
58
+ if (slugWords.includes(phrase))
59
+ score += 60;
60
+ if (headings.includes(phrase))
61
+ score += 30;
62
+ if (markdown.includes(phrase))
63
+ score += 20;
64
+ let matched = 0;
65
+ for (const t of tokens) {
66
+ const inTitle = title.includes(t);
67
+ const inHeading = headings.includes(t);
68
+ const count = occurrences(markdown, t);
69
+ if (inTitle || inHeading || count > 0)
70
+ matched += 1;
71
+ if (inTitle)
72
+ score += 25;
73
+ if (slugWords.includes(t))
74
+ score += 10;
75
+ if (inHeading)
76
+ score += 8;
77
+ // Capped so a long page cannot outrank a precise one on bulk alone.
78
+ score += Math.min(count, 5);
79
+ }
80
+ if (score > 0)
81
+ scored.push({ d, score, complete: matched === tokens.length });
82
+ }
83
+ const complete = scored.filter((s) => s.complete);
84
+ const pool = complete.length ? complete : scored;
85
+ const ranked = pool
86
+ .sort((a, b) => b.score - a.score || a.d.slug.localeCompare(b.d.slug))
87
+ .slice(0, limit);
88
+ return {
89
+ hits: ranked.map((s) => ({
90
+ slug: s.d.slug,
91
+ title: s.d.title,
92
+ section: s.d.section,
93
+ score: s.score,
94
+ excerpt: excerptAround(s.d.markdown, [phrase, ...tokens]),
95
+ })),
96
+ total: pool.length,
97
+ partial: complete.length === 0 && scored.length > 0,
98
+ };
99
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * check_svgrid_code - the verification half of the MCP server.
3
+ *
4
+ * Retrieval tools (docs, demos, API listing) only ever give a model something
5
+ * to read. This one closes the loop: the model writes SvGrid code, this checks
6
+ * it against the REAL exported surface of the installed version and hands back
7
+ * diagnostics it can act on. A wrong prop name, a symbol imported from the
8
+ * wrong package, or Svelte 4 syntax in a Svelte 5 component all come back with
9
+ * the exact replacement rather than "hmm, that didn't work" three edits later.
10
+ *
11
+ * Two layers:
12
+ * 1. Static analysis (this file) - pure, no dependencies, no filesystem, so
13
+ * it runs identically in the Node stdio server and in a Worker.
14
+ * 2. The Svelte compiler - injected by the caller when it is available
15
+ * (`compile` option). Node has it; the Worker does not, and skips.
16
+ *
17
+ * The API surface is generated from the workspace sources at build time by
18
+ * scripts/api-surface.mjs, so it cannot drift from what the package exports.
19
+ */
20
+ export type Severity = 'error' | 'warning' | 'info';
21
+ export type Diagnostic = {
22
+ /** Stable rule id, e.g. "svgrid/unknown-prop". */
23
+ rule: string;
24
+ severity: Severity;
25
+ /** 1-based line in the checked source. */
26
+ line: number;
27
+ message: string;
28
+ /** The concrete edit that fixes it, when there is one. */
29
+ fix?: string;
30
+ /** Doc slug or demo id to read for the full story. */
31
+ see?: string;
32
+ };
33
+ export type TypeMember = {
34
+ readonly name: string;
35
+ readonly optional: boolean;
36
+ readonly type: string;
37
+ };
38
+ export type ApiSurface = {
39
+ readonly gridVersion: string;
40
+ readonly enterpriseVersion: string;
41
+ readonly grid: {
42
+ readonly values: readonly string[];
43
+ readonly types: readonly string[];
44
+ readonly subpaths: readonly string[];
45
+ };
46
+ readonly enterprise: {
47
+ readonly values: readonly string[];
48
+ readonly types: readonly string[];
49
+ readonly subpaths: readonly string[];
50
+ };
51
+ readonly props: readonly TypeMember[];
52
+ readonly columnDef: readonly TypeMember[];
53
+ /** Methods on the free `SvGridApi`. */
54
+ readonly apiMethods: readonly string[];
55
+ /** Methods `installEnterprise(api)` adds on top. */
56
+ readonly enterpriseApiMethods: readonly string[];
57
+ readonly themes: readonly string[];
58
+ readonly features: readonly string[];
59
+ readonly rowModels: readonly string[];
60
+ };
61
+ export type CheckResult = {
62
+ ok: boolean;
63
+ /** Which version the code was checked against. */
64
+ checkedAgainst: string;
65
+ compiler: 'svelte' | 'unavailable' | 'not-svelte';
66
+ counts: {
67
+ errors: number;
68
+ warnings: number;
69
+ info: number;
70
+ };
71
+ diagnostics: Diagnostic[];
72
+ summary: string;
73
+ };
74
+ /** A compile pass supplied by the host when a Svelte compiler is reachable. */
75
+ export type CompileFn = (source: string, filename: string) => Promise<{
76
+ available: boolean;
77
+ diagnostics: Diagnostic[];
78
+ }>;
79
+ /**
80
+ * Blank out comments and the inside of strings, keeping every offset and line
81
+ * break intact. All structural scanning runs on this copy so a prop name in a
82
+ * doc comment or a `<SvGrid>` inside a template string never trips a rule.
83
+ */
84
+ export declare function blankOut(src: string): string;
85
+ /**
86
+ * Closest known name to `word`, or null when nothing is near enough. A
87
+ * case-only difference always wins; otherwise the edit distance has to be
88
+ * small relative to the word so "foo" does not "resolve" to "bar".
89
+ */
90
+ export declare function nearest(word: string, candidates: readonly string[]): string | null;
91
+ /** Run every static rule. Exported for tests and for hosts that skip compiling. */
92
+ export declare function checkStatic(source: string, surface: ApiSurface, filename?: string): Diagnostic[];
93
+ /**
94
+ * Check a snippet and report what a model should do next. `compile` is the
95
+ * optional second gate: when the host can reach a Svelte compiler, real parse
96
+ * errors are merged in with the static findings.
97
+ */
98
+ export declare function checkSvGridCode(source: string, surface: ApiSurface, opts?: {
99
+ filename?: string;
100
+ compile?: CompileFn;
101
+ }): Promise<CheckResult>;