@cdevhub/ngx-tw-mcp 0.4.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/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@cdevhub/ngx-tw-mcp",
3
+ "version": "0.4.0",
4
+ "description": "MCP server exposing the @cdevhub/ngx-tw component API, usage examples, and conventions to AI coding agents.",
5
+ "keywords": [
6
+ "mcp",
7
+ "model-context-protocol",
8
+ "angular",
9
+ "tailwindcss",
10
+ "ngx-tw",
11
+ "ai",
12
+ "claude"
13
+ ],
14
+ "author": "Iuga Ciprian",
15
+ "license": "MIT",
16
+ "homepage": "https://github.com/avs2001/ngx-tw#readme",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/avs2001/ngx-tw.git",
20
+ "directory": "projects/ngx-tw-mcp"
21
+ },
22
+ "bugs": {
23
+ "url": "https://github.com/avs2001/ngx-tw/issues"
24
+ },
25
+ "type": "module",
26
+ "bin": {
27
+ "ngx-tw-mcp": "./src/index.js"
28
+ },
29
+ "main": "./src/index.js",
30
+ "files": [
31
+ "src",
32
+ "index.json",
33
+ "README.md"
34
+ ],
35
+ "dependencies": {
36
+ "@modelcontextprotocol/sdk": "^1.29.0",
37
+ "zod": "^3.25.0 || ^4.0.0"
38
+ },
39
+ "engines": {
40
+ "node": "^22.22.3 || ^24.15.0 || >=26.0.0"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ }
45
+ }
package/src/index.js ADDED
@@ -0,0 +1,196 @@
1
+ #!/usr/bin/env node
2
+ // stdio MCP server for @cdevhub/ngx-tw.
3
+ //
4
+ // The server is deliberately thin. Everything it serves was baked into
5
+ // `index.json` at library build time, inside the monorepo where component
6
+ // source and demo app both exist — a consumer's node_modules has neither. So
7
+ // there is no parsing here, only reading.
8
+ //
9
+ // Tool surface is few-but-rich: `get_component` returns everything about one
10
+ // entry point in a single response, because the caller pays a round trip per
11
+ // call and a component's API, examples, and guidance are almost always wanted
12
+ // together.
13
+
14
+ import { readFileSync } from 'node:fs';
15
+ import { dirname, join } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
19
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
20
+ import { z } from 'zod';
21
+
22
+ import { searchComponents } from './search.js';
23
+
24
+ const here = dirname(fileURLToPath(import.meta.url));
25
+ const index = JSON.parse(readFileSync(join(here, '..', 'index.json'), 'utf8'));
26
+
27
+ /**
28
+ * Every response carries the library version it describes. If a consumer's
29
+ * `.mcp.json` pins an old MCP package, the caller can see the mismatch against
30
+ * their installed `@cdevhub/ngx-tw` rather than silently trusting the answer.
31
+ */
32
+ function reply(payload) {
33
+ return {
34
+ content: [{
35
+ type: 'text',
36
+ text: JSON.stringify({ libraryVersion: index.libraryVersion, ...payload }, null, 2),
37
+ }],
38
+ };
39
+ }
40
+
41
+ const notFound = (name) => reply({
42
+ error: `No entry point named "${name}".`,
43
+ hint: 'Call list_components for the full list, or search_components to find one by description.',
44
+ });
45
+
46
+ const server = new McpServer(
47
+ { name: 'ngx-tw', version: index.libraryVersion },
48
+ {
49
+ instructions:
50
+ `Authoritative API reference for @cdevhub/ngx-tw ${index.libraryVersion}, an Angular ` +
51
+ `component library for Tailwind CSS v4. Use it instead of guessing selectors, inputs, ` +
52
+ `or import paths — the data is extracted from library source at build time.\n\n` +
53
+ `Typical flow: search_components to find the right component, then get_component for its ` +
54
+ `full API and usage examples. Call get_conventions before writing styling code and ` +
55
+ `get_started when setting a project up for the first time.\n\n` +
56
+ `Note: <tw-icon> has no built-in icon set. Valid icon names are only those the consuming ` +
57
+ `application registered via provideTwIcons() / provideTwLucideIcons() — never assume one exists.`,
58
+ },
59
+ );
60
+
61
+ // ─── search_components ────────────────────────────────────────────────────
62
+ server.registerTool('search_components', {
63
+ title: 'Search components',
64
+ description:
65
+ 'Find ngx-tw entry points by free-text description of what you are trying to build. ' +
66
+ 'Matches names, aliases, summaries, and stated use cases, so vocabulary from other design ' +
67
+ 'systems works ("dropdown", "snackbar", "datagrid", "wizard"). Start here when you know the ' +
68
+ 'problem but not the component name, then call get_component for the winner.',
69
+ inputSchema: {
70
+ query: z.string().describe('What you are trying to build, e.g. "dropdown of actions" or "date range"'),
71
+ limit: z.number().int().min(1).max(25).optional().describe('Maximum results (default 10)'),
72
+ },
73
+ }, async ({ query, limit }) => {
74
+ const results = searchComponents(index, query, limit ?? 10);
75
+ return reply({
76
+ query,
77
+ results,
78
+ ...(results.length ? {} : { hint: 'No match — call list_components to see everything available.' }),
79
+ });
80
+ });
81
+
82
+ // ─── get_component ────────────────────────────────────────────────────────
83
+ server.registerTool('get_component', {
84
+ title: 'Get component',
85
+ description:
86
+ 'Everything about one entry point in a single response: every exported symbol (components, ' +
87
+ 'directives, types) with its selector and usage form, all inputs/outputs/models with types ' +
88
+ 'and defaults, content-projection slots, real usage examples from the documentation site, ' +
89
+ 'and guidance on when to use it and what to use instead. Call this before writing markup ' +
90
+ 'for a component — an entry point often exports several directives that work together.',
91
+ inputSchema: {
92
+ name: z.string().describe('Entry-point name, e.g. "badge", "date-range-picker"'),
93
+ },
94
+ }, async ({ name }) => {
95
+ const entry = index.entryPoints.find((e) => e.name === name.trim().toLowerCase());
96
+ if (!entry) return notFound(name);
97
+
98
+ return reply({
99
+ name: entry.name,
100
+ importPath: entry.importPath,
101
+ summary: entry.summary ?? null,
102
+ whenToUse: entry.whenToUse ?? [],
103
+ whenNotToUse: entry.whenNotToUse ?? [],
104
+ related: entry.related ?? [],
105
+ symbols: entry.symbols,
106
+ examples: entry.snippets,
107
+ });
108
+ });
109
+
110
+ // ─── list_components ──────────────────────────────────────────────────────
111
+ server.registerTool('list_components', {
112
+ title: 'List components',
113
+ description:
114
+ 'Every entry point in the library with its import path and one-line summary. Use it to see ' +
115
+ 'the whole surface at once when search comes back empty or you are unsure what exists.',
116
+ inputSchema: {},
117
+ }, async () => reply({
118
+ count: index.entryPoints.length,
119
+ components: index.entryPoints.map((e) => ({
120
+ name: e.name,
121
+ importPath: e.importPath,
122
+ summary: e.summary ?? null,
123
+ })),
124
+ }));
125
+
126
+ // ─── get_conventions ──────────────────────────────────────────────────────
127
+ server.registerTool('get_conventions', {
128
+ title: 'Get conventions',
129
+ description:
130
+ 'The library\'s styling and code conventions: semantic color tokens, the size/spacing scale, ' +
131
+ 'radius and shadow scales, focus-ring pattern, icon sizing, transition rules, and Angular ' +
132
+ 'idioms. Read this before writing styling code — components use semantic tokens ' +
133
+ '(bg-primary-500) and never raw palette colors (bg-blue-500), and a mismatch breaks theming.',
134
+ inputSchema: {
135
+ topic: z.string().optional().describe('Optional filter, e.g. "focus", "spacing", "color", "animation"'),
136
+ },
137
+ }, async ({ topic }) => reply({
138
+ topic: topic ?? null,
139
+ conventions: section(index.content.conventions, topic),
140
+ }));
141
+
142
+ // ─── get_started ──────────────────────────────────────────────────────────
143
+ server.registerTool('get_started', {
144
+ title: 'Get started',
145
+ description:
146
+ 'Installation and project setup: peer dependencies, the Tailwind v4 theme CSS import, the ' +
147
+ 'CDK overlay stylesheet, provider registration (theme, date adapter, icons, dialog, sheet, ' +
148
+ 'toast), and the icon registration model. Read this when wiring ngx-tw into a project for ' +
149
+ 'the first time or when a component renders unstyled.',
150
+ inputSchema: {
151
+ topic: z.string().optional().describe('Optional filter, e.g. "icons", "theming", "install", "forms"'),
152
+ },
153
+ }, async ({ topic }) => reply({
154
+ topic: topic ?? null,
155
+ gettingStarted: section(index.content.gettingStarted, topic),
156
+ }));
157
+
158
+ // ─── list_theme_tokens ────────────────────────────────────────────────────
159
+ server.registerTool('list_theme_tokens', {
160
+ title: 'List theme tokens',
161
+ description:
162
+ 'The design tokens the theme actually defines, with their values and the Tailwind utilities ' +
163
+ 'they generate. Use it to confirm a utility class exists before emitting it — the library ' +
164
+ 'defines bg-primary-500 and text-2xs but not bg-brand-500, and Tailwind fails silently on a ' +
165
+ 'class that resolves to nothing.',
166
+ inputSchema: {
167
+ kind: z.enum(['color', 'typography', 'font', 'duration', 'shadow', 'width'])
168
+ .optional().describe('Filter by token kind'),
169
+ group: z.enum(['surface', 'foreground', 'border', 'semantic-color', 'other'])
170
+ .optional().describe('Filter color tokens by role group'),
171
+ },
172
+ }, async ({ kind, group }) => {
173
+ const tokens = index.themeTokens
174
+ .filter((t) => (!kind || t.kind === kind) && (!group || t.group === group));
175
+ return reply({ count: tokens.length, tokens });
176
+ });
177
+
178
+ /**
179
+ * Narrow a markdown document to the `##` sections matching a topic. Returns the
180
+ * whole document when nothing matches, because a silently empty answer is worse
181
+ * than an over-long one.
182
+ */
183
+ function section(markdown, topic) {
184
+ if (!topic) return markdown;
185
+
186
+ const needle = topic.toLowerCase();
187
+ const parts = markdown.split(/\n(?=## )/);
188
+ const hits = parts.filter((part) => {
189
+ const heading = part.slice(0, part.indexOf('\n')).toLowerCase();
190
+ return heading.includes(needle) || part.toLowerCase().includes(needle);
191
+ });
192
+
193
+ return hits.length ? hits.join('\n') : markdown;
194
+ }
195
+
196
+ await server.connect(new StdioServerTransport());
package/src/search.js ADDED
@@ -0,0 +1,94 @@
1
+ // Lexical search over the entry-point index.
2
+ //
3
+ // ~56 entry points does not justify embeddings. What actually decides whether
4
+ // search works is the `aliases` field in each `*.meta.ts`: "dropdown" has to
5
+ // reach menu, select, and combobox, none of which contain the word.
6
+
7
+ /**
8
+ * Words that carry no intent. Without this, "dropdown of actions" scores every
9
+ * component whose prose contains "of" — which is most of them.
10
+ */
11
+ const STOPWORDS = new Set([
12
+ 'a', 'an', 'the', 'of', 'for', 'to', 'in', 'on', 'at', 'by', 'with', 'and',
13
+ 'or', 'is', 'it', 'as', 'that', 'this', 'my', 'i', 'we', 'want', 'need',
14
+ 'component', 'angular', 'ngx', 'tw',
15
+ ]);
16
+
17
+ /** Split a query or field into comparable lowercase tokens. */
18
+ function tokenize(text) {
19
+ return String(text ?? '')
20
+ .toLowerCase()
21
+ .split(/[^a-z0-9]+/)
22
+ .filter((t) => t && !STOPWORDS.has(t));
23
+ }
24
+
25
+ // Weighted by how strongly a hit in that field predicts intent. An alias match
26
+ // is worth nearly as much as a name match — that is the whole point of aliases.
27
+ const FIELDS = [
28
+ { weight: 100, of: (e) => [e.name] },
29
+ { weight: 80, of: (e) => e.aliases ?? [] },
30
+ { weight: 30, of: (e) => [e.summary ?? ''] },
31
+ { weight: 20, of: (e) => e.whenToUse ?? [] },
32
+ { weight: 12, of: (e) => (e.symbols ?? []).flatMap((s) => [s.name, s.selector ?? '']) },
33
+ { weight: 6, of: (e) => (e.whenNotToUse ?? []).map((w) => w.because) },
34
+ ];
35
+
36
+ /**
37
+ * Score one entry point against the query's tokens. Exact token equality beats
38
+ * prefix, which beats substring — so "tab" ranks `tabs` and `tab-nav` above
39
+ * anything merely containing the letters.
40
+ */
41
+ function score(entry, queryTokens, rawQuery) {
42
+ let total = 0;
43
+
44
+ for (const { weight, of } of FIELDS) {
45
+ for (const value of of(entry)) {
46
+ const valueTokens = tokenize(value);
47
+ if (!valueTokens.length) continue;
48
+
49
+ for (const q of queryTokens) {
50
+ for (const token of valueTokens) {
51
+ if (token === q) total += weight;
52
+ else if (token.startsWith(q) && q.length >= 3) total += weight * 0.6;
53
+ else if (token.includes(q) && q.length >= 4) total += weight * 0.3;
54
+ }
55
+ }
56
+ }
57
+ }
58
+
59
+ // Whole-phrase hits on the name or an alias: "date range picker" should land
60
+ // on `date-range-picker` rather than spreading across three components.
61
+ const phrase = rawQuery.toLowerCase().trim();
62
+ if (phrase) {
63
+ if (entry.name.replace(/-/g, ' ') === phrase) total += 250;
64
+ if ((entry.aliases ?? []).some((a) => a.toLowerCase() === phrase)) total += 200;
65
+ }
66
+
67
+ // A component nobody documented is a weaker answer than one with examples.
68
+ if (entry.snippets?.length) total += 3;
69
+
70
+ return total;
71
+ }
72
+
73
+ /** Ranked entry points for a free-text query. */
74
+ export function searchComponents(index, query, limit = 10) {
75
+ const queryTokens = tokenize(query);
76
+ if (!queryTokens.length) return [];
77
+
78
+ return index.entryPoints
79
+ .map((entry) => ({ entry, score: score(entry, queryTokens, query) }))
80
+ .filter((r) => r.score > 0)
81
+ .sort((a, b) => b.score - a.score || a.entry.name.localeCompare(b.entry.name))
82
+ .slice(0, limit)
83
+ .map(({ entry, score: s }) => ({
84
+ name: entry.name,
85
+ importPath: entry.importPath,
86
+ summary: entry.summary ?? null,
87
+ whenToUse: entry.whenToUse ?? [],
88
+ aliases: entry.aliases ?? [],
89
+ selectors: [...new Set(
90
+ (entry.symbols ?? []).filter((sym) => sym.selector).map((sym) => sym.selector),
91
+ )],
92
+ relevance: Math.round(s),
93
+ }));
94
+ }