@supericons/mcp 0.4.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.
package/index.js ADDED
@@ -0,0 +1,1240 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * SuperIcons MCP Server
4
+ * Provides free icon search plus premium Motion Lab and Converter workflows.
5
+ * Transport: stdio (for local IDE integration)
6
+ * Auth: SUPERICONS_API_KEY env var for Pro workflow tools
7
+ *
8
+ * Premium access tiers:
9
+ * - Pro subscribers: Motion Lab and Converter workflow tools
10
+ * - Pack/Bundle buyers: purchased collections in the Supericons web app
11
+ * - Anonymous: free icon access only
12
+ */
13
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
14
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
15
+ import { z } from 'zod';
16
+ import { readFileSync, readdirSync, existsSync } from 'fs';
17
+ import { join, dirname, basename } from 'path';
18
+ import { fileURLToPath } from 'url';
19
+ import { searchIconsHostedMcp } from './hosted-search-client.js';
20
+ import { recommendIconsForTask } from './recommend-icons.js';
21
+ import { validateApiKey } from './auth.js';
22
+ import {
23
+ MATERIAL_EXPORT_DEFAULT_AXES,
24
+ MATERIAL_EXPORT_STORAGE,
25
+ buildMaterialCacheKey,
26
+ buildMaterialOwnedSnapshotUrl,
27
+ getMaterialManifestEntry,
28
+ normalizeMaterialSnapshotSvg as normalizeOwnedMaterialSnapshotSvg,
29
+ } from './material-export.js';
30
+ import { listMotionLabPresets } from './motion-lab.js';
31
+ import {
32
+ SUPPORTED_MCP_OUTPUT_LOCALES,
33
+ localizeConverterOptions,
34
+ localizeIconNotFoundHint,
35
+ localizeMotionRecipe,
36
+ localizeSearchNoResultsHint,
37
+ localizeSelectorInstructions,
38
+ localizeWorkflowAccessPayload,
39
+ } from './mcp-output-localization.js';
40
+ import {
41
+ animateMotionLabIconHosted,
42
+ getMotionLabRecipeHosted,
43
+ renderMotionLabAnimatedSvgHosted,
44
+ renderMotionLabCssHosted,
45
+ } from './motion-lab-client.js';
46
+ import {
47
+ buildSearchIntentProfile,
48
+ getIntentCandidateAdjustment,
49
+ } from './runtime/search-intent-core.js';
50
+ import { convertPngToSvg, convertSvgToPng, getConverterMcpOptions, inspectConverterInput } from './converter.js';
51
+ import {
52
+ buildPremiumLibraryAccessError,
53
+ buildProWorkflowAccessError,
54
+ hasPremiumLibraryAccess,
55
+ hasProWorkflowAccess,
56
+ } from './workflow-access.js';
57
+ import { logMcpSearchAttempt, logMcpSearchBatch } from './telemetry.js';
58
+ import {
59
+ attachSemanticPayload,
60
+ createSemanticRegistryMap,
61
+ loadSemanticRegistryRecords,
62
+ mergeSemanticMatchesIntoIcons,
63
+ } from './semantic-registry.js';
64
+ import {
65
+ buildVariantLookupCandidates,
66
+ compareVariantPreference,
67
+ getConceptKeyForIcon,
68
+ iconMatchesRequestedStyle,
69
+ librarySupportsSolid,
70
+ normalizeRequestedStyle,
71
+ VARIANT_STYLES,
72
+ } from './variant-support.js';
73
+
74
+ const __dirname = dirname(fileURLToPath(import.meta.url));
75
+
76
+ // ============================================================
77
+ // Data Loading
78
+ // ============================================================
79
+ const dataDir = join(__dirname, 'public');
80
+ const packsDir = join(dataDir, 'packs');
81
+ const manifestPath = join(packsDir, 'manifest.json');
82
+ const materialExportManifestPath = join(dataDir, 'material-export-manifest.json');
83
+ const materialExportDir = join(dataDir, 'material-export');
84
+ const productFactsPath = join(dataDir, 'product-facts.json');
85
+ const mcpPackagePath = join(__dirname, 'package.json');
86
+
87
+ const MATERIAL_EXPORT_MANIFEST_FALLBACK = {
88
+ version: 2,
89
+ upstream: null,
90
+ defaultAxes: MATERIAL_EXPORT_DEFAULT_AXES,
91
+ storage: MATERIAL_EXPORT_STORAGE,
92
+ entries: {},
93
+ };
94
+
95
+ const materialExportState = {
96
+ manifest: null,
97
+ svgCache: new Map(),
98
+ failedKeys: new Set(),
99
+ };
100
+ const semanticRegistryMap = createSemanticRegistryMap(loadSemanticRegistryRecords(dataDir));
101
+
102
+ function loadData() {
103
+ const iconIndexPath = join(dataDir, 'icon-index.json');
104
+ if (!existsSync(iconIndexPath)) {
105
+ return { freeIcons: [], outlineIcons: [], solidIcons: [], synonyms: {} };
106
+ }
107
+
108
+ const raw = JSON.parse(readFileSync(iconIndexPath, 'utf8'));
109
+ const solidPath = join(dataDir, 'icon-index-solid.json');
110
+ const solidRaw = existsSync(solidPath)
111
+ ? JSON.parse(readFileSync(solidPath, 'utf8'))
112
+ : { icons: [] };
113
+ const synonymsPath = join(dataDir, 'synonyms.json');
114
+ const synonyms = existsSync(synonymsPath)
115
+ ? JSON.parse(readFileSync(synonymsPath, 'utf8'))
116
+ : {};
117
+
118
+ // icon-index.json has { icons: [...] } where each entry is { id, name, lib, type, style, svg? }
119
+ // Include Material Symbols so MCP tools can resolve export-grade SVG snapshots on demand.
120
+ const outlineIcons = raw.icons
121
+ .filter(entry => (entry.type === 'svg' && entry.svg) || (entry.lib === 'material' && entry.type === 'font'))
122
+ .map(icon => ({ ...icon, premium: false }));
123
+ const solidIcons = (solidRaw.icons || [])
124
+ .filter(entry => entry.type === 'svg' && entry.svg)
125
+ .map(icon => ({ ...icon, premium: false }));
126
+ const freeIcons = [...outlineIcons, ...solidIcons];
127
+
128
+ return { freeIcons, outlineIcons, solidIcons, synonyms };
129
+ }
130
+
131
+ function loadPremiumPacks() {
132
+ const premiumIcons = [];
133
+ if (!existsSync(packsDir)) return premiumIcons;
134
+
135
+ // Load manifest for classMap (reverse obfuscation of wrapper classes)
136
+ let manifest = {};
137
+ try {
138
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
139
+ } catch {
140
+ // No manifest: premium icons served as-is
141
+ }
142
+
143
+ const packDirs = readdirSync(packsDir, { withFileTypes: true })
144
+ .filter(d => d.isDirectory());
145
+
146
+ for (const packDir of packDirs) {
147
+ const packPath = join(packsDir, packDir.name);
148
+ const svgFiles = readdirSync(packPath).filter(f => f.endsWith('.svg'));
149
+ const slug = packDir.name;
150
+
151
+ // Load CSS for this collection (for serving with premium icons)
152
+ const cssFiles = readdirSync(packPath).filter(f => f.endsWith('.css'));
153
+ let collectionCss = '';
154
+ if (cssFiles.length > 0) {
155
+ collectionCss = readFileSync(join(packPath, cssFiles[0]), 'utf8');
156
+ }
157
+
158
+ // Build reverse classMap: obfuscated -> original (for wrapper classes only)
159
+ const classMap = manifest[slug]?.classMap || {};
160
+ const reverseMap = {};
161
+ for (const [iconName, token] of Object.entries(classMap)) {
162
+ reverseMap[token] = iconName;
163
+ }
164
+
165
+ // Reverse-map wrapper classes in CSS (si-anim--{icon} only)
166
+ let cleanCss = collectionCss;
167
+ for (const [token, iconName] of Object.entries(reverseMap)) {
168
+ cleanCss = cleanCss.replaceAll(`.${token}`, `.si-anim--${iconName}`);
169
+ }
170
+
171
+ for (const svgFile of svgFiles) {
172
+ try {
173
+ const svg = readFileSync(join(packPath, svgFile), 'utf8');
174
+ const id = basename(svgFile, '.svg');
175
+
176
+ // Extract this icon's CSS block from collection CSS
177
+ // The wrapper class is si-anim--{iconName} in clean CSS
178
+ const iconCssClass = `si-anim--${id}`;
179
+ const iconCssLines = extractIconCss(cleanCss, iconCssClass);
180
+
181
+ premiumIcons.push({
182
+ id,
183
+ name: id.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()),
184
+ lib: slug,
185
+ type: 'svg',
186
+ svg,
187
+ css: iconCssLines,
188
+ premium: true,
189
+ });
190
+ } catch {
191
+ // Skip unreadable files
192
+ }
193
+ }
194
+ }
195
+
196
+ return premiumIcons;
197
+ }
198
+
199
+ /**
200
+ * Extract CSS rules relevant to a specific icon from the collection CSS.
201
+ * Returns the matched rules as a string, or the full CSS if extraction fails.
202
+ */
203
+ function extractIconCss(fullCss, iconClass) {
204
+ if (!fullCss || !iconClass) return '';
205
+
206
+ // Match all rule blocks that reference this icon's wrapper class
207
+ const lines = fullCss.split('\n');
208
+ const relevantLines = [];
209
+ let inBlock = false;
210
+ let braceDepth = 0;
211
+ const keyframeNames = new Set();
212
+
213
+ for (const line of lines) {
214
+ if (!inBlock && line.includes(`.${iconClass}`)) {
215
+ inBlock = true;
216
+ braceDepth = 0;
217
+ }
218
+
219
+ if (inBlock) {
220
+ relevantLines.push(line);
221
+ braceDepth += (line.match(/{/g) || []).length;
222
+ braceDepth -= (line.match(/}/g) || []).length;
223
+ if (braceDepth <= 0) {
224
+ inBlock = false;
225
+ }
226
+
227
+ // Track referenced keyframe names
228
+ const animMatch = line.match(/animation[^:]*:\s*([a-z][a-z0-9-]+)/i);
229
+ if (animMatch) keyframeNames.add(animMatch[1]);
230
+ }
231
+ }
232
+
233
+ // Also extract referenced @keyframes blocks
234
+ inBlock = false;
235
+ braceDepth = 0;
236
+ for (const line of lines) {
237
+ if (!inBlock) {
238
+ const kfMatch = line.match(/@keyframes\s+([a-z0-9-]+)/i);
239
+ if (kfMatch && keyframeNames.has(kfMatch[1])) {
240
+ inBlock = true;
241
+ braceDepth = 0;
242
+ }
243
+ }
244
+
245
+ if (inBlock) {
246
+ relevantLines.push(line);
247
+ braceDepth += (line.match(/{/g) || []).length;
248
+ braceDepth -= (line.match(/}/g) || []).length;
249
+ if (braceDepth <= 0) {
250
+ inBlock = false;
251
+ }
252
+ }
253
+ }
254
+
255
+ return relevantLines.join('\n');
256
+ }
257
+
258
+ function loadProductFacts() {
259
+ try {
260
+ return JSON.parse(readFileSync(productFactsPath, 'utf8'));
261
+ } catch {
262
+ return null;
263
+ }
264
+ }
265
+
266
+ function loadPackageMetadata() {
267
+ try {
268
+ return JSON.parse(readFileSync(mcpPackagePath, 'utf8'));
269
+ } catch {
270
+ return { version: '0.0.0' };
271
+ }
272
+ }
273
+
274
+ function loadMaterialExportManifest() {
275
+ if (materialExportState.manifest) return materialExportState.manifest;
276
+ if (existsSync(materialExportManifestPath)) {
277
+ try {
278
+ materialExportState.manifest = JSON.parse(readFileSync(materialExportManifestPath, 'utf8'));
279
+ return materialExportState.manifest;
280
+ } catch {
281
+ // Fall back below
282
+ }
283
+ }
284
+ materialExportState.manifest = MATERIAL_EXPORT_MANIFEST_FALLBACK;
285
+ return materialExportState.manifest;
286
+ }
287
+
288
+ function getMaterialExportAxes(style = VARIANT_STYLES.OUTLINE) {
289
+ const manifest = loadMaterialExportManifest();
290
+ const normalizedStyle = normalizeRequestedStyle(style);
291
+ return {
292
+ ...MATERIAL_EXPORT_DEFAULT_AXES,
293
+ ...(manifest?.defaultAxes || {}),
294
+ fill: normalizedStyle === VARIANT_STYLES.SOLID ? 1 : 0,
295
+ };
296
+ }
297
+
298
+ function normalizeMaterialSnapshotSvg(rawSvg) {
299
+ return normalizeOwnedMaterialSnapshotSvg(rawSvg);
300
+ }
301
+
302
+ async function resolveMaterialSnapshotSvg(icon, style = VARIANT_STYLES.OUTLINE) {
303
+ const manifest = loadMaterialExportManifest();
304
+ const axes = getMaterialExportAxes(style);
305
+ const cacheKey = buildMaterialCacheKey(icon.id, axes);
306
+
307
+ if (materialExportState.svgCache.has(cacheKey)) {
308
+ return {
309
+ svg: materialExportState.svgCache.get(cacheKey),
310
+ axes,
311
+ source: 'material-snapshot',
312
+ };
313
+ }
314
+
315
+ if (materialExportState.failedKeys.has(cacheKey)) return null;
316
+
317
+ const entry = getMaterialManifestEntry(manifest, icon.id, axes);
318
+ if (entry?.path) {
319
+ const localPath = join(materialExportDir, entry.path);
320
+ if (existsSync(localPath)) {
321
+ const svg = normalizeMaterialSnapshotSvg(readFileSync(localPath, 'utf8'));
322
+ materialExportState.svgCache.set(cacheKey, svg);
323
+ return {
324
+ svg,
325
+ axes,
326
+ source: 'owned-material-cache:local',
327
+ };
328
+ }
329
+ }
330
+
331
+ const url = buildMaterialOwnedSnapshotUrl(icon.id, axes, manifest);
332
+ let response;
333
+ try {
334
+ response = await fetch(url);
335
+ } catch {
336
+ materialExportState.failedKeys.add(cacheKey);
337
+ return null;
338
+ }
339
+ if (!response.ok) {
340
+ materialExportState.failedKeys.add(cacheKey);
341
+ return null;
342
+ }
343
+
344
+ const svg = normalizeMaterialSnapshotSvg(await response.text());
345
+ materialExportState.svgCache.set(cacheKey, svg);
346
+ return {
347
+ svg,
348
+ axes,
349
+ source: response.headers.get('X-Cache-Status')
350
+ ? `owned-material-cache:${response.headers.get('X-Cache-Status')}`
351
+ : 'owned-material-cache',
352
+ };
353
+ }
354
+
355
+ async function buildToolIconResult(icon, options = {}) {
356
+ const requestedStyle = normalizeRequestedStyle(options.style);
357
+ const resolvedStyle = icon.lib === 'material'
358
+ ? (requestedStyle === VARIANT_STYLES.SOLID ? VARIANT_STYLES.SOLID : VARIANT_STYLES.OUTLINE)
359
+ : (icon.style || VARIANT_STYLES.OUTLINE);
360
+ let svg = icon.svg;
361
+ let materialAxes = null;
362
+ let svgSource = 'native-svg';
363
+
364
+ if (icon.lib === 'material') {
365
+ const resolved = await resolveMaterialSnapshotSvg(icon, resolvedStyle);
366
+ if (!resolved?.svg) return null;
367
+ svg = resolved.svg;
368
+ materialAxes = resolved.axes;
369
+ svgSource = resolved.source;
370
+ }
371
+
372
+ const result = {
373
+ id: icon.id,
374
+ name: icon.name,
375
+ library: icon.lib,
376
+ libraryName: libraryMeta[icon.lib]?.name || icon.lib,
377
+ type: 'svg',
378
+ style: resolvedStyle,
379
+ originalType: icon.type,
380
+ premium: icon.premium || false,
381
+ svg,
382
+ svgSource,
383
+ };
384
+
385
+ if (materialAxes) {
386
+ result.materialExportAxes = materialAxes;
387
+ }
388
+
389
+ if (icon.premium && icon.css) {
390
+ result.css = icon.css;
391
+ result.usage = `<div class="si-anim si-anim--${icon.id}"><!-- paste SVG here --></div>`;
392
+ }
393
+
394
+ if (icon.semantic) {
395
+ result.semantic = icon.semantic;
396
+ }
397
+
398
+ return attachSemanticPayload(result, semanticRegistryMap, icon);
399
+ }
400
+
401
+ const { freeIcons, outlineIcons, solidIcons, synonyms } = loadData();
402
+ const premiumIcons = loadPremiumPacks();
403
+
404
+ // Combined icon set (auth determines which subset is searchable)
405
+ const allIcons = [...freeIcons, ...premiumIcons];
406
+
407
+ // ============================================================
408
+ // Auth State (resolved at startup)
409
+ // ============================================================
410
+ let authState = { authenticated: false, isPro: false, purchasedSlugs: [], userId: null, error: null };
411
+ const shouldLogStartupAuth = process.env.SUPERICONS_MCP_LOG_STARTUP === '1';
412
+
413
+ async function initAuth() {
414
+ authState = await validateApiKey();
415
+ if (shouldLogStartupAuth) {
416
+ if (authState.error) {
417
+ console.error(`[SuperIcons] Auth: ${authState.error}`);
418
+ } else if (authState.isPro) {
419
+ console.error(`[SuperIcons] Auth: Pro (${freeIcons.length} free + ${premiumIcons.length} premium icons)`);
420
+ } else if (authState.purchasedSlugs.length > 0) {
421
+ const purchasedCount = premiumIcons.filter(i => authState.purchasedSlugs.includes(i.lib)).length;
422
+ console.error(`[SuperIcons] Auth: Pack buyer (${freeIcons.length} free + ${purchasedCount} purchased premium icons)`);
423
+ } else if (authState.authenticated) {
424
+ console.error(`[SuperIcons] Auth: Free tier (${freeIcons.length} free icons, ${premiumIcons.length} premium locked)`);
425
+ } else {
426
+ console.error(`[SuperIcons] Auth: Anonymous (${freeIcons.length} free icons)`);
427
+ }
428
+ }
429
+ }
430
+
431
+ // Get the searchable icon set based on auth
432
+ function getAccessibleIcons() {
433
+ if (authState.isPro) return allIcons;
434
+ if (authState.purchasedSlugs.length > 0) {
435
+ const purchased = premiumIcons.filter(i => authState.purchasedSlugs.includes(i.lib));
436
+ return [...freeIcons, ...purchased];
437
+ }
438
+ return freeIcons;
439
+ }
440
+
441
+ function getResultKey(icon, requestedStyle) {
442
+ const normalizedStyle = normalizeRequestedStyle(requestedStyle);
443
+ if (normalizedStyle === VARIANT_STYLES.ANY) {
444
+ return getConceptKeyForIcon(icon) || `${icon.lib}:${icon.id}:${icon.style || VARIANT_STYLES.OUTLINE}`;
445
+ }
446
+ return `${icon.lib}:${icon.id}:${icon.style || VARIANT_STYLES.OUTLINE}`;
447
+ }
448
+
449
+ function mergeOrderedSearchResults(primaryResults, secondaryResults, requestedStyle) {
450
+ const normalizedStyle = normalizeRequestedStyle(requestedStyle);
451
+ const selected = new Map();
452
+ const orderedKeys = [];
453
+
454
+ for (const icon of [...primaryResults, ...secondaryResults]) {
455
+ const key = getResultKey(icon, normalizedStyle);
456
+ const existing = selected.get(key);
457
+
458
+ if (!existing) {
459
+ selected.set(key, icon);
460
+ orderedKeys.push(key);
461
+ continue;
462
+ }
463
+
464
+ if (compareVariantPreference(existing, icon, normalizedStyle) > 0) {
465
+ selected.set(key, icon);
466
+ }
467
+ }
468
+
469
+ return orderedKeys.map((key) => selected.get(key));
470
+ }
471
+
472
+ function rerankIconsForIntent(query, icons) {
473
+ const intentProfile = buildSearchIntentProfile(query);
474
+ if (!intentProfile.expanded) return icons;
475
+
476
+ return icons
477
+ .map((icon, index) => {
478
+ const adjustment = getIntentCandidateAdjustment(icon, intentProfile);
479
+ return {
480
+ icon,
481
+ index,
482
+ score: adjustment.boost - adjustment.penalty,
483
+ };
484
+ })
485
+ .sort((left, right) => {
486
+ if (right.score !== left.score) return right.score - left.score;
487
+ return left.index - right.index;
488
+ })
489
+ .map((entry) => entry.icon);
490
+ }
491
+
492
+ function choosePreferredIconCandidate(candidates, requestedStyle) {
493
+ if (!Array.isArray(candidates) || candidates.length === 0) return null;
494
+ return [...candidates].sort((left, right) => compareVariantPreference(left, right, requestedStyle))[0] || null;
495
+ }
496
+
497
+ // Check if user has access to a specific premium library
498
+ function hasLibraryAccess(library) {
499
+ return hasPremiumLibraryAccess(authState, library);
500
+ }
501
+
502
+ function buildTextResponse(payload) {
503
+ return {
504
+ content: [{ type: 'text', text: typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2) }],
505
+ };
506
+ }
507
+
508
+ function buildWorkflowAccessResponse(featureName, locale = null) {
509
+ return buildTextResponse(localizeWorkflowAccessPayload(
510
+ buildProWorkflowAccessError(authState, featureName),
511
+ locale
512
+ ));
513
+ }
514
+
515
+ function buildStructuredToolErrorResponse(error, fallbackMessage) {
516
+ const payload = {
517
+ error: typeof error?.message === 'string' ? error.message : fallbackMessage,
518
+ };
519
+
520
+ if (typeof error?.code === 'string') {
521
+ payload.code = error.code;
522
+ }
523
+ if (typeof error?.hint === 'string') {
524
+ payload.hint = error.hint;
525
+ }
526
+ if (typeof error?.retryable === 'boolean') {
527
+ payload.retryable = error.retryable;
528
+ }
529
+ if (typeof error?.status === 'number') {
530
+ payload.status = error.status;
531
+ }
532
+ if (typeof error?.retry_after_seconds === 'number') {
533
+ payload.retry_after_seconds = error.retry_after_seconds;
534
+ }
535
+ if (typeof error?.limit_scope === 'string') {
536
+ payload.limit_scope = error.limit_scope;
537
+ }
538
+
539
+ return buildTextResponse(payload);
540
+ }
541
+
542
+ function shouldAllowLocalSearchFallback() {
543
+ const raw = String(process.env.SUPERICONS_ALLOW_LOCAL_SEARCH_FALLBACK || '').trim().toLowerCase();
544
+ if (!raw) return false;
545
+ return raw === '1' || raw === 'true' || raw === 'on';
546
+ }
547
+
548
+ function hasLocalSearchData() {
549
+ return freeIcons.length > 0;
550
+ }
551
+
552
+ function buildHostedIcon(row) {
553
+ if (!row?.icon_id) return null;
554
+ const [libraryFromId, ...idParts] = String(row.icon_id).split(':');
555
+ const library = row.library || row.source_library || libraryFromId;
556
+ const id = idParts.join(':') || row.id || row.name;
557
+ if (!library || !id) return null;
558
+ if (!row.svg && library !== 'material') return null;
559
+
560
+ return {
561
+ id,
562
+ name: row.name || id.replace(/[-_]/g, ' '),
563
+ lib: library,
564
+ type: row.icon_type || 'svg',
565
+ style: row.style || VARIANT_STYLES.OUTLINE,
566
+ svg: row.svg,
567
+ semantic: row.semantic || null,
568
+ premium: false,
569
+ hosted: true,
570
+ };
571
+ }
572
+
573
+ function buildSelectorInstructions(selectorMode, selectorToken) {
574
+ if (selectorMode === 'literal') {
575
+ return 'The CSS already includes the selector you supplied, so you can use it as returned without replacing any placeholder token.';
576
+ }
577
+
578
+ if (selectorToken) {
579
+ return `Replace ${selectorToken} with the CSS selector that targets your inline <svg> element, for example ".settings-button svg" or "#login-icon svg".`;
580
+ }
581
+
582
+ return 'Use the returned CSS with the SVG selector that targets your inline icon element.';
583
+ }
584
+
585
+ function buildMotionLabIconLookupError(id, library, locale = null) {
586
+ if (libraryMeta[library]?.premium && !hasLibraryAccess(library)) {
587
+ return buildTextResponse({
588
+ ...buildPremiumLibraryAccessError(
589
+ libraryMeta[library].name,
590
+ 'Use a Pro-linked SUPERICONS_API_KEY or a key that includes access to this premium pack.'
591
+ ),
592
+ message: `Motion Lab could not export "${id}" from "${libraryMeta[library].name}" because this premium pack is not available to the current API key. Visit https://supericons.dev`,
593
+ });
594
+ }
595
+
596
+ return buildTextResponse({
597
+ error: 'Icon not found',
598
+ code: 'icon_not_found',
599
+ message: `Icon "${id}" not found in library "${library}". Use search_icons to find available icons.`,
600
+ hint: 'Confirm the icon id and library, or call search_icons before exporting.',
601
+ ...(localizeIconNotFoundHint(locale)
602
+ ? {
603
+ localized: {
604
+ locale,
605
+ hint: localizeIconNotFoundHint(locale),
606
+ },
607
+ }
608
+ : {}),
609
+ retryable: false,
610
+ });
611
+ }
612
+
613
+ async function resolveAccessibleIcon(id, library, options = {}) {
614
+ const requestedStyle = normalizeRequestedStyle(options.style);
615
+ const accessibleIcons = getAccessibleIcons().filter((icon) => icon.lib.toLowerCase() === library.toLowerCase());
616
+
617
+ for (const candidateId of buildVariantLookupCandidates({ library, id, style: requestedStyle })) {
618
+ const candidates = accessibleIcons.filter((icon) =>
619
+ icon.id.toLowerCase() === candidateId.toLowerCase() && iconMatchesRequestedStyle(icon, requestedStyle)
620
+ );
621
+
622
+ const chosen = choosePreferredIconCandidate(candidates, requestedStyle);
623
+ if (chosen) {
624
+ return buildToolIconResult(chosen, { style: requestedStyle });
625
+ }
626
+ }
627
+
628
+ try {
629
+ const hostedPayload = await searchIconsHostedMcp({
630
+ query: id.replace(/[-_]+/g, ' '),
631
+ library,
632
+ limit: 50,
633
+ style: requestedStyle,
634
+ });
635
+ const requestedIds = new Set(
636
+ buildVariantLookupCandidates({ library, id, style: requestedStyle }).map((candidate) => candidate.toLowerCase())
637
+ );
638
+ const hostedIcon = (hostedPayload.results || [])
639
+ .map(buildHostedIcon)
640
+ .filter(Boolean)
641
+ .find((icon) =>
642
+ icon.lib.toLowerCase() === library.toLowerCase()
643
+ && requestedIds.has(icon.id.toLowerCase())
644
+ && iconMatchesRequestedStyle(icon, requestedStyle)
645
+ );
646
+ if (hostedIcon) {
647
+ return buildToolIconResult(hostedIcon, { style: requestedStyle });
648
+ }
649
+ } catch (error) {
650
+ if (!shouldAllowLocalSearchFallback() || !hasLocalSearchData()) {
651
+ throw error;
652
+ }
653
+ }
654
+
655
+ return null;
656
+ }
657
+
658
+ async function searchAccessibleIcons({ query, library, limit, style = VARIANT_STYLES.ANY, locale = null }) {
659
+ const requestedStyle = normalizeRequestedStyle(style);
660
+ const accessibleIcons = getAccessibleIcons();
661
+ const searchableIcons = library
662
+ ? accessibleIcons.filter((icon) => icon.lib === library && iconMatchesRequestedStyle(icon, requestedStyle))
663
+ : accessibleIcons.filter((icon) => iconMatchesRequestedStyle(icon, requestedStyle));
664
+
665
+ let hostedResults = [];
666
+ try {
667
+ const hostedPayload = await searchIconsHostedMcp({ query, library, limit, style: requestedStyle, locale });
668
+ hostedResults = (hostedPayload.results || [])
669
+ .map(buildHostedIcon)
670
+ .filter((icon) => icon && iconMatchesRequestedStyle(icon, requestedStyle));
671
+ if (hostedResults.length > 0) {
672
+ return hostedResults.slice(0, Math.max(1, limit));
673
+ }
674
+ } catch (error) {
675
+ if (!shouldAllowLocalSearchFallback() || !hasLocalSearchData()) {
676
+ throw error;
677
+ }
678
+ }
679
+
680
+ if (!hasLocalSearchData()) return [];
681
+
682
+ const { searchIcons } = await import('./search.js');
683
+ const localResults = searchIcons(query, searchableIcons, synonyms, {
684
+ library,
685
+ limit: Math.max(limit * 2, 20),
686
+ style: requestedStyle,
687
+ locale,
688
+ });
689
+
690
+ const baselineResults = requestedStyle === VARIANT_STYLES.SOLID
691
+ ? localResults
692
+ : mergeOrderedSearchResults(hostedResults, localResults, requestedStyle);
693
+
694
+ const merged = mergeSemanticMatchesIntoIcons(query, baselineResults, searchableIcons, semanticRegistryMap, { limit });
695
+ const intentRanked = rerankIconsForIntent(query, merged);
696
+ return mergeOrderedSearchResults(intentRanked, [], requestedStyle).slice(0, Math.max(1, limit));
697
+ }
698
+
699
+ // ============================================================
700
+ // Library Metadata
701
+ // ============================================================
702
+ const libraryMeta = {
703
+ material: { name: 'Material Symbols', description: 'Google Material Symbols with 4-axis variable font support', hasStroke: false, hasFilled: true, count: 4205, outlineCount: 4205, solidCount: 4205 },
704
+ lucide: { name: 'Lucide', description: 'Beautiful, consistent open-source icons', hasStroke: true, hasFilled: false, count: 1951, outlineCount: 1951, solidCount: 0 },
705
+ tabler: { name: 'Tabler', description: 'Over 5,000 free MIT-licensed SVG icons', hasStroke: true, hasFilled: true, count: 5021, outlineCount: 5021, solidCount: 1053 },
706
+ phosphor: { name: 'Phosphor', description: 'Flexible icon family for interfaces and beyond', hasStroke: false, hasFilled: true, count: 1512, outlineCount: 1512, solidCount: 1512 },
707
+ heroicons: { name: 'Heroicons', description: 'Beautiful hand-crafted SVG icons by Tailwind CSS', hasStroke: true, hasFilled: true, count: 324, outlineCount: 324, solidCount: 324 },
708
+ bootstrap: { name: 'Bootstrap', description: 'Official open-source SVG icon library for Bootstrap', hasStroke: false, hasFilled: true, count: 1373, outlineCount: 1373, solidCount: 705 },
709
+ iconoir: { name: 'Iconoir', description: 'High-quality open-source icon library', hasStroke: true, hasFilled: true, count: 1383, outlineCount: 1383, solidCount: 288 },
710
+ ionicons: { name: 'Ionicons', description: 'Premium open-source icons for Ionic Framework', hasStroke: true, hasFilled: true, count: 421, outlineCount: 421, solidCount: 515 },
711
+ simpleicons: { name: 'Simple Icons', description: '3,400+ SVG icons for popular brands', hasStroke: false, hasFilled: false, count: 3412, outlineCount: 3412, solidCount: 0 },
712
+ mingcute: { name: 'MingCute', description: 'Modern open-source icon set with broad interface coverage', hasStroke: false, hasFilled: true, count: 1662, outlineCount: 1662, solidCount: 1662 },
713
+ };
714
+
715
+ // Add premium pack libraries
716
+ const packDirNames = existsSync(packsDir)
717
+ ? readdirSync(packsDir, { withFileTypes: true }).filter(d => d.isDirectory()).map(d => d.name)
718
+ : [];
719
+
720
+ for (const packName of packDirNames) {
721
+ const displayName = packName.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
722
+ libraryMeta[packName] = {
723
+ name: `${displayName} (Premium)`,
724
+ description: `Premium animated icon pack: ${displayName}`,
725
+ hasStroke: false,
726
+ premium: true,
727
+ };
728
+ }
729
+
730
+ // Compute counts per library
731
+ const libCounts = {};
732
+ for (const icon of allIcons) {
733
+ libCounts[icon.lib] = (libCounts[icon.lib] || 0) + 1;
734
+ }
735
+ const outlineLibCounts = {};
736
+ for (const icon of outlineIcons) {
737
+ outlineLibCounts[icon.lib] = (outlineLibCounts[icon.lib] || 0) + 1;
738
+ }
739
+ const solidLibCounts = {};
740
+ for (const icon of solidIcons) {
741
+ solidLibCounts[icon.lib] = (solidLibCounts[icon.lib] || 0) + 1;
742
+ }
743
+ const freeLibraryCount = Object.values(libraryMeta).filter(meta => !meta.premium).length;
744
+ const productFacts = loadProductFacts();
745
+ const mcpPackage = loadPackageMetadata();
746
+ const freeIconCountLabel = productFacts?.display?.freeIconsAcrossLibrariesFreeLabel
747
+ || `${freeIcons.length.toLocaleString()} free icons across ${freeLibraryCount} libraries`;
748
+ const mcpLocaleSchema = z.enum(SUPPORTED_MCP_OUTPUT_LOCALES);
749
+ const mcpLocaleDescription = `Optional locale for multilingual output. Supported values: ${SUPPORTED_MCP_OUTPUT_LOCALES.join(', ')}.`;
750
+
751
+ // ============================================================
752
+ // MCP Server
753
+ // ============================================================
754
+ const server = new McpServer({
755
+ name: 'supericons',
756
+ version: mcpPackage.version,
757
+ });
758
+
759
+ // --- Tool: search_icons ---
760
+ server.tool(
761
+ 'search_icons',
762
+ `Search ${freeIconCountLabel} using AI-powered synonym expansion. Returns matching free icons with SVG code and SI semantic guidance when available. Pro API keys unlock workflow tools; premium pack icon search is not exposed through MCP yet.`,
763
+ {
764
+ query: z.string().describe('Search term (e.g. "heart", "login", "download arrow")'),
765
+ library: z.string().optional().describe('Filter by free library: lucide, tabler, phosphor, heroicons, bootstrap, iconoir, ionicons, material, simpleicons, or mingcute'),
766
+ style: z.enum(['any', 'outline', 'solid']).optional().default('any').describe('Optional style preference. Use `solid` only for libraries that ship fill or solid variants.'),
767
+ locale: z.enum(['zh-Hans', 'zh-Hant', 'ja', 'ko', 'es', 'de', 'pt', 'ar', 'hi', 'vi', 'th']).optional().describe('Optional locale for multilingual search terms. Supported values: zh-Hans, zh-Hant, ja, ko, es, de, pt, ar, hi, vi, th.'),
768
+ limit: z.number().min(1).max(50).optional().default(10).describe('Max results (1-50, default 10)'),
769
+ },
770
+ async ({ query, library, style, locale, limit }) => {
771
+ // If user requests a premium library without Pro access, return 403-like message
772
+ // Check if requesting premium library without access
773
+ if (libraryMeta[library]?.premium && !hasLibraryAccess(library)) {
774
+ return buildTextResponse(buildPremiumLibraryAccessError(libraryMeta[library].name));
775
+ }
776
+
777
+ let results;
778
+ try {
779
+ results = await searchAccessibleIcons({ query, library, style, locale, limit });
780
+ } catch (error) {
781
+ return buildStructuredToolErrorResponse(error, 'SuperIcons search is unavailable.');
782
+ }
783
+
784
+ if (results.length === 0) {
785
+ void logMcpSearchAttempt({
786
+ query,
787
+ resultCount: 0,
788
+ libraryFilter: library || 'all',
789
+ locale: locale || null,
790
+ });
791
+ return buildTextResponse({
792
+ error: 'No icons found',
793
+ code: 'no_icons_found',
794
+ query,
795
+ library: library || null,
796
+ locale: locale || null,
797
+ hint: locale
798
+ ? 'Try a broader term in the same language, remove the library filter, or search with an English concept.'
799
+ : 'Try a broader term, remove the library filter, or add locale when searching with a non-English term.',
800
+ ...(localizeSearchNoResultsHint(locale, Boolean(locale))
801
+ ? {
802
+ localized: {
803
+ locale,
804
+ hint: localizeSearchNoResultsHint(locale, Boolean(locale)),
805
+ },
806
+ }
807
+ : {}),
808
+ retryable: true,
809
+ });
810
+ }
811
+ const formatted = (await Promise.all(results.map(icon => buildToolIconResult(icon, { style })))).filter(Boolean);
812
+ if (formatted.length === 0) {
813
+ void logMcpSearchAttempt({
814
+ query,
815
+ resultCount: 0,
816
+ libraryFilter: library || 'all',
817
+ locale: locale || null,
818
+ });
819
+ return buildTextResponse(`Icons were found for "${query}"${library ? ` in ${library}` : ''}, but their SVG payloads could not be resolved right now.`);
820
+ }
821
+ void logMcpSearchAttempt({
822
+ query,
823
+ resultCount: formatted.length,
824
+ libraryFilter: library || 'all',
825
+ locale: locale || null,
826
+ });
827
+ void logMcpSearchBatch({
828
+ query,
829
+ results: formatted,
830
+ locale: locale || null,
831
+ });
832
+ return buildTextResponse({ results: formatted, source: 'Powered by SuperIcons (https://supericons.dev)' });
833
+ }
834
+ );
835
+
836
+ // --- Tool: recommend_icons ---
837
+ server.tool(
838
+ 'recommend_icons',
839
+ 'Recommend the most suitable icons for one or more UI slots. Returns shortlist choices with preview-ready SVGs, short reasons, and SI semantic guidance when available.',
840
+ {
841
+ task: z.string().describe('Overall UI task, for example "replace the 4 bottom navigation icons" or "choose icons for a settings panel".'),
842
+ library: z.string().optional().describe('Optional library filter such as mingcute, lucide, tabler, material, or simpleicons.'),
843
+ style: z.enum(['any', 'outline', 'solid']).optional().default('any').describe('Optional style preference. Use `solid` to prefer filled variants where they exist.'),
844
+ locale: z.enum(['zh-Hans', 'zh-Hant', 'ja', 'ko', 'es', 'de', 'pt', 'ar', 'hi', 'vi', 'th']).optional().describe('Optional locale for multilingual slot labels. Supported values: zh-Hans, zh-Hant, ja, ko, es, de, pt, ar, hi, vi, th.'),
845
+ slots: z.array(z.string().min(1)).min(1).max(12).describe('List of UI slots to fill, for example ["Home tab", "Create action", "Alerts tab", "Profile tab"].'),
846
+ limit_per_slot: z.number().min(1).max(5).optional().default(3).describe('How many choices to return per slot, including the top recommendation.'),
847
+ },
848
+ async ({ task, library, style, locale, slots, limit_per_slot }) => {
849
+ if (libraryMeta[library]?.premium && !hasLibraryAccess(library)) {
850
+ return buildTextResponse(buildPremiumLibraryAccessError(libraryMeta[library].name));
851
+ }
852
+
853
+ try {
854
+ const payload = await recommendIconsForTask({
855
+ task,
856
+ library,
857
+ style,
858
+ locale,
859
+ slots,
860
+ limitPerSlot: limit_per_slot,
861
+ semanticMap: semanticRegistryMap,
862
+ searchIconsForQuery: ({ query, library: searchLibrary, style: searchStyle, limit, locale: searchLocale }) =>
863
+ searchAccessibleIcons({ query, library: searchLibrary, style: searchStyle, limit, locale: searchLocale }),
864
+ buildIconResult: buildToolIconResult,
865
+ });
866
+
867
+ return buildTextResponse({
868
+ ...payload,
869
+ source: 'Powered by SuperIcons (https://supericons.dev)',
870
+ });
871
+ } catch (error) {
872
+ return buildStructuredToolErrorResponse(error, 'SuperIcons icon recommendation is unavailable.');
873
+ }
874
+ }
875
+ );
876
+
877
+ // --- Tool: get_icon ---
878
+ server.tool(
879
+ 'get_icon',
880
+ 'Retrieve a specific free icon by its ID and library. Returns the full SVG code, metadata, and SI semantic guidance when available. Premium pack icon retrieval is not exposed through MCP yet.',
881
+ {
882
+ id: z.string().describe('Icon ID (e.g. "heart", "arrow-right", "settings")'),
883
+ library: z.string().describe('Free library name (e.g. "lucide", "tabler", "phosphor", "iconoir", or "mingcute")'),
884
+ style: z.enum(['any', 'outline', 'solid']).optional().default('any').describe('Optional style preference. Use `solid` to request a filled variant when the library supports it.'),
885
+ },
886
+ async ({ id, library, style }) => {
887
+ // Check if requesting premium library without access
888
+ if (libraryMeta[library]?.premium && !hasLibraryAccess(library)) {
889
+ return buildTextResponse({
890
+ ...buildPremiumLibraryAccessError(libraryMeta[library].name),
891
+ message: `Icon "${id}" is in the premium "${libraryMeta[library].name}" pack. Visit https://supericons.dev`,
892
+ });
893
+ }
894
+
895
+ const result = await resolveAccessibleIcon(id, library, { style });
896
+ if (!result) {
897
+ return buildTextResponse(`Icon "${id}" not found in library "${library}". Use search_icons to find available icons.`);
898
+ }
899
+ return buildTextResponse(result);
900
+ }
901
+ );
902
+
903
+ // --- Tool: list_libraries ---
904
+ server.tool(
905
+ 'list_libraries',
906
+ 'List the free icon libraries available through Supericons MCP with their names, icon counts, and descriptions.',
907
+ {},
908
+ async () => {
909
+ const libs = Object.entries(libraryMeta).map(([id, meta]) => ({
910
+ id,
911
+ name: meta.name,
912
+ count: libCounts[id] || meta.count || 0,
913
+ outlineCount: outlineLibCounts[id] || meta.outlineCount || 0,
914
+ solidCount: id === 'material'
915
+ ? (librarySupportsSolid(id) ? outlineLibCounts[id] || meta.solidCount || meta.outlineCount || 0 : 0)
916
+ : (solidLibCounts[id] || meta.solidCount || 0),
917
+ hasStroke: meta.hasStroke,
918
+ hasFilled: meta.hasFilled || librarySupportsSolid(id),
919
+ supportedStyles: meta.hasFilled || librarySupportsSolid(id) ? ['outline', 'solid'] : ['outline'],
920
+ description: meta.description,
921
+ premium: meta.premium || false,
922
+ accessible: meta.premium ? hasLibraryAccess(id) : true,
923
+ }));
924
+ return buildTextResponse(libs);
925
+ }
926
+ );
927
+
928
+ // --- Tool: list_motion_presets ---
929
+ server.tool(
930
+ 'list_motion_presets',
931
+ 'List the Motion Lab presets currently available through Supericons MCP, including preset id, label, group, description, and supported triggers. Motion Lab MCP is a Pro workflow tool.',
932
+ {
933
+ locale: mcpLocaleSchema.optional().describe(mcpLocaleDescription),
934
+ },
935
+ async ({ locale }) => {
936
+ if (!hasProWorkflowAccess(authState)) {
937
+ return buildWorkflowAccessResponse('Motion Lab MCP', locale);
938
+ }
939
+ return buildTextResponse({
940
+ presets: listMotionLabPresets(locale),
941
+ source: 'Powered by SuperIcons Motion Lab',
942
+ });
943
+ }
944
+ );
945
+
946
+ // --- Tool: get_motion_recipe ---
947
+ server.tool(
948
+ 'get_motion_recipe',
949
+ 'Return a human-readable Motion Lab recipe for a preset, trigger, and duration. Use this before export tools when you want to compare presets or confirm the motion fit. Motion Lab MCP is a Pro workflow tool.',
950
+ {
951
+ preset: z.string().describe('Motion preset id, for example pulse, bounce, spin, trace, or typing.'),
952
+ trigger: z.enum(['loop', 'hover', 'click']).optional().default('loop').describe('How the animation should start.'),
953
+ duration_ms: z.number().min(100).max(4000).optional().default(500).describe('Animation duration in milliseconds.'),
954
+ intensity_percent: z.number().min(25).max(200).optional().default(100).describe('Intensity scaling for the preset.'),
955
+ locale: mcpLocaleSchema.optional().describe(mcpLocaleDescription),
956
+ },
957
+ async ({ preset, trigger, duration_ms, intensity_percent, locale }) => {
958
+ if (!hasProWorkflowAccess(authState)) {
959
+ return buildWorkflowAccessResponse('Motion Lab MCP', locale);
960
+ }
961
+ try {
962
+ const recipe = await getMotionLabRecipeHosted({
963
+ preset,
964
+ trigger,
965
+ duration_ms,
966
+ intensity_percent,
967
+ });
968
+ return buildTextResponse(localizeMotionRecipe(recipe, locale));
969
+ } catch (error) {
970
+ return buildStructuredToolErrorResponse(error, 'Motion Lab recipe generation failed.');
971
+ }
972
+ }
973
+ );
974
+
975
+ // --- Tool: export_motion_css ---
976
+ server.tool(
977
+ 'export_motion_css',
978
+ 'Generate Motion Lab CSS for a Supericons icon. The returned CSS uses a portable {{ICON_SELECTOR}} token you replace with your inline SVG selector. Call get_motion_recipe first if you want to compare presets before exporting. Motion Lab MCP is a Pro workflow tool.',
979
+ {
980
+ id: z.string().describe('Icon ID, for example heart, scan-virus, or fingerprint-scan.'),
981
+ library: z.string().describe('Free icon library key, for example lucide, tabler, phosphor, iconoir, or mingcute.'),
982
+ preset: z.string().describe('Motion preset id.'),
983
+ trigger: z.enum(['loop', 'hover', 'click']).optional().default('loop').describe('How the animation should start.'),
984
+ duration_ms: z.number().min(100).max(4000).optional().default(500).describe('Animation duration in milliseconds.'),
985
+ intensity_percent: z.number().min(25).max(200).optional().default(100).describe('Intensity scaling for the preset.'),
986
+ locale: mcpLocaleSchema.optional().describe(mcpLocaleDescription),
987
+ },
988
+ async ({ id, library, preset, trigger, duration_ms, intensity_percent, locale }) => {
989
+ if (!hasProWorkflowAccess(authState)) {
990
+ return buildWorkflowAccessResponse('Motion Lab MCP', locale);
991
+ }
992
+
993
+ const icon = await resolveAccessibleIcon(id, library);
994
+ if (!icon?.svg) {
995
+ return buildMotionLabIconLookupError(id, library, locale);
996
+ }
997
+
998
+ try {
999
+ const cssResponse = await renderMotionLabCssHosted({
1000
+ preset,
1001
+ trigger,
1002
+ duration_ms,
1003
+ intensity_percent,
1004
+ });
1005
+ return buildTextResponse({
1006
+ id: icon.id,
1007
+ library: icon.library,
1008
+ preset: localizeMotionRecipe(
1009
+ await getMotionLabRecipeHosted({ preset, trigger, duration_ms, intensity_percent }),
1010
+ locale
1011
+ ),
1012
+ css: cssResponse.css,
1013
+ selector_mode: cssResponse.selector_mode,
1014
+ ...(cssResponse.selector_token ? { selector_token: cssResponse.selector_token } : {}),
1015
+ selector_instructions: buildSelectorInstructions(cssResponse.selector_mode, cssResponse.selector_token),
1016
+ ...(localizeSelectorInstructions(cssResponse.selector_mode, cssResponse.selector_token, locale)
1017
+ ? {
1018
+ localized_selector_instructions: localizeSelectorInstructions(
1019
+ cssResponse.selector_mode,
1020
+ cssResponse.selector_token,
1021
+ locale
1022
+ ),
1023
+ }
1024
+ : {}),
1025
+ });
1026
+ } catch (error) {
1027
+ return buildStructuredToolErrorResponse(error, 'Motion Lab CSS export failed.');
1028
+ }
1029
+ }
1030
+ );
1031
+
1032
+ // --- Tool: export_animated_svg ---
1033
+ server.tool(
1034
+ 'export_animated_svg',
1035
+ 'Generate a self-contained animated SVG using Motion Lab presets. Call get_motion_recipe first if you want to compare presets before exporting. Motion Lab MCP is a Pro workflow tool.',
1036
+ {
1037
+ id: z.string().describe('Icon ID, for example heart, scan-virus, or fingerprint-scan.'),
1038
+ library: z.string().describe('Free icon library key, for example lucide, tabler, phosphor, iconoir, or mingcute.'),
1039
+ preset: z.string().describe('Motion preset id.'),
1040
+ trigger: z.enum(['loop', 'hover', 'click']).optional().default('loop').describe('How the animation should start.'),
1041
+ duration_ms: z.number().min(100).max(4000).optional().default(500).describe('Animation duration in milliseconds.'),
1042
+ intensity_percent: z.number().min(25).max(200).optional().default(100).describe('Intensity scaling for the preset.'),
1043
+ color: z.string().optional().describe('Optional CSS color override for icons that inherit currentColor.'),
1044
+ locale: mcpLocaleSchema.optional().describe(mcpLocaleDescription),
1045
+ },
1046
+ async ({ id, library, preset, trigger, duration_ms, intensity_percent, color, locale }) => {
1047
+ if (!hasProWorkflowAccess(authState)) {
1048
+ return buildWorkflowAccessResponse('Motion Lab MCP', locale);
1049
+ }
1050
+
1051
+ const icon = await resolveAccessibleIcon(id, library);
1052
+ if (!icon?.svg) {
1053
+ return buildMotionLabIconLookupError(id, library, locale);
1054
+ }
1055
+
1056
+ try {
1057
+ const animatedSvgResponse = await renderMotionLabAnimatedSvgHosted({
1058
+ svg: icon.svg,
1059
+ preset,
1060
+ trigger,
1061
+ duration_ms,
1062
+ intensity_percent,
1063
+ color: color || null,
1064
+ });
1065
+ return buildTextResponse({
1066
+ id: icon.id,
1067
+ library: icon.library,
1068
+ preset: localizeMotionRecipe(
1069
+ await getMotionLabRecipeHosted({ preset, trigger, duration_ms, intensity_percent }),
1070
+ locale
1071
+ ),
1072
+ animated_svg: animatedSvgResponse.animated_svg,
1073
+ ...(animatedSvgResponse.applied_color ? { applied_color: animatedSvgResponse.applied_color } : {}),
1074
+ });
1075
+ } catch (error) {
1076
+ return buildStructuredToolErrorResponse(error, 'Motion Lab animated SVG export failed.');
1077
+ }
1078
+ }
1079
+ );
1080
+
1081
+ // --- Tool: animate_icon ---
1082
+ server.tool(
1083
+ 'animate_icon',
1084
+ 'Generate both Motion Lab CSS and a self-contained animated SVG for one icon. The CSS output uses a portable {{ICON_SELECTOR}} token you replace with your inline SVG selector. Call get_motion_recipe first if you want to compare presets before exporting. Motion Lab MCP is a Pro workflow tool.',
1085
+ {
1086
+ id: z.string().describe('Icon ID, for example heart, scan-virus, or fingerprint-scan.'),
1087
+ library: z.string().describe('Free icon library key, for example lucide, tabler, phosphor, iconoir, or mingcute.'),
1088
+ preset: z.string().describe('Motion preset id.'),
1089
+ trigger: z.enum(['loop', 'hover', 'click']).optional().default('loop').describe('How the animation should start.'),
1090
+ duration_ms: z.number().min(100).max(4000).optional().default(500).describe('Animation duration in milliseconds.'),
1091
+ intensity_percent: z.number().min(25).max(200).optional().default(100).describe('Intensity scaling for the preset.'),
1092
+ color: z.string().optional().describe('Optional CSS color override for icons that inherit currentColor.'),
1093
+ locale: mcpLocaleSchema.optional().describe(mcpLocaleDescription),
1094
+ },
1095
+ async ({ id, library, preset, trigger, duration_ms, intensity_percent, color, locale }) => {
1096
+ if (!hasProWorkflowAccess(authState)) {
1097
+ return buildWorkflowAccessResponse('Motion Lab MCP', locale);
1098
+ }
1099
+
1100
+ const icon = await resolveAccessibleIcon(id, library);
1101
+ if (!icon?.svg) {
1102
+ return buildMotionLabIconLookupError(id, library, locale);
1103
+ }
1104
+
1105
+ try {
1106
+ const bundle = await animateMotionLabIconHosted({
1107
+ svg: icon.svg,
1108
+ preset,
1109
+ trigger,
1110
+ duration_ms,
1111
+ intensity_percent,
1112
+ color: color || null,
1113
+ });
1114
+ return buildTextResponse({
1115
+ id: icon.id,
1116
+ library: icon.library,
1117
+ recipe: localizeMotionRecipe(bundle.recipe, locale),
1118
+ css: bundle.css,
1119
+ animated_svg: bundle.animated_svg,
1120
+ selector_mode: bundle.selector_mode,
1121
+ ...(bundle.selector_token ? { selector_token: bundle.selector_token } : {}),
1122
+ selector_instructions: buildSelectorInstructions(bundle.selector_mode, bundle.selector_token),
1123
+ ...(localizeSelectorInstructions(bundle.selector_mode, bundle.selector_token, locale)
1124
+ ? {
1125
+ localized_selector_instructions: localizeSelectorInstructions(
1126
+ bundle.selector_mode,
1127
+ bundle.selector_token,
1128
+ locale
1129
+ ),
1130
+ }
1131
+ : {}),
1132
+ ...(bundle.applied_color ? { applied_color: bundle.applied_color } : {}),
1133
+ });
1134
+ } catch (error) {
1135
+ return buildStructuredToolErrorResponse(error, 'Motion Lab bundle export failed.');
1136
+ }
1137
+ }
1138
+ );
1139
+
1140
+ // --- Tool: inspect_converter_options ---
1141
+ server.tool(
1142
+ 'inspect_converter_options',
1143
+ 'List the current Converter MCP options, workflow hints, and recommended starting combinations. Converter MCP is a Pro workflow tool.',
1144
+ {
1145
+ locale: mcpLocaleSchema.optional().describe(mcpLocaleDescription),
1146
+ },
1147
+ async ({ locale }) => {
1148
+ if (!hasProWorkflowAccess(authState)) {
1149
+ return buildWorkflowAccessResponse('Converter MCP', locale);
1150
+ }
1151
+ return buildTextResponse(localizeConverterOptions(getConverterMcpOptions(), locale));
1152
+ }
1153
+ );
1154
+
1155
+ // --- Tool: inspect_converter_input ---
1156
+ server.tool(
1157
+ 'inspect_converter_input',
1158
+ 'Inspect a PNG before tracing. Returns structural hints, likely risks, and recommended starting settings for Converter MCP.',
1159
+ {
1160
+ imageBase64: z.string().describe('PNG as base64 text or data URL.'),
1161
+ mimeType: z.string().optional().describe('Optional MIME type override. Only image/png is currently supported.'),
1162
+ locale: mcpLocaleSchema.optional().describe(mcpLocaleDescription),
1163
+ },
1164
+ async ({ imageBase64, mimeType, locale }) => {
1165
+ if (!hasProWorkflowAccess(authState)) {
1166
+ return buildWorkflowAccessResponse('Converter MCP', locale);
1167
+ }
1168
+ try {
1169
+ return buildTextResponse(inspectConverterInput({
1170
+ imageBase64,
1171
+ mimeType,
1172
+ }));
1173
+ } catch (error) {
1174
+ return buildTextResponse({ error: error.message });
1175
+ }
1176
+ }
1177
+ );
1178
+
1179
+ // --- Tool: convert_svg_to_png ---
1180
+ server.tool(
1181
+ 'convert_svg_to_png',
1182
+ 'Convert an SVG string to PNG. Converter MCP is a Pro workflow tool.',
1183
+ {
1184
+ svg: z.string().describe('Raw SVG string to render.'),
1185
+ targetWidth: z.number().min(16).max(2048).optional().default(512).describe('Output width in pixels.'),
1186
+ background: z.string().optional().default('transparent').describe('Background color: `transparent` or a hex value like `#ffffff`.'),
1187
+ locale: mcpLocaleSchema.optional().describe(mcpLocaleDescription),
1188
+ },
1189
+ async ({ svg, targetWidth, background, locale }) => {
1190
+ if (!hasProWorkflowAccess(authState)) {
1191
+ return buildWorkflowAccessResponse('Converter MCP', locale);
1192
+ }
1193
+ try {
1194
+ return buildTextResponse(convertSvgToPng({
1195
+ svg,
1196
+ targetWidth,
1197
+ background,
1198
+ }));
1199
+ } catch (error) {
1200
+ return buildTextResponse({ error: error.message });
1201
+ }
1202
+ }
1203
+ );
1204
+
1205
+ // --- Tool: convert_png_to_svg ---
1206
+ server.tool(
1207
+ 'convert_png_to_svg',
1208
+ 'Convert a PNG payload to SVG. Converter MCP is a Pro workflow tool.',
1209
+ {
1210
+ imageBase64: z.string().describe('PNG as base64 text or data URL.'),
1211
+ qualityMode: z.enum(['exact', 'compact']).optional().default('exact').describe('Tracing quality mode.'),
1212
+ colorMode: z.enum(['color', 'mono']).optional().default('color').describe('Tracing color mode.'),
1213
+ traceClass: z.enum(['general-color', 'flat-logo-color', 'tile-icon-color', 'tiny-line-icon', 'single-color-mark', 'mono-mask']).optional().default('general-color').describe('Tracing profile tuned for the source image.'),
1214
+ uiMode: z.enum(['logo', 'icon']).optional().default('logo').describe('Output bias for logo or icon-style artwork.'),
1215
+ locale: mcpLocaleSchema.optional().describe(mcpLocaleDescription),
1216
+ },
1217
+ async ({ imageBase64, qualityMode, colorMode, traceClass, uiMode, locale }) => {
1218
+ if (!hasProWorkflowAccess(authState)) {
1219
+ return buildWorkflowAccessResponse('Converter MCP', locale);
1220
+ }
1221
+ try {
1222
+ return buildTextResponse(await convertPngToSvg({
1223
+ imageBase64,
1224
+ qualityMode,
1225
+ colorMode,
1226
+ traceClass,
1227
+ uiMode,
1228
+ }));
1229
+ } catch (error) {
1230
+ return buildTextResponse({ error: error.message });
1231
+ }
1232
+ }
1233
+ );
1234
+
1235
+ // ============================================================
1236
+ // Start
1237
+ // ============================================================
1238
+ await initAuth();
1239
+ const transport = new StdioServerTransport();
1240
+ await server.connect(transport);