reachy-mini-api / server /mcpTools.js
tfrere's picture
tfrere HF Staff
feat(api): fail-closed mobile catalog moderation
21c6750
Raw
History Blame Contribute Delete
9.25 kB
// =====================================================================
// 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://<subdomain>.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;
}