// ===================================================================== // MCP tool-space catalog // ===================================================================== // // Mirror of the JS-app catalog (`/api/js-apps`) but for MCP tool // sources: public Hugging Face Spaces that expose the standard Gradio // MCP endpoint and opt into discovery with the `reachy-mini-tool` tag. // // The convention (tags + endpoint) is the one introduced server-side // in the conversation app's MCP integration: // - tag `reachy-mini-tool` : reachy-specific curation signal (what we // filter on here). // - tag `mcp` : advisory "this Space speaks MCP" hint // (surfaced as `extra.hasMcpTag`, never required). // - endpoint `https://.hf.space/gradio_api/mcp/` : where a // Gradio Space publishes its MCP server. // // Tags are advisory only: a Space carrying the tag still has to expose a // working MCP endpoint for a client to use it. This module does NOT probe // the endpoint (that would mean one live MCP handshake per Space on every // catalog refresh); it resolves the deterministic endpoint URL and lets // the client discover/validate tools at install time, exactly like the // conversation app does. // // Pure helpers (URL building, mapping, dedup) are exported for unit tests; // the cache + Express route live in `index.js` alongside the app catalog. // Tag that gates the MCP tool subset. Hyphenated to match the documented // discovery convention (distinct from the underscore app tags like // `reachy_mini_js_app`). export const MCP_TOOL_TAG = 'reachy-mini-tool'; // Advisory "speaks MCP" tag. Surfaced but never used to filter. export const MCP_SPEAKS_TAG = 'mcp'; // Same icon convention as the app catalog: an author commits // `public/icon.svg` (preferred) or `public/icon.png` in the Space repo. const ICON_CANDIDATES = ['public/icon.svg', 'public/icon.png']; /** * Resolve the conventional app icon at indexing time from the `siblings` * file list returned by `?full=true`. Returns the absolute HF resolve URL * when found, `null` otherwise. Kept local (rather than imported from * `index.js`) so this module stays free of the server entry point and can * be imported by tests without booting Express. */ export function findIconUrl(spaceId, siblings) { if (!spaceId || !Array.isArray(siblings)) return null; const files = new Set(); for (const s of siblings) { const name = s && typeof s.rfilename === 'string' ? s.rfilename : null; if (name) files.add(name); } for (const candidate of ICON_CANDIDATES) { if (files.has(candidate)) { return `https://huggingface.co/spaces/${spaceId}/resolve/main/${candidate}`; } } return null; } /** * Build the deterministic Gradio MCP endpoint for a public Space. * * HF derives a Space's default subdomain from its `owner/name` slug by * lowercasing and collapsing every run of non-alphanumeric characters to * a single dash (so `pollen-robotics/reachy_mini_central` becomes * `pollen-robotics-reachy-mini-central`). We reproduce that rule rather * than depend on an optional `host`/`subdomain` field that the spaces * listing API does not always return. This matches the fallback used by * the conversation app's `_build_public_space_mcp_url`. */ export function buildSpaceMcpUrl(spaceId) { const subdomain = String(spaceId || '') .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); return `https://${subdomain}.hf.space/gradio_api/mcp/`; } /** * Map one raw HF Space record (from `?full=true`) into the catalog shape * the clients consume. Mirrors the app mapping in `index.js` and adds the * MCP-specific `mcpUrl` field plus an advisory `hasMcpTag` flag. */ export function mapSpaceToMcpTool(space, { officialSet, blockedSet } = {}) { const spaceId = (space && space.id) || ''; const tags = (space && space.tags) || []; const author = spaceId.split('/')[0]; const name = spaceId.split('/').pop(); const idLower = spaceId.toLowerCase(); return { id: spaceId, name, description: space?.cardData?.short_description || '', url: `https://huggingface.co/spaces/${spaceId}`, // The single field clients actually need to wire up an MCP transport. mcpUrl: buildSpaceMcpUrl(spaceId), source_kind: 'hf_space', isOfficial: officialSet ? officialSet.has(idLower) : false, isBlocked: blockedSet ? blockedSet.has(idLower) : false, iconUrl: findIconUrl(spaceId, space?.siblings), extra: { id: spaceId, author, likes: space?.likes || 0, downloads: space?.downloads || 0, createdAt: space?.createdAt || null, lastModified: space?.lastModified || null, runtime: space?.runtime || null, sdk: space?.cardData?.sdk || space?.sdk || null, tags, // Advisory: did the author also add the generic `mcp` tag? Purely // informational - discovery never depends on it. hasMcpTag: Array.isArray(tags) && tags.includes(MCP_SPEAKS_TAG), cardData: { emoji: space?.cardData?.emoji || '🛠️', short_description: space?.cardData?.short_description || '', sdk: space?.cardData?.sdk || null, tags: space?.cardData?.tags || [], ...space?.cardData, }, }, }; } /** * Pick a winner among tool Spaces sharing the same repo name. Same policy * as the app catalog: official first, then oldest (likely the original), * then most likes as a tiebreaker. Forks of a popular tool keep the * upstream name, and we surface only one to keep the catalog clean. */ export function dedupToolsByName(tools) { const deduped = new Map(); for (const tool of tools) { const key = tool.name.toLowerCase(); const existing = deduped.get(key); if (!existing) { deduped.set(key, tool); continue; } if (tool.isOfficial && !existing.isOfficial) { deduped.set(key, tool); continue; } if (existing.isOfficial) continue; const toolDate = tool.extra?.createdAt ? new Date(tool.extra.createdAt).getTime() : Infinity; const existingDate = existing.extra?.createdAt ? new Date(existing.extra.createdAt).getTime() : Infinity; if (toolDate < existingDate) { deduped.set(key, tool); } else if ( toolDate === existingDate && (tool.extra?.likes || 0) > (existing.extra?.likes || 0) ) { deduped.set(key, tool); } } return [...deduped.values()]; } /** * Fetch the MCP tool catalog from the HF Hub. * * Mirrors `fetchAppsFromHF` in `index.js`: * 1. Pull the official + blocked Space lists from the store dataset * (both best-effort; a missing file means "none"). * 2. List public Spaces carrying the `reachy-mini-tool` tag. * 3. Map each into the catalog shape, attach the resolved MCP URL. * 4. Sort official-first, then by likes. * * Dedup is left to the caller/route (mirrors the app catalog, where each * route owns its own dedup policy). Returns the raw, sorted entries. * * `fetchImpl` is injectable for tests; defaults to the global `fetch`. */ export async function fetchMcpToolsFromHF({ hfSpacesApi, limit, officialListUrl, blockListUrl, fetchImpl = fetch, } = {}) { // 1. Official + block lists (both optional). Reuses the same store // dataset files as the app catalog so a Space blocked there is blocked // here too (the killswitch is global by design). const [officialResponse, blockResponse] = await Promise.all([ officialListUrl ? fetchImpl(officialListUrl).catch(() => null) : null, blockListUrl ? fetchImpl(blockListUrl).catch(() => null) : null, ]); let officialIdList = []; if (officialResponse && officialResponse.ok) { officialIdList = await officialResponse.json().catch(() => []); } const officialSet = new Set( (Array.isArray(officialIdList) ? officialIdList : []).map((id) => String(id).toLowerCase(), ), ); let blockedIdList = []; if (blockResponse && blockResponse.ok) { blockedIdList = await blockResponse.json().catch(() => []); } const blockedSet = new Set( (Array.isArray(blockedIdList) ? blockedIdList : []).map((id) => String(id).toLowerCase(), ), ); // 2. List spaces carrying the MCP tool tag. `full=true` brings the // `siblings` file list (icon resolution) and `cardData` in one call. const spacesUrl = `${hfSpacesApi}?filter=${encodeURIComponent( MCP_TOOL_TAG, )}&full=true&limit=${limit}`; const spacesResponse = await fetchImpl(spacesUrl); if (!spacesResponse || !spacesResponse.ok) { throw new Error( `HF API returned ${spacesResponse ? spacesResponse.status : 'no response'}`, ); } const allSpaces = await spacesResponse.json(); if (!Array.isArray(allSpaces)) { throw new Error('HF API returned a non-array spaces payload'); } // 3. Map into catalog shape. const tools = allSpaces.map((space) => mapSpaceToMcpTool(space, { officialSet, blockedSet }), ); // 4. Sort: official first, then by likes (same order as the app catalog). tools.sort((a, b) => { if (a.isOfficial !== b.isOfficial) return a.isOfficial ? -1 : 1; return (b.extra.likes || 0) - (a.extra.likes || 0); }); return tools; }