@fundamental-ngx/mcp 0.62.0-rc.67
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 +109 -0
- package/package.json +21 -0
- package/src/data/components.json +61370 -0
- package/src/extractors/build-metadata.d.ts +8 -0
- package/src/extractors/build-metadata.js +178 -0
- package/src/extractors/build-metadata.js.map +1 -0
- package/src/extractors/cem-extractor.d.ts +17 -0
- package/src/extractors/cem-extractor.js +430 -0
- package/src/extractors/cem-extractor.js.map +1 -0
- package/src/extractors/changelog-extractor.d.ts +6 -0
- package/src/extractors/changelog-extractor.js +115 -0
- package/src/extractors/changelog-extractor.js.map +1 -0
- package/src/extractors/description-extractor.d.ts +11 -0
- package/src/extractors/description-extractor.js +58 -0
- package/src/extractors/description-extractor.js.map +1 -0
- package/src/extractors/example-extractor.d.ts +19 -0
- package/src/extractors/example-extractor.js +67 -0
- package/src/extractors/example-extractor.js.map +1 -0
- package/src/extractors/token-extractor.d.ts +6 -0
- package/src/extractors/token-extractor.js +345 -0
- package/src/extractors/token-extractor.js.map +1 -0
- package/src/extractors/typedoc-extractor.d.ts +16 -0
- package/src/extractors/typedoc-extractor.js +576 -0
- package/src/extractors/typedoc-extractor.js.map +1 -0
- package/src/index.d.ts +2 -0
- package/src/index.js +3 -0
- package/src/index.js.map +1 -0
- package/src/server.d.ts +1 -0
- package/src/server.js +794 -0
- package/src/server.js.map +1 -0
- package/src/types/component-metadata.d.ts +177 -0
- package/src/types/component-metadata.js +21 -0
- package/src/types/component-metadata.js.map +1 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,794 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { readFileSync } from 'fs';
|
|
4
|
+
import { resolve } from 'path';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { extractChangelogs } from './extractors/changelog-extractor';
|
|
7
|
+
import { extractDesignTokens } from './extractors/token-extractor';
|
|
8
|
+
import { LIBRARY_ALIAS_MAP } from './types/component-metadata';
|
|
9
|
+
// Load component catalog from pre-built JSON
|
|
10
|
+
let catalog;
|
|
11
|
+
try {
|
|
12
|
+
const dataPath = resolve(__dirname, 'data', 'components.json');
|
|
13
|
+
catalog = JSON.parse(readFileSync(dataPath, 'utf-8'));
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
catalog = { generatedAt: new Date().toISOString(), version: 'unknown', components: [] };
|
|
17
|
+
console.error('Warning: components.json not found. Run `nx run mcp-server:extract-metadata` first.');
|
|
18
|
+
}
|
|
19
|
+
// Design tokens and changelog loaded async at startup
|
|
20
|
+
let designTokens = [];
|
|
21
|
+
let changelogEntries = [];
|
|
22
|
+
const server = new McpServer({
|
|
23
|
+
name: 'fundamental-ngx',
|
|
24
|
+
version: catalog.version
|
|
25
|
+
});
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Tool: list_components
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
server.tool('list_components', `List all Fundamental NGX components. Returns name, selector, library, and description.
|
|
30
|
+
Optionally filter by library (core, platform, btp, cx, cdk, ui5, ui5-fiori, ui5-ai)
|
|
31
|
+
or category (Action, Form, Layout, Display, Navigation, etc.).
|
|
32
|
+
Use this to discover what components are available.`, {
|
|
33
|
+
library: z
|
|
34
|
+
.enum(['core', 'platform', 'btp', 'cx', 'cdk', 'i18n', 'ui5', 'ui5-fiori', 'ui5-ai'])
|
|
35
|
+
.optional()
|
|
36
|
+
.describe('Filter by library'),
|
|
37
|
+
category: z.string().optional().describe('Filter by category')
|
|
38
|
+
}, async ({ library, category }) => {
|
|
39
|
+
let components = catalog.components;
|
|
40
|
+
if (library) {
|
|
41
|
+
const fullLibrary = LIBRARY_ALIAS_MAP[library];
|
|
42
|
+
if (fullLibrary) {
|
|
43
|
+
components = components.filter((c) => c.library === fullLibrary);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (category) {
|
|
47
|
+
const lowerCategory = category.toLowerCase();
|
|
48
|
+
components = components.filter((c) => c.category.toLowerCase().includes(lowerCategory));
|
|
49
|
+
}
|
|
50
|
+
const summary = components.map((c) => ({
|
|
51
|
+
name: c.name,
|
|
52
|
+
selector: c.selector,
|
|
53
|
+
library: c.library,
|
|
54
|
+
description: truncate(c.description, 120)
|
|
55
|
+
}));
|
|
56
|
+
return {
|
|
57
|
+
content: [
|
|
58
|
+
{
|
|
59
|
+
type: 'text',
|
|
60
|
+
text: JSON.stringify({ count: summary.length, components: summary }, null, 2)
|
|
61
|
+
}
|
|
62
|
+
]
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Tool: search_components
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
server.tool('search_components', `Search Fundamental NGX components by keyword. Searches across component names,
|
|
69
|
+
selectors, descriptions, and input/output property names.
|
|
70
|
+
Use this when you need to find a component by a partial name or feature keyword.`, {
|
|
71
|
+
query: z.string().describe('Search keyword (e.g., "button", "table", "date", "navigation")'),
|
|
72
|
+
library: z
|
|
73
|
+
.enum(['core', 'platform', 'btp', 'cx', 'cdk', 'ui5', 'ui5-fiori', 'ui5-ai'])
|
|
74
|
+
.optional()
|
|
75
|
+
.describe('Restrict search to a specific library')
|
|
76
|
+
}, async ({ query, library }) => {
|
|
77
|
+
const lowerQuery = query.toLowerCase();
|
|
78
|
+
let components = catalog.components;
|
|
79
|
+
if (library) {
|
|
80
|
+
const fullLibrary = LIBRARY_ALIAS_MAP[library];
|
|
81
|
+
if (fullLibrary) {
|
|
82
|
+
components = components.filter((c) => c.library === fullLibrary);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const scored = components
|
|
86
|
+
.map((c) => ({ component: c, score: scoreMatch(c, lowerQuery) }))
|
|
87
|
+
.filter((s) => s.score > 0)
|
|
88
|
+
.sort((a, b) => b.score - a.score)
|
|
89
|
+
.slice(0, 20);
|
|
90
|
+
const results = scored.map((s) => ({
|
|
91
|
+
name: s.component.name,
|
|
92
|
+
selector: s.component.selector,
|
|
93
|
+
library: s.component.library,
|
|
94
|
+
category: s.component.category,
|
|
95
|
+
description: truncate(s.component.description, 150),
|
|
96
|
+
relevance: s.score
|
|
97
|
+
}));
|
|
98
|
+
return {
|
|
99
|
+
content: [
|
|
100
|
+
{
|
|
101
|
+
type: 'text',
|
|
102
|
+
text: JSON.stringify({ query, count: results.length, results }, null, 2)
|
|
103
|
+
}
|
|
104
|
+
]
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Tool: get_component_api
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
server.tool('get_component_api', `Get full API details for a specific Fundamental NGX component.
|
|
111
|
+
Accepts component name (e.g., "ButtonComponent"), selector (e.g., "fd-button", "ui5-button"),
|
|
112
|
+
or partial match. Returns all inputs, outputs, slots, methods, and enum values.
|
|
113
|
+
Use this when you need to know how to use a specific component.`, {
|
|
114
|
+
name: z.string().describe('Component name, selector, or search term')
|
|
115
|
+
}, async ({ name }) => {
|
|
116
|
+
const component = findComponent(name);
|
|
117
|
+
if (!component) {
|
|
118
|
+
return {
|
|
119
|
+
content: [
|
|
120
|
+
{
|
|
121
|
+
type: 'text',
|
|
122
|
+
text: `Component "${name}" not found. Use search_components to find available components.`
|
|
123
|
+
}
|
|
124
|
+
]
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
const result = { ...component };
|
|
128
|
+
if (component.deprecated) {
|
|
129
|
+
result.deprecationWarning = `This component is deprecated: ${component.deprecated}`;
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
content: [
|
|
133
|
+
{
|
|
134
|
+
type: 'text',
|
|
135
|
+
text: JSON.stringify(result, null, 2)
|
|
136
|
+
}
|
|
137
|
+
]
|
|
138
|
+
};
|
|
139
|
+
});
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// Tool: get_component_examples
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
server.tool('get_component_examples', `Get working code examples for a Fundamental NGX component.
|
|
144
|
+
Returns TypeScript and HTML snippets from the documentation examples.
|
|
145
|
+
Use this when you need real usage patterns for a component.`, {
|
|
146
|
+
name: z.string().describe('Component name or selector')
|
|
147
|
+
}, async ({ name }) => {
|
|
148
|
+
const component = findComponent(name);
|
|
149
|
+
if (!component) {
|
|
150
|
+
return {
|
|
151
|
+
content: [
|
|
152
|
+
{
|
|
153
|
+
type: 'text',
|
|
154
|
+
text: `Component "${name}" not found. Use search_components to find available components.`
|
|
155
|
+
}
|
|
156
|
+
]
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
if (!component.examples || component.examples.length === 0) {
|
|
160
|
+
return {
|
|
161
|
+
content: [
|
|
162
|
+
{
|
|
163
|
+
type: 'text',
|
|
164
|
+
text: JSON.stringify({
|
|
165
|
+
component: component.name,
|
|
166
|
+
selector: component.selector,
|
|
167
|
+
docsUrl: component.docsUrl || 'https://sap.github.io/fundamental-ngx',
|
|
168
|
+
note: 'No examples found for this component. Check the docs site for usage guidance.'
|
|
169
|
+
}, null, 2)
|
|
170
|
+
}
|
|
171
|
+
]
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
const formatted = component.examples.map((ex) => {
|
|
175
|
+
let code = `// --- ${ex.description} ---\n\n`;
|
|
176
|
+
code += ex.typescript;
|
|
177
|
+
if (ex.html) {
|
|
178
|
+
code += `\n\n<!-- Template: ${ex.name}.component.html -->\n\n${ex.html}`;
|
|
179
|
+
}
|
|
180
|
+
return { name: ex.description, code };
|
|
181
|
+
});
|
|
182
|
+
return {
|
|
183
|
+
content: [
|
|
184
|
+
{
|
|
185
|
+
type: 'text',
|
|
186
|
+
text: JSON.stringify({
|
|
187
|
+
component: component.name,
|
|
188
|
+
selector: component.selector,
|
|
189
|
+
exampleCount: formatted.length,
|
|
190
|
+
examples: formatted
|
|
191
|
+
}, null, 2)
|
|
192
|
+
}
|
|
193
|
+
]
|
|
194
|
+
};
|
|
195
|
+
});
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// Tool: recommend_components
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
server.tool('recommend_components', `Given a UI description or use case, recommend which Fundamental NGX components to use.
|
|
200
|
+
Describe what you want to build (e.g., "a filterable data table", "a login form",
|
|
201
|
+
"a master-detail layout") and get a list of recommended components with their selectors,
|
|
202
|
+
libraries, and how they compose together.`, {
|
|
203
|
+
description: z.string().describe('What you want to build (e.g., "a form with date picker and validation")')
|
|
204
|
+
}, async ({ description }) => {
|
|
205
|
+
const lowerDesc = description.toLowerCase();
|
|
206
|
+
const recommendations = [];
|
|
207
|
+
// Pattern-based recommendations
|
|
208
|
+
for (const [pattern, componentSelectors] of Object.entries(UI_PATTERNS)) {
|
|
209
|
+
const keywords = pattern.split('|');
|
|
210
|
+
if (keywords.some((kw) => lowerDesc.includes(kw))) {
|
|
211
|
+
for (const sel of componentSelectors) {
|
|
212
|
+
const comp = catalog.components.find((c) => c.selector === sel);
|
|
213
|
+
if (comp && !recommendations.some((r) => r.selector === sel)) {
|
|
214
|
+
recommendations.push({
|
|
215
|
+
component: comp.name,
|
|
216
|
+
selector: comp.selector,
|
|
217
|
+
library: comp.library,
|
|
218
|
+
reason: `Matches "${pattern.split('|')[0]}" pattern`
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// Also do a keyword search for anything not caught by patterns
|
|
225
|
+
if (recommendations.length < 3) {
|
|
226
|
+
const words = lowerDesc.split(/\s+/).filter((w) => w.length > 3);
|
|
227
|
+
for (const word of words) {
|
|
228
|
+
const matches = catalog.components
|
|
229
|
+
.filter((c) => c.selector.includes(word) ||
|
|
230
|
+
c.name.toLowerCase().includes(word) ||
|
|
231
|
+
c.category.toLowerCase().includes(word))
|
|
232
|
+
.slice(0, 3);
|
|
233
|
+
for (const comp of matches) {
|
|
234
|
+
if (!recommendations.some((r) => r.selector === comp.selector)) {
|
|
235
|
+
recommendations.push({
|
|
236
|
+
component: comp.name,
|
|
237
|
+
selector: comp.selector,
|
|
238
|
+
library: comp.library,
|
|
239
|
+
reason: `Name/category matches "${word}"`
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
content: [
|
|
247
|
+
{
|
|
248
|
+
type: 'text',
|
|
249
|
+
text: JSON.stringify({
|
|
250
|
+
description,
|
|
251
|
+
recommendations: recommendations.slice(0, 15),
|
|
252
|
+
note: 'Use get_component_api for full details on each component.'
|
|
253
|
+
}, null, 2)
|
|
254
|
+
}
|
|
255
|
+
]
|
|
256
|
+
};
|
|
257
|
+
});
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
// Tool: get_migration_guide
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
server.tool('get_migration_guide', `Get migration guidance for upgrading Fundamental NGX.
|
|
262
|
+
Returns breaking changes, deprecated APIs, and migration steps.
|
|
263
|
+
Use this when helping users upgrade between versions.`, {
|
|
264
|
+
component: z.string().optional().describe('Specific component to get migration info for'),
|
|
265
|
+
from_version: z.string().optional().describe('Version migrating from (e.g., "0.58.0")'),
|
|
266
|
+
to_version: z.string().optional().describe('Version migrating to (defaults to latest)')
|
|
267
|
+
}, async ({ component, from_version, to_version }) => {
|
|
268
|
+
let entries = changelogEntries;
|
|
269
|
+
if (component) {
|
|
270
|
+
const lowerComp = component.toLowerCase();
|
|
271
|
+
entries = entries.filter((e) => e.component?.toLowerCase().includes(lowerComp) || e.description.toLowerCase().includes(lowerComp));
|
|
272
|
+
}
|
|
273
|
+
if (from_version) {
|
|
274
|
+
entries = entries.filter((e) => compareVersions(e.version, from_version) >= 0);
|
|
275
|
+
}
|
|
276
|
+
if (to_version) {
|
|
277
|
+
entries = entries.filter((e) => compareVersions(e.version, to_version) <= 0);
|
|
278
|
+
}
|
|
279
|
+
if (entries.length === 0) {
|
|
280
|
+
return {
|
|
281
|
+
content: [
|
|
282
|
+
{
|
|
283
|
+
type: 'text',
|
|
284
|
+
text: JSON.stringify({
|
|
285
|
+
filters: { component, from_version, to_version },
|
|
286
|
+
matches: 0,
|
|
287
|
+
note: 'No changelog entries found for the given filters. Try broader version ranges or omit the component filter.'
|
|
288
|
+
}, null, 2)
|
|
289
|
+
}
|
|
290
|
+
]
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
// Deduplicate: the same commit appears in multiple library CHANGELOGs
|
|
294
|
+
// (fixed-version monorepo). Keep one entry per unique description+type+version.
|
|
295
|
+
const seen = new Set();
|
|
296
|
+
entries = entries.filter((e) => {
|
|
297
|
+
const key = `${e.version}|${e.type}|${e.description}`;
|
|
298
|
+
if (seen.has(key)) {
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
seen.add(key);
|
|
302
|
+
return true;
|
|
303
|
+
});
|
|
304
|
+
// Collapse RC entries into their stable release. Stable releases (e.g. 0.61.0)
|
|
305
|
+
// aggregate all changes from their RCs (e.g. 0.61.0-rc.2), producing duplicates.
|
|
306
|
+
// If the same type+description exists in both RC and stable, keep only the stable entry.
|
|
307
|
+
const stableKeys = new Set();
|
|
308
|
+
for (const e of entries) {
|
|
309
|
+
if (!e.version.includes('-')) {
|
|
310
|
+
stableKeys.add(`${baseVersion(e.version)}|${e.type}|${e.description}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
entries = entries.filter((e) => {
|
|
314
|
+
if (!e.version.includes('-')) {
|
|
315
|
+
return true;
|
|
316
|
+
} // keep all stable entries
|
|
317
|
+
const key = `${baseVersion(e.version)}|${e.type}|${e.description}`;
|
|
318
|
+
return !stableKeys.has(key); // drop RC entry if stable has same change
|
|
319
|
+
});
|
|
320
|
+
// Group by version
|
|
321
|
+
const byVersion = {};
|
|
322
|
+
for (const entry of entries) {
|
|
323
|
+
(byVersion[entry.version] ??= []).push(entry);
|
|
324
|
+
}
|
|
325
|
+
// Sort versions descending, limit output
|
|
326
|
+
const sortedVersions = Object.keys(byVersion).sort((a, b) => compareVersions(b, a));
|
|
327
|
+
const limitedVersions = sortedVersions.slice(0, 20);
|
|
328
|
+
const result = limitedVersions.map((version) => ({
|
|
329
|
+
version,
|
|
330
|
+
changes: byVersion[version].map((e) => ({
|
|
331
|
+
type: e.type,
|
|
332
|
+
library: e.library,
|
|
333
|
+
component: e.component,
|
|
334
|
+
description: e.description
|
|
335
|
+
}))
|
|
336
|
+
}));
|
|
337
|
+
return {
|
|
338
|
+
content: [
|
|
339
|
+
{
|
|
340
|
+
type: 'text',
|
|
341
|
+
text: JSON.stringify({
|
|
342
|
+
filters: { component, from_version, to_version },
|
|
343
|
+
totalEntries: entries.length,
|
|
344
|
+
versions: result
|
|
345
|
+
}, null, 2)
|
|
346
|
+
}
|
|
347
|
+
]
|
|
348
|
+
};
|
|
349
|
+
});
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
// Tool: get_design_tokens
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
server.tool('get_design_tokens', `Look up SAP design tokens (CSS custom properties) and utility classes.
|
|
354
|
+
Search for colors, spacing (margins/paddings), typography, elevation, and border tokens.
|
|
355
|
+
Returns CSS variable names, descriptions, and usage examples.
|
|
356
|
+
Use this when styling Fundamental NGX components or building custom layouts.`, {
|
|
357
|
+
query: z.string().describe('What to look for (e.g., "background color", "margin", "font size")'),
|
|
358
|
+
category: z
|
|
359
|
+
.enum(['color', 'spacing', 'typography', 'elevation', 'border', 'size'])
|
|
360
|
+
.optional()
|
|
361
|
+
.describe('Filter by token category')
|
|
362
|
+
}, async ({ query, category }) => {
|
|
363
|
+
const lowerQuery = query.toLowerCase();
|
|
364
|
+
let tokens = designTokens;
|
|
365
|
+
if (category) {
|
|
366
|
+
tokens = tokens.filter((t) => t.category === category);
|
|
367
|
+
}
|
|
368
|
+
const matches = tokens
|
|
369
|
+
.filter((t) => t.name.toLowerCase().includes(lowerQuery) || t.description.toLowerCase().includes(lowerQuery))
|
|
370
|
+
.slice(0, 50);
|
|
371
|
+
if (matches.length === 0) {
|
|
372
|
+
return {
|
|
373
|
+
content: [
|
|
374
|
+
{
|
|
375
|
+
type: 'text',
|
|
376
|
+
text: JSON.stringify({
|
|
377
|
+
query,
|
|
378
|
+
category,
|
|
379
|
+
matches: 0,
|
|
380
|
+
note: 'No tokens found. Try broader terms like "color", "spacing", "font", or "margin".'
|
|
381
|
+
}, null, 2)
|
|
382
|
+
}
|
|
383
|
+
]
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
return {
|
|
387
|
+
content: [
|
|
388
|
+
{
|
|
389
|
+
type: 'text',
|
|
390
|
+
text: JSON.stringify({
|
|
391
|
+
query,
|
|
392
|
+
category,
|
|
393
|
+
matches: matches.length,
|
|
394
|
+
tokens: matches
|
|
395
|
+
}, null, 2)
|
|
396
|
+
}
|
|
397
|
+
]
|
|
398
|
+
};
|
|
399
|
+
});
|
|
400
|
+
// ---------------------------------------------------------------------------
|
|
401
|
+
// Tool: get_accessibility_guide
|
|
402
|
+
// ---------------------------------------------------------------------------
|
|
403
|
+
server.tool('get_accessibility_guide', `Get accessibility guidance for a Fundamental NGX component.
|
|
404
|
+
Returns ARIA-related inputs (ariaLabel, ariaDescribedBy, role, etc.),
|
|
405
|
+
keyboard handling notes, and accessibility code examples when available.
|
|
406
|
+
Use this when building accessible UIs or auditing existing components.`, {
|
|
407
|
+
name: z.string().describe('Component name or selector (e.g., "fd-button", "ui5-dialog")')
|
|
408
|
+
}, async ({ name }) => {
|
|
409
|
+
const component = findComponent(name);
|
|
410
|
+
if (!component) {
|
|
411
|
+
return {
|
|
412
|
+
content: [
|
|
413
|
+
{
|
|
414
|
+
type: 'text',
|
|
415
|
+
text: `Component "${name}" not found. Use search_components to find available components.`
|
|
416
|
+
}
|
|
417
|
+
]
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
// Extract ARIA-related inputs
|
|
421
|
+
const ariaInputs = component.inputs.filter((i) => i.name.toLowerCase().startsWith('aria') ||
|
|
422
|
+
i.name.toLowerCase() === 'role' ||
|
|
423
|
+
i.name.toLowerCase().startsWith('accessible'));
|
|
424
|
+
// Extract a11y examples
|
|
425
|
+
const a11yExamples = (component.examples ?? []).filter((ex) => ex.name.toLowerCase().includes('a11y') ||
|
|
426
|
+
ex.name.toLowerCase().includes('accessibility') ||
|
|
427
|
+
ex.name.toLowerCase().includes('accessible'));
|
|
428
|
+
// Build general guidance based on component features
|
|
429
|
+
const tips = [];
|
|
430
|
+
if (ariaInputs.length > 0) {
|
|
431
|
+
const labelInput = ariaInputs.find((i) => i.name === 'ariaLabel' || i.name === 'accessibleName');
|
|
432
|
+
if (labelInput) {
|
|
433
|
+
tips.push(`Provide a descriptive "${labelInput.name}" for screen readers, especially when the component has no visible text label.`);
|
|
434
|
+
}
|
|
435
|
+
const describedByInput = ariaInputs.find((i) => i.name === 'ariaDescribedBy' || i.name === 'accessibleNameRef');
|
|
436
|
+
if (describedByInput) {
|
|
437
|
+
tips.push(`Use "${describedByInput.name}" to reference IDs of elements that describe this component.`);
|
|
438
|
+
}
|
|
439
|
+
const roleInput = ariaInputs.find((i) => i.name === 'role' || i.name === 'accessibleRole');
|
|
440
|
+
if (roleInput) {
|
|
441
|
+
tips.push(`The "${roleInput.name}" input overrides the default ARIA role. Only change it when the component is used in a non-standard context.`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
else {
|
|
445
|
+
tips.push('This component has no explicit ARIA inputs. It likely handles accessibility internally or relies on native HTML semantics.');
|
|
446
|
+
}
|
|
447
|
+
if (component.keyboardHandling) {
|
|
448
|
+
tips.push('This component has documented keyboard interactions — see keyboardHandling below.');
|
|
449
|
+
}
|
|
450
|
+
if (component.slots && component.slots.length > 0) {
|
|
451
|
+
tips.push('When projecting content via slots, ensure projected elements have appropriate ARIA attributes.');
|
|
452
|
+
}
|
|
453
|
+
const result = {
|
|
454
|
+
component: component.name,
|
|
455
|
+
selector: component.selector,
|
|
456
|
+
library: component.library,
|
|
457
|
+
ariaInputs: ariaInputs.map((i) => ({
|
|
458
|
+
name: i.name,
|
|
459
|
+
type: i.type,
|
|
460
|
+
description: i.description,
|
|
461
|
+
defaultValue: i.defaultValue
|
|
462
|
+
})),
|
|
463
|
+
keyboardHandling: component.keyboardHandling || null,
|
|
464
|
+
a11yExamples: a11yExamples.map((ex) => ({
|
|
465
|
+
name: ex.name,
|
|
466
|
+
description: ex.description,
|
|
467
|
+
typescript: ex.typescript,
|
|
468
|
+
html: ex.html
|
|
469
|
+
})),
|
|
470
|
+
tips
|
|
471
|
+
};
|
|
472
|
+
return {
|
|
473
|
+
content: [
|
|
474
|
+
{
|
|
475
|
+
type: 'text',
|
|
476
|
+
text: JSON.stringify(result, null, 2)
|
|
477
|
+
}
|
|
478
|
+
]
|
|
479
|
+
};
|
|
480
|
+
});
|
|
481
|
+
// ---------------------------------------------------------------------------
|
|
482
|
+
// Tool: compare_components
|
|
483
|
+
// ---------------------------------------------------------------------------
|
|
484
|
+
server.tool('compare_components', `Compare two Fundamental NGX components side by side.
|
|
485
|
+
Returns shared and unique inputs, outputs, slots, and methods.
|
|
486
|
+
Useful for choosing between core (fd-) and UI5 (ui5-) variants,
|
|
487
|
+
or comparing alternative components for the same use case.`, {
|
|
488
|
+
component_a: z.string().describe('First component name or selector (e.g., "fd-button")'),
|
|
489
|
+
component_b: z.string().describe('Second component name or selector (e.g., "ui5-button")')
|
|
490
|
+
}, async ({ component_a, component_b }) => {
|
|
491
|
+
const compA = findComponent(component_a);
|
|
492
|
+
const compB = findComponent(component_b);
|
|
493
|
+
if (!compA || !compB) {
|
|
494
|
+
const missing = [...(!compA ? [component_a] : []), ...(!compB ? [component_b] : [])];
|
|
495
|
+
return {
|
|
496
|
+
content: [
|
|
497
|
+
{
|
|
498
|
+
type: 'text',
|
|
499
|
+
text: `Component(s) not found: ${missing.map((m) => `"${m}"`).join(', ')}. Use search_components to find available components.`
|
|
500
|
+
}
|
|
501
|
+
]
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
// Compare inputs by name
|
|
505
|
+
const inputNamesA = new Set(compA.inputs.map((i) => i.name));
|
|
506
|
+
const inputNamesB = new Set(compB.inputs.map((i) => i.name));
|
|
507
|
+
const sharedInputs = compA.inputs
|
|
508
|
+
.filter((i) => inputNamesB.has(i.name))
|
|
509
|
+
.map((i) => {
|
|
510
|
+
const bInput = compB.inputs.find((bi) => bi.name === i.name);
|
|
511
|
+
return {
|
|
512
|
+
name: i.name,
|
|
513
|
+
typeA: i.type,
|
|
514
|
+
typeB: bInput.type,
|
|
515
|
+
defaultA: i.defaultValue,
|
|
516
|
+
defaultB: bInput.defaultValue
|
|
517
|
+
};
|
|
518
|
+
});
|
|
519
|
+
const onlyInA_inputs = compA.inputs
|
|
520
|
+
.filter((i) => !inputNamesB.has(i.name))
|
|
521
|
+
.map((i) => ({ name: i.name, type: i.type, defaultValue: i.defaultValue }));
|
|
522
|
+
const onlyInB_inputs = compB.inputs
|
|
523
|
+
.filter((i) => !inputNamesA.has(i.name))
|
|
524
|
+
.map((i) => ({ name: i.name, type: i.type, defaultValue: i.defaultValue }));
|
|
525
|
+
// Compare outputs by name
|
|
526
|
+
const outputNamesA = new Set(compA.outputs.map((o) => o.name));
|
|
527
|
+
const outputNamesB = new Set(compB.outputs.map((o) => o.name));
|
|
528
|
+
const sharedOutputs = compA.outputs
|
|
529
|
+
.filter((o) => outputNamesB.has(o.name))
|
|
530
|
+
.map((o) => {
|
|
531
|
+
const bOutput = compB.outputs.find((bo) => bo.name === o.name);
|
|
532
|
+
return { name: o.name, typeA: o.type, typeB: bOutput.type };
|
|
533
|
+
});
|
|
534
|
+
const onlyInA_outputs = compA.outputs
|
|
535
|
+
.filter((o) => !outputNamesB.has(o.name))
|
|
536
|
+
.map((o) => ({ name: o.name, type: o.type }));
|
|
537
|
+
const onlyInB_outputs = compB.outputs
|
|
538
|
+
.filter((o) => !outputNamesA.has(o.name))
|
|
539
|
+
.map((o) => ({ name: o.name, type: o.type }));
|
|
540
|
+
// Build summary
|
|
541
|
+
const summaryParts = [];
|
|
542
|
+
summaryParts.push(`${compA.selector} is from ${compA.library}; ${compB.selector} is from ${compB.library}.`);
|
|
543
|
+
summaryParts.push(`Inputs: ${compA.inputs.length} vs ${compB.inputs.length} (${sharedInputs.length} shared).`);
|
|
544
|
+
summaryParts.push(`Outputs: ${compA.outputs.length} vs ${compB.outputs.length} (${sharedOutputs.length} shared).`);
|
|
545
|
+
if (compA.slots.length > 0 || compB.slots.length > 0) {
|
|
546
|
+
const slotsA = compA.slots.map((s) => s.name).join(', ') || 'none';
|
|
547
|
+
const slotsB = compB.slots.map((s) => s.name).join(', ') || 'none';
|
|
548
|
+
summaryParts.push(`Slots: ${compA.selector} [${slotsA}], ${compB.selector} [${slotsB}].`);
|
|
549
|
+
}
|
|
550
|
+
if (compA.deprecated) {
|
|
551
|
+
summaryParts.push(`${compA.selector} is deprecated: ${compA.deprecated}`);
|
|
552
|
+
}
|
|
553
|
+
if (compB.deprecated) {
|
|
554
|
+
summaryParts.push(`${compB.selector} is deprecated: ${compB.deprecated}`);
|
|
555
|
+
}
|
|
556
|
+
// Find alternatives from UI_PATTERNS
|
|
557
|
+
const alternatives = findAlternatives(compA.selector, compB.selector);
|
|
558
|
+
const result = {
|
|
559
|
+
componentA: {
|
|
560
|
+
name: compA.name,
|
|
561
|
+
selector: compA.selector,
|
|
562
|
+
library: compA.library,
|
|
563
|
+
category: compA.category,
|
|
564
|
+
description: truncate(compA.description, 200)
|
|
565
|
+
},
|
|
566
|
+
componentB: {
|
|
567
|
+
name: compB.name,
|
|
568
|
+
selector: compB.selector,
|
|
569
|
+
library: compB.library,
|
|
570
|
+
category: compB.category,
|
|
571
|
+
description: truncate(compB.description, 200)
|
|
572
|
+
},
|
|
573
|
+
comparison: {
|
|
574
|
+
sharedInputs,
|
|
575
|
+
onlyInA: onlyInA_inputs,
|
|
576
|
+
onlyInB: onlyInB_inputs,
|
|
577
|
+
sharedOutputs,
|
|
578
|
+
onlyOutputsInA: onlyInA_outputs,
|
|
579
|
+
onlyOutputsInB: onlyInB_outputs,
|
|
580
|
+
slotsA: compA.slots.map((s) => ({ name: s.name, description: truncate(s.description, 80) })),
|
|
581
|
+
slotsB: compB.slots.map((s) => ({ name: s.name, description: truncate(s.description, 80) })),
|
|
582
|
+
methodsA: compA.methods.map((m) => m.name),
|
|
583
|
+
methodsB: compB.methods.map((m) => m.name),
|
|
584
|
+
summary: summaryParts.join(' ')
|
|
585
|
+
},
|
|
586
|
+
alternatives
|
|
587
|
+
};
|
|
588
|
+
return {
|
|
589
|
+
content: [
|
|
590
|
+
{
|
|
591
|
+
type: 'text',
|
|
592
|
+
text: JSON.stringify(result, null, 2)
|
|
593
|
+
}
|
|
594
|
+
]
|
|
595
|
+
};
|
|
596
|
+
});
|
|
597
|
+
// ---------------------------------------------------------------------------
|
|
598
|
+
// Resource: component catalog
|
|
599
|
+
// ---------------------------------------------------------------------------
|
|
600
|
+
server.resource('component-catalog', 'fundamental-ngx://components/catalog', async (uri) => ({
|
|
601
|
+
contents: [
|
|
602
|
+
{
|
|
603
|
+
uri: uri.href,
|
|
604
|
+
mimeType: 'application/json',
|
|
605
|
+
text: JSON.stringify(catalog, null, 2)
|
|
606
|
+
}
|
|
607
|
+
]
|
|
608
|
+
}));
|
|
609
|
+
// ---------------------------------------------------------------------------
|
|
610
|
+
// Helpers
|
|
611
|
+
// ---------------------------------------------------------------------------
|
|
612
|
+
function findComponent(nameOrSelector) {
|
|
613
|
+
const lower = nameOrSelector.toLowerCase();
|
|
614
|
+
// Exact match on selector
|
|
615
|
+
const bySelector = catalog.components.find((c) => c.selector.toLowerCase() === lower);
|
|
616
|
+
if (bySelector) {
|
|
617
|
+
return bySelector;
|
|
618
|
+
}
|
|
619
|
+
// Exact match on name
|
|
620
|
+
const byName = catalog.components.find((c) => c.name.toLowerCase() === lower);
|
|
621
|
+
if (byName) {
|
|
622
|
+
return byName;
|
|
623
|
+
}
|
|
624
|
+
// Partial match on selector
|
|
625
|
+
const byPartialSelector = catalog.components.find((c) => c.selector.toLowerCase().includes(lower));
|
|
626
|
+
if (byPartialSelector) {
|
|
627
|
+
return byPartialSelector;
|
|
628
|
+
}
|
|
629
|
+
// Partial match on name
|
|
630
|
+
const byPartialName = catalog.components.find((c) => c.name.toLowerCase().includes(lower));
|
|
631
|
+
if (byPartialName) {
|
|
632
|
+
return byPartialName;
|
|
633
|
+
}
|
|
634
|
+
return undefined;
|
|
635
|
+
}
|
|
636
|
+
function scoreMatch(component, query) {
|
|
637
|
+
let score = 0;
|
|
638
|
+
if (component.selector.toLowerCase() === query) {
|
|
639
|
+
score += 100;
|
|
640
|
+
}
|
|
641
|
+
else if (component.selector.toLowerCase().includes(query)) {
|
|
642
|
+
score += 50;
|
|
643
|
+
}
|
|
644
|
+
if (component.name.toLowerCase() === query) {
|
|
645
|
+
score += 90;
|
|
646
|
+
}
|
|
647
|
+
else if (component.name.toLowerCase().includes(query)) {
|
|
648
|
+
score += 40;
|
|
649
|
+
}
|
|
650
|
+
if (component.description.toLowerCase().includes(query)) {
|
|
651
|
+
score += 20;
|
|
652
|
+
}
|
|
653
|
+
if (component.category.toLowerCase().includes(query)) {
|
|
654
|
+
score += 30;
|
|
655
|
+
}
|
|
656
|
+
// Search in input/output names
|
|
657
|
+
for (const input of component.inputs) {
|
|
658
|
+
if (input.name.toLowerCase().includes(query)) {
|
|
659
|
+
score += 15;
|
|
660
|
+
break;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
for (const output of component.outputs) {
|
|
664
|
+
if (output.name.toLowerCase().includes(query)) {
|
|
665
|
+
score += 15;
|
|
666
|
+
break;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
return score;
|
|
670
|
+
}
|
|
671
|
+
function truncate(text, maxLength) {
|
|
672
|
+
if (!text) {
|
|
673
|
+
return '';
|
|
674
|
+
}
|
|
675
|
+
// Take only the first line/paragraph for summaries
|
|
676
|
+
const firstLine = text.split('\n')[0];
|
|
677
|
+
if (firstLine.length <= maxLength) {
|
|
678
|
+
return firstLine;
|
|
679
|
+
}
|
|
680
|
+
return firstLine.slice(0, maxLength - 3) + '...';
|
|
681
|
+
}
|
|
682
|
+
/** Find alternative components from UI_PATTERNS that share a category with either selector. */
|
|
683
|
+
function findAlternatives(selectorA, selectorB) {
|
|
684
|
+
const alternatives = new Set();
|
|
685
|
+
for (const componentSelectors of Object.values(UI_PATTERNS)) {
|
|
686
|
+
const hasA = componentSelectors.includes(selectorA);
|
|
687
|
+
const hasB = componentSelectors.includes(selectorB);
|
|
688
|
+
if (hasA || hasB) {
|
|
689
|
+
for (const sel of componentSelectors) {
|
|
690
|
+
if (sel !== selectorA && sel !== selectorB) {
|
|
691
|
+
alternatives.add(sel);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
return [...alternatives];
|
|
697
|
+
}
|
|
698
|
+
/** Simple version comparison: "0.60.0" vs "0.61.0". Returns negative/0/positive. */
|
|
699
|
+
function compareVersions(a, b) {
|
|
700
|
+
const [aBase, aPre] = a.split('-');
|
|
701
|
+
const [bBase, bPre] = b.split('-');
|
|
702
|
+
const pa = aBase.split('.').map(Number);
|
|
703
|
+
const pb = bBase.split('.').map(Number);
|
|
704
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
705
|
+
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
706
|
+
if (diff !== 0) {
|
|
707
|
+
return diff;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
// Same base version: stable (no pre-release) > RC
|
|
711
|
+
if (!aPre && bPre) {
|
|
712
|
+
return 1;
|
|
713
|
+
}
|
|
714
|
+
if (aPre && !bPre) {
|
|
715
|
+
return -1;
|
|
716
|
+
}
|
|
717
|
+
if (aPre && bPre) {
|
|
718
|
+
// Compare RC numbers: rc.2 vs rc.10
|
|
719
|
+
const aRc = parseInt(aPre.replace(/\D+/g, ''), 10) || 0;
|
|
720
|
+
const bRc = parseInt(bPre.replace(/\D+/g, ''), 10) || 0;
|
|
721
|
+
return aRc - bRc;
|
|
722
|
+
}
|
|
723
|
+
return 0;
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Get the base version from a version string (strips -rc.N suffix).
|
|
727
|
+
*/
|
|
728
|
+
function baseVersion(version) {
|
|
729
|
+
return version.replace(/-.*$/, '');
|
|
730
|
+
}
|
|
731
|
+
// ---------------------------------------------------------------------------
|
|
732
|
+
// UI Pattern recommendations
|
|
733
|
+
// ---------------------------------------------------------------------------
|
|
734
|
+
const UI_PATTERNS = {
|
|
735
|
+
'table|data table|grid': ['fd-table', 'fdp-table', 'ui5-table'],
|
|
736
|
+
'form|input form': ['fd-form', 'fd-input-group', 'fd-checkbox', 'fd-radio', 'fd-select', 'fd-switch'],
|
|
737
|
+
'dialog|modal|popup': ['fd-dialog', 'ui5-dialog', 'fd-message-box'],
|
|
738
|
+
'date|calendar|date picker': ['fd-date-picker', 'fd-calendar', 'ui5-date-picker', 'ui5-calendar'],
|
|
739
|
+
'navigation|nav|sidebar': ['fd-side-navigation', 'fd-vertical-navigation', 'ui5-side-navigation'],
|
|
740
|
+
'button|action': ['fd-button', 'ui5-button', 'fd-split-button', 'fd-segmented-button'],
|
|
741
|
+
'list|items': ['fd-list', 'ui5-list', 'fd-grid-list'],
|
|
742
|
+
'menu|dropdown': ['fd-menu', 'fd-popover', 'ui5-menu'],
|
|
743
|
+
'tabs|tab': ['fd-tabs', 'ui5-tab-container'],
|
|
744
|
+
'wizard|stepper|step': ['fd-wizard', 'ui5-wizard'],
|
|
745
|
+
'card|tile': ['fd-card', 'fd-tile', 'ui5-card'],
|
|
746
|
+
'tree|hierarchy': ['fd-tree', 'ui5-tree'],
|
|
747
|
+
'upload|file': ['fd-file-uploader', 'ui5-file-uploader', 'fd-upload-collection'],
|
|
748
|
+
'search|filter': ['fdb-search-field', 'fdp-search-field', 'fdp-smart-filter-bar'],
|
|
749
|
+
'toolbar|header bar': ['fd-toolbar', 'fd-bar', 'ui5-toolbar'],
|
|
750
|
+
'notification|alert|message': ['fd-message-strip', 'fd-message-toast', 'fd-notification', 'ui5-notification-list'],
|
|
751
|
+
'avatar|user|profile': ['fd-avatar', 'fd-avatar-group', 'ui5-avatar'],
|
|
752
|
+
'pagination|paging': ['fd-pagination'],
|
|
753
|
+
'master detail|split|column': ['fd-flexible-column-layout', 'ui5-flexible-column-layout'],
|
|
754
|
+
breadcrumb: ['fd-breadcrumb', 'ui5-breadcrumbs'],
|
|
755
|
+
'progress|loading|busy': ['fd-busy-indicator', 'fd-progress-indicator', 'ui5-busy-indicator'],
|
|
756
|
+
'timeline|feed': ['fd-timeline', 'fd-feed-list-item', 'ui5-timeline'],
|
|
757
|
+
'approval|workflow': ['fdp-approval-flow'],
|
|
758
|
+
'shell|app header': ['fdb-tool-header', 'ui5-shell-bar'],
|
|
759
|
+
'slider|range': ['fd-slider', 'fdp-slider', 'ui5-slider', 'ui5-range-slider'],
|
|
760
|
+
'token|tag|chip': ['fd-token', 'ui5-token', 'ui5-tag'],
|
|
761
|
+
'combobox|autocomplete': ['fd-combobox', 'fd-multi-combobox', 'ui5-combo-box', 'ui5-multi-combo-box'],
|
|
762
|
+
'rating|star': ['fd-rating-indicator', 'ui5-rating-indicator'],
|
|
763
|
+
'icon|symbol': ['fd-icon', 'ui5-icon'],
|
|
764
|
+
'skeleton|placeholder': ['fd-skeleton'],
|
|
765
|
+
'panel|section': ['fd-panel', 'fd-layout-panel', 'ui5-panel'],
|
|
766
|
+
'dynamic page|object page': ['fd-dynamic-page', 'fdp-dynamic-page', 'ui5-dynamic-page'],
|
|
767
|
+
'splitter|resizable': ['fdb-splitter'],
|
|
768
|
+
'ai|artificial intelligence|prompt': [
|
|
769
|
+
'ui5-ai-button',
|
|
770
|
+
'ui5-ai-prompt-input',
|
|
771
|
+
'ui5-ai-text-area',
|
|
772
|
+
'ui5-ai-writing-assistant'
|
|
773
|
+
]
|
|
774
|
+
};
|
|
775
|
+
// ---------------------------------------------------------------------------
|
|
776
|
+
// Start server
|
|
777
|
+
// ---------------------------------------------------------------------------
|
|
778
|
+
async function main() {
|
|
779
|
+
// Load design tokens and changelog entries in parallel
|
|
780
|
+
const basePath = resolve(__dirname, '..', '..', '..');
|
|
781
|
+
const [tokens, changelog] = await Promise.all([
|
|
782
|
+
extractDesignTokens(basePath).catch(() => []),
|
|
783
|
+
extractChangelogs(basePath).catch(() => [])
|
|
784
|
+
]);
|
|
785
|
+
designTokens = tokens;
|
|
786
|
+
changelogEntries = changelog;
|
|
787
|
+
const transport = new StdioServerTransport();
|
|
788
|
+
await server.connect(transport);
|
|
789
|
+
}
|
|
790
|
+
main().catch((error) => {
|
|
791
|
+
console.error('MCP server failed to start:', error);
|
|
792
|
+
process.exit(1);
|
|
793
|
+
});
|
|
794
|
+
//# sourceMappingURL=server.js.map
|