openvibers/openvibe.design Commits

MCP: the icons for agents (search, SVG, code, migrate) (#78)

OP OpenVibers committed 12 days ago · 4be482c5be · 3 files, +102 −1

Plan L50 step 6. openvibe.design/mcp gains four read-only tools, the same answers as /icons:
openvibe_design_icon_search (any word, our name, or a Font Awesome, Lucide or Material name; best first, with the
page and SVG address), openvibe_design_icon_svg (size, colour, style: auto draws the solid at 18 px and under),
openvibe_design_icon_code (inline SVG, JSX, data URI, CSS mask, Android VectorDrawable, ES module, SVG) and
openvibe_design_icon_migrate (every icon a page or component uses, with its twin). aliases.search ranks them.

Changes

ddiff --git a/server/glyphs/aliases.js b/server/glyphs/aliases.js
iindex 70f9438..99f4f75 100644
---- a/server/glyphs/aliases.js
++++ b/server/glyphs/aliases.js
@@@ -7,6 +7,7 @@
* words(glyph) → the search words for a glyph (its synonyms, then every other set's names for it)
* resolve(name) → our glyph for "gear", "fa-gear", "lucide:settings", "material:settings" or "settings", or null
* scan(text) → every icon a page or a component asks for, by set: [{ set, name, count, glyph, brand }]
++ * search(query, n) → the glyphs that best answer a search: its own name, then any set's name, then its words
* LUCIDE, MATERIAL → { theirName: glyph } (only names whose glyph exists)
*/
const glyphs = require('openvibe-shared/glyphs');
@@@ -233,4 +234,24 @@ function scan(text) {
return [...found.values()].sort((a, b) => (a.glyph || a.brand ? 0 : 1) - (b.glyph || b.brand ? 0 : 1) || order[a.set] - order[b.set] || b.count - a.count || (a.name < b.name ? -1 : 1));
}
--module.exports = { words, resolve, scan, LUCIDE, MATERIAL };
++/** Ranked: the exact glyph a name resolves to, then names that start with or hold the query, then any set's names and
++ * the words. Every word of a several-word query must match somewhere. */
++function search(query, n = 24) {
++ const q = String(query || '').trim().toLowerCase().slice(0, 100);
++ if (!q) return glyphs.NAMES.slice(0, n);
++ const exact = resolve(q);
++ const terms = q.split(/\s+/).filter(Boolean);
++ const scored = [];
++ for (const g of glyphs.NAMES) {
++ const fa = Object.entries(glyphs.FA).filter(([, x]) => x === g).map(([f]) => f);
++ const hay = [g, ...fa, ...words(g)];
++ if (!terms.every((t) => hay.some((h) => h.includes(t)))) continue;
++ let score = g === exact ? 100 : g === q ? 90 : g.startsWith(q) ? 70 : g.includes(q) ? 60 : 0;
++ if (!score) score = fa.some((f) => f === q) ? 50 : words(g).some((w) => w === q) ? 45 : fa.some((f) => f.includes(q)) ? 30 : 20;
++ scored.push([g, score]);
++ }
++ if (exact && !scored.some(([g]) => g === exact)) scored.push([exact, 100]);
++ return scored.sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1)).slice(0, n).map(([g]) => g);
++}
++
++module.exports = { words, resolve, scan, search, LUCIDE, MATERIAL };
ddiff --git a/server/http/mcp.js b/server/http/mcp.js
iindex c6d3b38..a5a2138 100644
---- a/server/http/mcp.js
++++ b/server/http/mcp.js
@@@ -4,6 +4,13 @@
const express = require('express');
const components = require('openvibe-shared/components');
const themeTokens = require('../tokens');
++const glyphs = require('openvibe-shared/glyphs');
++const aliases = require('../glyphs/aliases');
++const ex = require('../glyphs/export');
++const formats = require('../glyphs/formats');
++
++const BASE = 'https://openvibe.design';
++const FORMATS = { svg: null, inline_svg: 'inlineSvg', jsx: 'jsx', data_uri: 'dataUri', css_mask: 'cssMask', android: 'vectorDrawable', esm: 'esModule' };
const { version } = require('../../package.json');
const PROTOCOL = '2025-06-18';
@@@ -26,6 +33,26 @@ const TOOLS = [
{ name: 'openvibe_design_validate_kit_document', description: 'Validate a kit document against the component contract and its size limits.', inputSchema: schema(['doc'], {
doc: { type: 'object', description: 'A kit component document.' },
}) },
++ // The OpenVibe glyphs (plan L50 step 6): the same answers as openvibe.design/icons, for agents.
++ { name: 'openvibe_design_icon_search', description: 'Find OpenVibe icons (free, MIT) by a word ("settings", "delete"), our name, or a Font Awesome, Lucide or Material name. Best match first, with each icon\'s page and SVG address.', inputSchema: schema(['query'], {
++ query: { type: 'string', minLength: 1, maxLength: 100 },
++ limit: { type: 'integer', minimum: 1, maximum: 60, default: 12 },
++ }) },
++ { name: 'openvibe_design_icon_svg', description: 'One OpenVibe icon as a standalone SVG. name may be ours or a Font Awesome (fa-gear), Lucide (lucide:settings) or Material (material:delete) name. style auto draws the solid drawing at 18 px and under.', inputSchema: schema(['name'], {
++ name: { type: 'string', minLength: 1, maxLength: 60 },
++ size: { type: 'integer', minimum: 16, maximum: 1024, default: 24 },
++ color: { type: 'string', pattern: '^(mark|[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$', description: 'Hex without #, or mark for the two OpenVibe colours. Default 0f172a.' },
++ style: { type: 'string', enum: ['auto', 'line', 'solid'], default: 'auto' },
++ }) },
++ { name: 'openvibe_design_icon_code', description: 'One OpenVibe icon as code to paste: inline_svg (HTML, Vue, Svelte, Solid; currentColor), jsx (a React component), data_uri, css_mask (a class painted in the text colour), android (VectorDrawable XML), esm (an ES module with the drawings) or svg.', inputSchema: schema(['name', 'format'], {
++ name: { type: 'string', minLength: 1, maxLength: 60 },
++ format: { type: 'string', enum: Object.keys(FORMATS) },
++ size: { type: 'integer', minimum: 16, maximum: 1024, default: 24 },
++ style: { type: 'string', enum: ['auto', 'line', 'solid'], default: 'auto' },
++ }) },
++ { name: 'openvibe_design_icon_migrate', description: 'Read HTML or code and list every Font Awesome, Lucide, Material or MUI icon it uses, with its OpenVibe twin and how often it appears. Read only.', inputSchema: schema(['text'], {
++ text: { type: 'string', minLength: 1, maxLength: 400000 },
++ }) },
].map(tool => ({ ...tool, annotations: { readOnlyHint: true, openWorldHint: false } }));
const rpc = (id, result) => ({ jsonrpc: '2.0', id, result });
@@@ -76,6 +103,33 @@ async function callTool(name, args, { catalog }) {
case 'openvibe_design_validate_kit_document':
if (!Object.prototype.hasOwnProperty.call(args, 'doc')) return toolError('doc is required.');
return toolResult(components.validate(args.doc));
++ case 'openvibe_design_icon_search': {
++ if (typeof args.query !== 'string' || !args.query.trim() || args.query.length > 100) return toolError('query must be a nonempty string of at most 100 characters.');
++ const limit = args.limit === undefined ? 12 : args.limit;
++ if (!Number.isInteger(limit) || limit < 1 || limit > 60) return toolError('limit must be a whole number from 1 to 60.');
++ const fa = (g) => Object.entries(glyphs.FA).filter(([, x]) => x === g).map(([f]) => `fa-${f}`);
++ return toolResult({ icons: aliases.search(args.query, limit).map((g) => ({
++ name: g, page: `${BASE}/icons/${g}`, svg: `${BASE}/icons/${g}.svg`, solid: !!glyphs.solid(g),
++ font_awesome: fa(g).slice(0, 6), words: aliases.words(g).slice(0, 8),
++ })), use: `<script src="${BASE}/ov-icon.js" defer></script> then <ov-icon name="…"></ov-icon>` });
++ }
++ case 'openvibe_design_icon_svg':
++ case 'openvibe_design_icon_code': {
++ const g = typeof args.name === 'string' && args.name.length <= 60 ? aliases.resolve(args.name) : null;
++ if (!g) return toolError('No OpenVibe icon by that name. Try openvibe_design_icon_search.');
++ if (args.size !== undefined && (!Number.isInteger(args.size) || args.size < 16 || args.size > 1024)) return toolError('size must be a whole number from 16 to 1024.');
++ if (args.style !== undefined && !['auto', 'line', 'solid'].includes(args.style)) return toolError('style is auto, line or solid.');
++ if (args.color !== undefined && (typeof args.color !== 'string' || !/^(mark|[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(args.color))) return toolError('color is a hex colour without # (0f172a), or mark.');
++ const opts = ex.options({ size: args.size || 24, style: args.style || 'auto', ...(args.color ? { color: args.color } : {}) });
++ if (name === 'openvibe_design_icon_svg') return toolResult({ name: g, style: ex.drawnAs(g, opts), svg: ex.svg(g, opts), page: `${BASE}/icons/${g}` });
++ if (!Object.prototype.hasOwnProperty.call(FORMATS, args.format)) return toolError(`format is one of ${Object.keys(FORMATS).join(', ')}.`);
++ const fn = FORMATS[args.format];
++ const code = fn === null ? ex.svg(g, opts) : fn === 'esModule' ? formats.esModule(g) : formats[fn](g, opts);
++ return toolResult({ name: g, format: args.format, code });
++ }
++ case 'openvibe_design_icon_migrate':
++ if (typeof args.text !== 'string' || !args.text || args.text.length > 400000) return toolError('text must be a nonempty string of at most 400,000 characters.');
++ return toolResult({ icons: aliases.scan(args.text), page: `${BASE}/icons/migrate` });
default:
return null;
}
ddiff --git a/test/mcp.test.js b/test/mcp.test.js
iindex 978856b..80ed6f5 100644
---- a/test/mcp.test.js
++++ b/test/mcp.test.js
@@@ -22,6 +22,7 @@ const components = require('openvibe-shared/components');
'openvibe_design_list_themes', 'openvibe_design_get_theme_tokens',
'openvibe_design_list_components', 'openvibe_design_get_component',
'openvibe_design_validate_kit_document',
++ 'openvibe_design_icon_search', 'openvibe_design_icon_svg', 'openvibe_design_icon_code', 'openvibe_design_icon_migrate',
]);
assert.ok(listed.every(x => x.inputSchema && x.annotations.readOnlyHint));
assert.strictEqual((await t.get('/mcp', { method: 'OPTIONS' })).status, 204);
@@@ -32,6 +33,31 @@ const components = require('openvibe-shared/components');
assert.strictEqual((await t.get('/.well-known/agent.json')).json().mcp.url, 'https://openvibe.design/mcp');
assert.match((await t.get('/llms.txt')).text, /https:\/\/openvibe\.design\/mcp/);
});
++ await check('the icon tools (plan L50 step 6): search by any word or set\'s name, an SVG, code to paste, a migration', async () => {
++ const found = (await call('openvibe_design_icon_search', { query: 'settings', limit: 3 })).structuredContent;
++ assert.strictEqual(found.icons[0].name, 'gear');
++ assert.strictEqual(found.icons[0].svg, 'https://openvibe.design/icons/gear.svg');
++ assert.ok(found.icons[0].font_awesome.includes('fa-gear') && found.icons[0].words.includes('settings'));
++ assert.match(found.use, /ov-icon\.js/);
++ assert.strictEqual((await call('openvibe_design_icon_search', { query: 'lucide:trash-2' })).structuredContent.icons[0].name, 'trash');
++ assert.strictEqual((await call('openvibe_design_icon_search', { query: '' })).isError, true);
++ assert.strictEqual((await call('openvibe_design_icon_search', { query: 'x', limit: 99 })).isError, true);
++ assert.strictEqual((await call('openvibe_design_icon_svg', { name: 'material:mic', size: 16 })).structuredContent.name, 'mic', 'a Material name');
++ const mic = (await call('openvibe_design_icon_svg', { name: 'mic', size: 16, color: 'ff0000' })).structuredContent;
++ assert.strictEqual(mic.style, 'solid', 'small and auto: the solid drawing');
++ assert.ok(mic.svg.startsWith('<svg xmlns="http://www.w3.org/2000/svg"') && mic.svg.includes('fill="#ff0000"'));
++ assert.strictEqual((await call('openvibe_design_icon_svg', { name: 'mic', size: 48 })).structuredContent.style, 'line');
++ assert.strictEqual((await call('openvibe_design_icon_svg', { name: 'no-such-icon' })).isError, true);
++ assert.strictEqual((await call('openvibe_design_icon_svg', { name: 'mic', color: 'red;}' })).isError, true);
++ const jsx = (await call('openvibe_design_icon_code', { name: 'fa-trash-can', format: 'jsx' })).structuredContent;
++ assert.strictEqual(jsx.name, 'trash');
++ assert.match(jsx.code, /^export function TrashIcon\(props\)/);
++ assert.match((await call('openvibe_design_icon_code', { name: 'robot', format: 'android' })).structuredContent.code, /<vector xmlns:android=/);
++ assert.match((await call('openvibe_design_icon_code', { name: 'heart', format: 'esm' })).structuredContent.code, /export default \{ name, line, solid \};/);
++ assert.strictEqual((await call('openvibe_design_icon_code', { name: 'heart', format: 'png' })).isError, true);
++ const moved = (await call('openvibe_design_icon_migrate', { text: '<i data-lucide="settings"></i><i class="fa-solid fa-house fa-fw"></i>' })).structuredContent;
++ assert.deepStrictEqual(moved.icons.map((f) => [f.set, f.name, f.glyph]), [['fa', 'house', 'home'], ['lucide', 'settings', 'gear']]);
++ });
await check('list themes good and bad arguments', async () => {
const good = await call('openvibe_design_list_themes', { query: 'nig', mode: 'dark' });
assert.strictEqual(good.structuredContent.themes.length, 1);