reicon-mcp 1.0.0 → 1.0.1

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/cli/index.js CHANGED
@@ -94,7 +94,7 @@ export async function run(argv) {
94
94
  case 'apply': {
95
95
  const name = positional[1];
96
96
  if (!name) {
97
- console.error('Usage: reicon-mcp apply <name> --framework react|vue|svelte|html|svg');
97
+ console.error('Usage: reicon-mcp apply <name> --framework react|react-native|vue|svelte|html|svg [--weight Outline|Filled] [--size 24] [--color #hex] [--file path] [--marker "{/* ICON */}"]');
98
98
  process.exit(1);
99
99
  }
100
100
  const framework = flags.framework || 'react';
package/core/codegen.js CHANGED
@@ -10,7 +10,7 @@ function sizeProp(size, framework) {
10
10
  return ` :size="${size}"`;
11
11
  if (framework === 'html')
12
12
  return ` size="${size}"`;
13
- if (framework === 'svelte' || framework === 'react')
13
+ if (framework === 'svelte' || framework === 'react' || framework === 'react-native')
14
14
  return ` size={${size}}`;
15
15
  return ` width="${size}" height="${size}"`;
16
16
  }
@@ -19,7 +19,7 @@ function colorProp(color, framework) {
19
19
  return ` color="${color}"`;
20
20
  if (framework === 'html')
21
21
  return ` color="${color}"`;
22
- if (framework === 'svelte' || framework === 'react') {
22
+ if (framework === 'svelte' || framework === 'react' || framework === 'react-native') {
23
23
  const val = color === 'currentColor' ? 'currentColor' : `"${color}"`;
24
24
  return ` color={${val}}`;
25
25
  }
@@ -47,6 +47,16 @@ export function generateCode(icon, input) {
47
47
  const usageSnippet = `<${component}${props} />`;
48
48
  return { importStatement, usageSnippet };
49
49
  }
50
+ case 'react-native': {
51
+ const importStatement = `import { ${component} } from 'reicon-react-native';`;
52
+ const props = [
53
+ sizeProp(size, 'react-native'),
54
+ colorAttr('react-native'),
55
+ weightProp(weight, 'react-native'),
56
+ ].join('');
57
+ const usageSnippet = `<${component}${props} />`;
58
+ return { importStatement, usageSnippet };
59
+ }
50
60
  case 'vue': {
51
61
  const importStatement = `import { ${component} } from 'reicon-vue';`;
52
62
  const props = [
package/core/search.js CHANGED
@@ -1,17 +1,99 @@
1
+ // Only flag clearly conversational strings — short "add home icon" should be fine
1
2
  const SENTENCE_MARKERS = /\?|!|\.{2,}/;
2
- const SENTENCE_WORDS = /\b(please|could|would|want|need|show|find|give|get|me|my|the|a|an|icon for|looking for|i am|i'm|can you|help me)\b/i;
3
+ const SENTENCE_WORDS = /\b(please|could|would|want|need|show|find|give|get me|looking for|i am|i'm|can you|help me|what is)\b/i;
3
4
  const SYNONYMS = {
4
5
  close: ['x'],
6
+ dismiss: ['x', 'close'],
7
+ cancel: ['x', 'close'],
5
8
  cart: ['cart', 'cart-large', 'shopping-cart2'],
6
9
  'shopping cart': ['cart', 'cart3', 'shopping-cart2', 'cart-large'],
7
10
  delete: ['trash', 'trash-bin-trash'],
8
11
  remove: ['trash', 'x'],
9
- settings: ['settings', 'settings-minimalistic'],
12
+ bin: ['trash', 'trash-bin-trash'],
13
+ settings: ['settings', 'settings-minimalistic', 'cog'],
14
+ gear: ['settings', 'settings-minimalistic'],
15
+ cog: ['settings'],
10
16
  arrow: ['arrow-right', 'arrow-down', 'arrow-up', 'arrow-left'],
11
17
  search: ['search', 'minimalistic-magnifer'],
18
+ magnifier: ['search', 'minimalistic-magnifer'],
12
19
  heart: ['heart', 'heart-angle'],
20
+ love: ['heart', 'heart-angle'],
21
+ favorite: ['heart', 'star'],
13
22
  trash: ['trash', 'trash-bin-trash'],
14
23
  user: ['user', 'user-circle'],
24
+ person: ['user', 'user-circle'],
25
+ profile: ['user', 'user-circle'],
26
+ avatar: ['user', 'user-circle'],
27
+ home: ['home', 'home-2'],
28
+ house: ['home', 'home-2'],
29
+ menu: ['hamburger-menu', 'menu'],
30
+ hamburger: ['hamburger-menu'],
31
+ check: ['check', 'check-circle', 'check-read'],
32
+ tick: ['check', 'check-circle'],
33
+ notification: ['bell', 'bell-bing'],
34
+ bell: ['bell', 'bell-bing'],
35
+ alert: ['bell', 'shield-warning'],
36
+ warning: ['shield-warning'],
37
+ info: ['info-circle'],
38
+ information: ['info-circle'],
39
+ edit: ['pen', 'pen-2', 'edit'],
40
+ pencil: ['pen', 'pen-2'],
41
+ pen: ['pen', 'pen-2'],
42
+ send: ['send-2', 'send'],
43
+ share: ['share', 'share-2'],
44
+ download: ['download', 'download-minimalistic'],
45
+ upload: ['upload', 'upload-minimalistic'],
46
+ link: ['link', 'link-2'],
47
+ copy: ['copy', 'clipboard'],
48
+ clipboard: ['clipboard', 'copy'],
49
+ globe: ['globe', 'map'],
50
+ world: ['globe'],
51
+ lock: ['lock', 'lock-key-minimalistic'],
52
+ unlock: ['lock-opened', 'lock-opened-2'],
53
+ star: ['star', 'stars'],
54
+ eye: ['eye', 'eye-closed'],
55
+ visibility: ['eye', 'eye-closed'],
56
+ phone: ['phone', 'phone-calling-rounded'],
57
+ call: ['phone', 'phone-calling-rounded'],
58
+ calendar: ['calendar', 'calendar-date'],
59
+ date: ['calendar'],
60
+ time: ['clock', 'stopwatch'],
61
+ clock: ['clock'],
62
+ camera: ['camera'],
63
+ photo: ['camera', 'gallery'],
64
+ image: ['gallery', 'camera'],
65
+ message: ['chat-round', 'letter'],
66
+ chat: ['chat-round', 'chat-square'],
67
+ email: ['letter', 'letter-opened'],
68
+ mail: ['letter', 'letter-opened'],
69
+ add: ['add', 'add-circle'],
70
+ plus: ['add', 'add-circle'],
71
+ minus: ['subtract'],
72
+ subtract: ['subtract'],
73
+ refresh: ['refresh', 'restart'],
74
+ reload: ['refresh', 'restart'],
75
+ filter: ['filter'],
76
+ sort: ['sort', 'alt-arrow-down'],
77
+ grid: ['widget', 'four-circles'],
78
+ list: ['list'],
79
+ tag: ['tag'],
80
+ folder: ['folder', 'folder-open'],
81
+ file: ['file', 'file-text'],
82
+ document: ['file-text', 'document'],
83
+ chart: ['chart', 'pie-chart', 'chart-2'],
84
+ graph: ['chart', 'chart-2'],
85
+ play: ['play'],
86
+ pause: ['pause'],
87
+ stop: ['stop-circle'],
88
+ volume: ['volume'],
89
+ music: ['music-note', 'music-notes'],
90
+ map: ['map', 'map-point'],
91
+ location: ['map-point', 'target'],
92
+ pin: ['map-point'],
93
+ power: ['power'],
94
+ wifi: ['wifi'],
95
+ bluetooth: ['bluetooth'],
96
+ battery: ['battery'],
15
97
  };
16
98
  function normalize(text) {
17
99
  return text.toLowerCase().trim().replace(/\s+/g, ' ');
@@ -26,9 +108,11 @@ export function isSentenceQuery(query) {
26
108
  if (SENTENCE_MARKERS.test(trimmed))
27
109
  return true;
28
110
  const words = tokenize(trimmed);
29
- if (words.length > 6)
111
+ // Only flag > 7 words as a sentence (was 6, which was too aggressive)
112
+ if (words.length > 7)
30
113
  return true;
31
- if (words.length >= 4 && SENTENCE_WORDS.test(trimmed))
114
+ // Only flag if sentence words appear AND query is 5+ words
115
+ if (words.length >= 5 && SENTENCE_WORDS.test(trimmed))
32
116
  return true;
33
117
  if (/^(how|what|where|which|why|when)\b/i.test(trimmed))
34
118
  return true;
@@ -44,37 +128,70 @@ function synonymBoost(query, name) {
44
128
  return 0;
45
129
  return 3000 - idx * 100;
46
130
  }
131
+ // Fuzzy partial match — how many chars of `token` appear sequentially in `str`
132
+ function fuzzyScore(str, token) {
133
+ let ti = 0;
134
+ let hits = 0;
135
+ for (const c of str) {
136
+ if (ti < token.length && c === token[ti]) {
137
+ ti++;
138
+ hits++;
139
+ }
140
+ }
141
+ return ti === token.length ? Math.round((hits / str.length) * 100) : 0;
142
+ }
47
143
  function scoreIcon(icon, query, tokens, weight) {
48
144
  const q = normalize(query);
49
145
  const name = icon.name;
50
146
  const nameNorm = name.replace(/-/g, ' ');
147
+ const catNorm = icon.category.replace(/-/g, ' ').toLowerCase();
51
148
  const tagsNorm = icon.tags.map((t) => normalize(t));
52
149
  let score = 0;
150
+ // Synonym boost
53
151
  score += synonymBoost(query, name);
152
+ // Exact name match
54
153
  if (name === q || name === q.replace(/ /g, '-')) {
55
154
  score += 10000;
56
155
  }
156
+ // Single token exact match
57
157
  if (tokens.length === 1 && name === tokens[0]) {
58
158
  score += 5000;
59
159
  }
160
+ // All tokens appear in name
60
161
  if (tokens.every((t) => name.includes(t) || nameNorm.includes(t))) {
61
162
  score += 2000;
62
163
  }
164
+ // Full tag exact match
63
165
  if (tagsNorm.some((t) => t === q)) {
64
166
  score += 1500;
65
167
  }
66
168
  for (const token of tokens) {
169
+ // Exact tag match
67
170
  if (tagsNorm.some((t) => t === token))
68
171
  score += 800;
172
+ // Name includes token
69
173
  if (name.includes(token))
70
174
  score += 600;
175
+ // Name starts with token (prefix boost)
176
+ if (name.startsWith(token))
177
+ score += 400;
178
+ // Tag includes token
71
179
  if (tagsNorm.some((t) => t.includes(token)))
72
180
  score += 300;
181
+ // Category match
182
+ if (catNorm.includes(token))
183
+ score += 150;
184
+ // Fuzzy fallback — only if no direct match
185
+ const directMatch = name.includes(token) || tagsNorm.some((t) => t.includes(token));
186
+ if (!directMatch) {
187
+ const fuzz = fuzzyScore(name, token);
188
+ if (fuzz > 70)
189
+ score += Math.round(fuzz * 0.8);
190
+ }
73
191
  }
74
- if (tokens.length === 1 && name.startsWith(tokens[0])) {
75
- score += 400;
76
- }
192
+ // Prefer shorter names (more specific)
77
193
  score += Math.max(0, 50 - name.length);
194
+ // Weight filter
78
195
  if (weight) {
79
196
  if (icon.weights[weight])
80
197
  score += 10;
@@ -89,7 +206,8 @@ export function searchIcons(index, query, options = {}) {
89
206
  error: 'Query looks like a full sentence. Use concise keywords instead, such as "cart", "user", or "settings".',
90
207
  };
91
208
  }
92
- const limit = options.limit ?? 5;
209
+ // Default limit raised from 5 → 8 for better agent context
210
+ const limit = options.limit ?? 8;
93
211
  const tokens = tokenize(query);
94
212
  const weights = options.weight ? [options.weight] : ['Outline', 'Filled'];
95
213
  const results = [];
@@ -121,9 +239,14 @@ export function searchIcons(index, query, options = {}) {
121
239
  if (deduped.length >= limit)
122
240
  break;
123
241
  }
242
+ if (deduped.length === 0) {
243
+ return {
244
+ error: `No icons found for "${query}". Try a shorter or different keyword.`,
245
+ };
246
+ }
124
247
  return {
125
248
  results: deduped,
126
- instruction: 'Pick exactly one result from the list above and proceed with view_icon or apply_icon. Do not ask a person to choose.',
249
+ instruction: 'Pick exactly one result from the list above. Prefer the highest score. Then call view_icon to confirm the SVG, or apply_icon to generate ready-to-use code. Do not ask the user to choose.',
127
250
  };
128
251
  }
129
252
  export function findIcon(index, name) {
package/core/types.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export type IconWeight = 'Outline' | 'Filled';
2
- export type Framework = 'react' | 'vue' | 'svelte' | 'html' | 'svg';
2
+ export type Framework = 'react' | 'react-native' | 'vue' | 'svelte' | 'html' | 'svg';
3
3
  export interface IconWeightData {
4
4
  code: string;
5
5
  viewBox: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reicon-mcp",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "type": "module",
5
5
  "description": "MCP server and CLI for browsing and applying Reicon icons.",
6
6
  "bin": {
package/server/index.js CHANGED
@@ -7,41 +7,85 @@ import { handleSearchIcons } from './tools/search-icons.js';
7
7
  import { handleViewIcon } from './tools/view-icon.js';
8
8
  const server = new McpServer({
9
9
  name: 'reicon-mcp',
10
- version: '1.0.0',
10
+ version: '1.0.1',
11
11
  });
12
- server.tool('search_icons', 'Search Reicon icons by keyword. Returns ranked matches for agent selection.', {
13
- query: z.string().describe('Concise search keywords, not full sentences'),
14
- weight: z.enum(['Outline', 'Filled']).optional(),
15
- limit: z.number().optional(),
12
+ server.tool('search_icons', [
13
+ 'Search 2,700+ Reicon icons by keyword.',
14
+ 'Returns ranked matches. Use short, specific terms — not full sentences.',
15
+ 'Good queries: "cart", "user circle", "arrow down", "settings".',
16
+ 'Bad queries: "please find me a heart icon" or "I need a shopping cart".',
17
+ 'After getting results, pick the highest-scoring match and call apply_icon directly.',
18
+ 'Only call view_icon first if you need to inspect the raw SVG before generating code.',
19
+ ].join(' '), {
20
+ query: z.string().describe('Concise keyword(s) — e.g. "cart", "bell", "user circle". Not full sentences.'),
21
+ weight: z
22
+ .enum(['Outline', 'Filled'])
23
+ .optional()
24
+ .describe('Filter by weight. Omit to search both.'),
25
+ limit: z
26
+ .number()
27
+ .int()
28
+ .min(1)
29
+ .max(20)
30
+ .optional()
31
+ .describe('Max results to return. Defaults to 8.'),
16
32
  }, async (args) => {
17
33
  const result = handleSearchIcons(args);
18
34
  return {
19
35
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
20
36
  };
21
37
  });
22
- server.tool('view_icon', 'View raw SVG markup and metadata for a Reicon icon.', {
23
- name: z.string().describe('Icon kebab-case name'),
24
- weight: z.enum(['Outline', 'Filled']),
38
+ server.tool('view_icon', [
39
+ 'Fetch raw SVG markup and metadata for a specific Reicon icon.',
40
+ 'Use this to inspect the SVG before applying, or when you only need the raw markup.',
41
+ 'For generating import/usage code, prefer apply_icon instead.',
42
+ ].join(' '), {
43
+ name: z.string().describe('Icon kebab-case name, e.g. "heart", "arrow-right", "user-circle"'),
44
+ weight: z.enum(['Outline', 'Filled']).describe('Which weight variant to fetch'),
25
45
  }, async (args) => {
26
46
  const result = handleViewIcon(args);
27
47
  return {
28
48
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
29
49
  };
30
50
  });
31
- server.tool('apply_icon', 'Generate copy-pasteable import and usage code for a Reicon icon.', {
32
- name: z.string(),
33
- weight: z.enum(['Outline', 'Filled']),
34
- framework: z.enum(['react', 'vue', 'svelte', 'html', 'svg']),
35
- size: z.number().optional(),
36
- color: z.string().optional(),
37
- componentName: z.string().optional(),
51
+ server.tool('apply_icon', [
52
+ 'Generate ready-to-paste import and usage code for a Reicon icon.',
53
+ 'Returns importStatement and usageSnippet for the chosen framework.',
54
+ 'Supported frameworks: react, react-native, vue, svelte, html (CDN custom element), svg (raw markup).',
55
+ 'Insert importStatement at the top of the file and usageSnippet where the icon should appear.',
56
+ 'Always call search_icons first to confirm the icon name and weight exist.',
57
+ ].join(' '), {
58
+ name: z.string().describe('Icon kebab-case name, e.g. "heart"'),
59
+ weight: z.enum(['Outline', 'Filled']).describe('Icon weight variant'),
60
+ framework: z
61
+ .enum(['react', 'react-native', 'vue', 'svelte', 'html', 'svg'])
62
+ .describe('Target framework or output format'),
63
+ size: z
64
+ .number()
65
+ .int()
66
+ .min(8)
67
+ .max(256)
68
+ .optional()
69
+ .describe('Icon size in pixels. Defaults to 24.'),
70
+ color: z
71
+ .string()
72
+ .optional()
73
+ .describe('CSS color value, e.g. "#ef4444" or "currentColor". Defaults to currentColor.'),
74
+ componentName: z
75
+ .string()
76
+ .optional()
77
+ .describe('Override the component name. Defaults to the PascalCase icon name.'),
38
78
  }, async (args) => {
39
79
  const result = handleApplyIcon(args);
40
80
  return {
41
81
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
42
82
  };
43
83
  });
44
- server.tool('list_categories', 'List all icon categories in the Reicon dataset.', {}, async () => {
84
+ server.tool('list_categories', [
85
+ 'List all icon categories available in the Reicon dataset.',
86
+ 'Use this to explore what types of icons exist before searching.',
87
+ 'Each category can be used as a search keyword with search_icons.',
88
+ ].join(' '), {}, async () => {
45
89
  const result = handleListCategories();
46
90
  return {
47
91
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
@@ -1,4 +1,18 @@
1
1
  import type { ApplyIconInput } from '../../core/types.js';
2
- export declare function handleApplyIcon(args: ApplyIconInput): import("../../core/types.js").ApplyIconOutput | {
2
+ export declare function handleApplyIcon(args: ApplyIconInput): {
3
3
  error: string;
4
+ } | {
5
+ error: string;
6
+ suggestion: string;
7
+ } | {
8
+ meta: {
9
+ name: string;
10
+ weight: import("../../core/types.js").IconWeight;
11
+ framework: import("../../core/types.js").Framework;
12
+ size: number;
13
+ };
14
+ importStatement: string;
15
+ usageSnippet: string;
16
+ error?: undefined;
17
+ suggestion?: undefined;
4
18
  };
@@ -5,7 +5,31 @@ export function handleApplyIcon(args) {
5
5
  const index = loadIndex();
6
6
  const icon = findIcon(index, args.name);
7
7
  if (!icon) {
8
- return { error: `Icon "${args.name}" not found.` };
8
+ // Soft-match: try normalized kebab-case
9
+ const lower = args.name.toLowerCase().replace(/\s+/g, '-');
10
+ const match = index.icons.find((i) => i.name.toLowerCase() === lower ||
11
+ i.pascal.toLowerCase() === lower.replace(/-/g, ''));
12
+ if (match) {
13
+ return {
14
+ error: `Icon "${args.name}" not found. Did you mean "${match.name}"? Call apply_icon again with name="${match.name}".`,
15
+ suggestion: match.name,
16
+ };
17
+ }
18
+ return {
19
+ error: `Icon "${args.name}" not found. Use search_icons to find the correct name.`,
20
+ };
9
21
  }
10
- return generateCode(icon, args);
22
+ const result = generateCode(icon, args);
23
+ if ('error' in result)
24
+ return result;
25
+ return {
26
+ ...result,
27
+ // Echo back key info so the agent can confirm what was applied
28
+ meta: {
29
+ name: icon.name,
30
+ weight: args.weight,
31
+ framework: args.framework,
32
+ size: args.size ?? 24,
33
+ },
34
+ };
11
35
  }
@@ -5,8 +5,25 @@ export declare function handleViewIcon(args: {
5
5
  }): {
6
6
  error: string;
7
7
  } | {
8
- svg: string;
9
- viewBox: string;
10
- tags: string[];
8
+ error: string;
9
+ suggestion: string;
10
+ name?: undefined;
11
+ weight?: undefined;
12
+ availableWeights?: undefined;
13
+ category?: undefined;
14
+ tags?: undefined;
15
+ viewBox?: undefined;
16
+ svg?: undefined;
17
+ hint?: undefined;
18
+ } | {
19
+ name: string;
20
+ weight: IconWeight;
21
+ availableWeights: IconWeight[];
11
22
  category: string;
23
+ tags: string[];
24
+ viewBox: string;
25
+ svg: string;
26
+ hint: string;
27
+ error?: undefined;
28
+ suggestion?: undefined;
12
29
  };
@@ -5,17 +5,31 @@ export function handleViewIcon(args) {
5
5
  const index = loadIndex();
6
6
  const icon = findIcon(index, args.name);
7
7
  if (!icon) {
8
- return { error: `Icon "${args.name}" not found.` };
8
+ // Try a soft match in case casing or dashes differ
9
+ const lower = args.name.toLowerCase().replace(/\s+/g, '-');
10
+ const match = index.icons.find((i) => i.name.toLowerCase() === lower || i.pascal.toLowerCase() === lower.replace(/-/g, ''));
11
+ if (match) {
12
+ return {
13
+ error: `Icon "${args.name}" not found. Did you mean "${match.name}"?`,
14
+ suggestion: match.name,
15
+ };
16
+ }
17
+ return { error: `Icon "${args.name}" not found. Use search_icons to find the correct name.` };
9
18
  }
10
19
  const svg = buildSvgMarkup(icon, args.weight);
11
20
  if (typeof svg !== 'string') {
12
21
  return svg;
13
22
  }
14
23
  const weightData = icon.weights[args.weight];
24
+ const availableWeights = Object.keys(icon.weights);
15
25
  return {
16
- svg,
17
- viewBox: weightData.viewBox,
18
- tags: icon.tags,
26
+ name: icon.name,
27
+ weight: args.weight,
28
+ availableWeights,
19
29
  category: icon.category,
30
+ tags: icon.tags,
31
+ viewBox: weightData.viewBox,
32
+ svg,
33
+ hint: 'Call apply_icon with this name and weight to generate framework-specific code.',
20
34
  };
21
35
  }