From 2d876872d57210c0fa7787b1bd381bd0ad7c2895 Mon Sep 17 00:00:00 2001 From: Jonas Jesus Date: Tue, 30 Dec 2025 22:35:54 -0300 Subject: [PATCH 1/8] feat(registry): restore Supabase integration files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restaura arquivos de integração com Supabase que foram perdidos: - registry/server/lib/supabase-client.ts - Cliente Supabase com CRUD - registry/scripts/create-table.sql - Script DDL da tabela mcp_servers - registry/scripts/populate-supabase.ts - Script de sincronização com Registry API Estes arquivos são necessários para a funcionalidade do registry com Supabase. --- registry/scripts/create-table.sql | 137 ++++++ registry/scripts/populate-supabase.ts | 610 +++++++++++++++++++++++++ registry/server/lib/supabase-client.ts | 391 ++++++++++++++++ 3 files changed, 1138 insertions(+) create mode 100644 registry/scripts/create-table.sql create mode 100644 registry/scripts/populate-supabase.ts create mode 100644 registry/server/lib/supabase-client.ts diff --git a/registry/scripts/create-table.sql b/registry/scripts/create-table.sql new file mode 100644 index 00000000..c3f8c114 --- /dev/null +++ b/registry/scripts/create-table.sql @@ -0,0 +1,137 @@ +-- ═══════════════════════════════════════════════════════════════ +-- MCP Servers Table for Registry +-- +-- This table stores ALL data from the MCP Registry API plus +-- additional metadata from the Mesh (tags, categories, etc.) +-- +-- Run this in your Supabase SQL Editor +-- ═══════════════════════════════════════════════════════════════ + +CREATE TABLE IF NOT EXISTS mcp_servers ( + -- ═══════════════════════════════════════════════════════════════ + -- DADOS ORIGINAIS DO REGISTRY (indexados) + -- ═══════════════════════════════════════════════════════════════ + + -- Identificação (chave primária composta para suportar múltiplas versões) + name TEXT NOT NULL, -- "ai.exa/exa" + version TEXT NOT NULL, -- "3.1.3" + PRIMARY KEY (name, version), + schema_url TEXT, -- "$schema" URL + + -- Conteúdo + description TEXT, -- Descrição original do registry (duplicada em short_description) + website_url TEXT, + + -- Objetos complexos (JSONB para queries flexíveis) + repository JSONB, -- {"url": "...", "source": "github"} + remotes JSONB, -- [{"type": "streamable-http", "url": "..."}] + packages JSONB, -- [{"type": "npm", "name": "..."}] + icons JSONB, -- [{"src": "...", "mimeType": "..."}] + + -- Metadados oficiais do registry + registry_status TEXT DEFAULT 'active', -- status do registry oficial + published_at TIMESTAMPTZ, + registry_updated_at TIMESTAMPTZ, + is_latest BOOLEAN DEFAULT TRUE, + + -- ═══════════════════════════════════════════════════════════════ + -- DADOS EXTRAS DA MESH (agregados) + -- ═══════════════════════════════════════════════════════════════ + + -- Metadados descritivos enriquecidos + friendly_name TEXT, -- Nome amigável para UI + short_description TEXT, -- Cópia do description (para consistência com outros campos mesh) + mesh_description TEXT, -- Descrição completa markdown (será populada por IA/manual) + tags TEXT[], -- ["search", "web", "ai"] + categories TEXT[], -- ["productivity", "research"] + + -- Flags da Mesh (curadas manualmente ou por AI) + verified BOOLEAN DEFAULT FALSE, -- Verificado pela mesh + unlisted BOOLEAN DEFAULT TRUE, -- TRUE = não aparece (padrão), FALSE = aparece (allowlist) + has_oauth BOOLEAN DEFAULT FALSE, -- Requer OAuth/autenticação + + -- Flags computadas (preenchidas pelo script de sync) + has_remote BOOLEAN DEFAULT FALSE, -- remotes IS NOT NULL AND jsonb_array_length(remotes) > 0 + is_npm BOOLEAN DEFAULT FALSE, -- packages contém type: "npm" + is_local_repo BOOLEAN DEFAULT FALSE, -- só tem repository, sem remote/npm + + -- ═══════════════════════════════════════════════════════════════ + -- CONTROLE INTERNO + -- ═══════════════════════════════════════════════════════════════ + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- ═══════════════════════════════════════════════════════════════ +-- INDEXES +-- ═══════════════════════════════════════════════════════════════ + +-- Filtros principais +CREATE INDEX IF NOT EXISTS idx_mcp_servers_is_latest ON mcp_servers(is_latest); +CREATE INDEX IF NOT EXISTS idx_mcp_servers_verified ON mcp_servers(verified); +CREATE INDEX IF NOT EXISTS idx_mcp_servers_unlisted ON mcp_servers(unlisted); +CREATE INDEX IF NOT EXISTS idx_mcp_servers_has_remote ON mcp_servers(has_remote); + +-- Índice composto para listagem (query mais comum: is_latest=true + unlisted=false) +CREATE INDEX IF NOT EXISTS idx_mcp_servers_listing ON mcp_servers(is_latest, unlisted, verified DESC, name); + +-- Busca por arrays +CREATE INDEX IF NOT EXISTS idx_mcp_servers_tags ON mcp_servers USING GIN(tags); +CREATE INDEX IF NOT EXISTS idx_mcp_servers_categories ON mcp_servers USING GIN(categories); + +-- Full-text search +CREATE INDEX IF NOT EXISTS idx_mcp_servers_search ON mcp_servers USING GIN( + to_tsvector('english', coalesce(name, '') || ' ' || + coalesce(description, '') || ' ' || + coalesce(friendly_name, '') || ' ' || + coalesce(short_description, '')) +); + +-- Ordenação comum (deprecated - use idx_mcp_servers_listing) +-- CREATE INDEX IF NOT EXISTS idx_mcp_servers_verified_name ON mcp_servers(verified DESC, name); + +-- ═══════════════════════════════════════════════════════════════ +-- TRIGGERS +-- ═══════════════════════════════════════════════════════════════ + +-- Auto-update updated_at timestamp +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ language 'plpgsql'; + +DROP TRIGGER IF EXISTS update_mcp_servers_updated_at ON mcp_servers; +CREATE TRIGGER update_mcp_servers_updated_at + BEFORE UPDATE ON mcp_servers + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- ═══════════════════════════════════════════════════════════════ +-- RLS POLICIES (Row Level Security) +-- ═══════════════════════════════════════════════════════════════ + +-- Enable RLS +ALTER TABLE mcp_servers ENABLE ROW LEVEL SECURITY; + +-- Allow public read access (anon key) +CREATE POLICY "Allow public read access" ON mcp_servers + FOR SELECT + USING (true); + +-- Allow authenticated users to insert/update (service role key) +CREATE POLICY "Allow service role full access" ON mcp_servers + FOR ALL + USING (auth.role() = 'service_role'); + +-- ═══════════════════════════════════════════════════════════════ +-- COMMENTS +-- ═══════════════════════════════════════════════════════════════ + +COMMENT ON TABLE mcp_servers IS 'MCP servers indexed from the official registry with mesh metadata'; +COMMENT ON COLUMN mcp_servers.name IS 'Unique server name from registry (e.g., ai.exa/exa)'; +COMMENT ON COLUMN mcp_servers.verified IS 'Whether the server is verified by mesh'; +COMMENT ON COLUMN mcp_servers.unlisted IS 'TRUE = hidden (default for new servers), FALSE = visible (allowlist servers)'; + diff --git a/registry/scripts/populate-supabase.ts b/registry/scripts/populate-supabase.ts new file mode 100644 index 00000000..cf15ea03 --- /dev/null +++ b/registry/scripts/populate-supabase.ts @@ -0,0 +1,610 @@ +#!/usr/bin/env bun +/** + * Script para popular o Supabase com TODOS os MCPs do Registry + * + * Funcionalidades: + * 1. Cria a tabela mcp_servers se não existir + * 2. Busca todos os servidores da API do Registry + * 3. Computa flags (has_remote, is_npm, is_local_repo) + * 4. Define unlisted baseado na allowlist (allowlist = visible, resto = hidden) + * 5. Migra dados de verified.ts + * 6. Upsert no Supabase + * + * Usage: + * bun run scripts/populate-supabase.ts + * + * Environment variables: + * SUPABASE_URL - Supabase project URL + * SUPABASE_SERVICE_ROLE_KEY - Supabase service role key (for write access) + */ + +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; +import { + VERIFIED_SERVERS, + VERIFIED_SERVER_OVERRIDES, +} from "../server/lib/verified.ts"; + +// ═══════════════════════════════════════════════════════════════ +// SQL para criar a tabela +// ═══════════════════════════════════════════════════════════════ + +const CREATE_TABLE_SQL = ` +-- Tabela principal (chave primária composta para suportar múltiplas versões) +CREATE TABLE IF NOT EXISTS mcp_servers ( + name TEXT NOT NULL, + version TEXT NOT NULL, + PRIMARY KEY (name, version), + schema_url TEXT, + description TEXT, + website_url TEXT, + repository JSONB, + remotes JSONB, + packages JSONB, + icons JSONB, + registry_status TEXT DEFAULT 'active', + published_at TIMESTAMPTZ, + registry_updated_at TIMESTAMPTZ, + is_latest BOOLEAN DEFAULT TRUE, + friendly_name TEXT, + short_description TEXT, + mesh_description TEXT, + tags TEXT[], + categories TEXT[], + verified BOOLEAN DEFAULT FALSE, + unlisted BOOLEAN DEFAULT TRUE, + has_oauth BOOLEAN DEFAULT FALSE, + has_remote BOOLEAN DEFAULT FALSE, + is_npm BOOLEAN DEFAULT FALSE, + is_local_repo BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_mcp_servers_is_latest ON mcp_servers(is_latest); +CREATE INDEX IF NOT EXISTS idx_mcp_servers_verified ON mcp_servers(verified); +CREATE INDEX IF NOT EXISTS idx_mcp_servers_unlisted ON mcp_servers(unlisted); +CREATE INDEX IF NOT EXISTS idx_mcp_servers_has_remote ON mcp_servers(has_remote); +CREATE INDEX IF NOT EXISTS idx_mcp_servers_listing ON mcp_servers(is_latest, unlisted, verified DESC, name); +CREATE INDEX IF NOT EXISTS idx_mcp_servers_tags ON mcp_servers USING GIN(tags); +CREATE INDEX IF NOT EXISTS idx_mcp_servers_categories ON mcp_servers USING GIN(categories); + +-- Trigger para updated_at +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ language 'plpgsql'; + +DROP TRIGGER IF EXISTS update_mcp_servers_updated_at ON mcp_servers; +CREATE TRIGGER update_mcp_servers_updated_at + BEFORE UPDATE ON mcp_servers + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); +`; + +const ENABLE_RLS_SQL = ` +-- Enable RLS +ALTER TABLE mcp_servers ENABLE ROW LEVEL SECURITY; + +-- Allow public read access +DROP POLICY IF EXISTS "Allow public read access" ON mcp_servers; +CREATE POLICY "Allow public read access" ON mcp_servers + FOR SELECT USING (true); + +-- Allow service role full access +DROP POLICY IF EXISTS "Allow service role full access" ON mcp_servers; +CREATE POLICY "Allow service role full access" ON mcp_servers + FOR ALL USING (auth.role() = 'service_role'); +`; + +// ═══════════════════════════════════════════════════════════════ +// Configuration +// ═══════════════════════════════════════════════════════════════ + +const REGISTRY_URL = "https://registry.modelcontextprotocol.io/v0.1/servers"; +const REQUEST_TIMEOUT = 30000; + +// ═══════════════════════════════════════════════════════════════ +// Database Setup +// ═══════════════════════════════════════════════════════════════ + +async function ensureTableExists(supabase: SupabaseClient): Promise { + console.log("🗄️ Verificando/criando tabela mcp_servers...\n"); + + // Executa o SQL para criar tabela (IF NOT EXISTS garante idempotência) + const { error: createError } = await supabase.rpc("exec_sql", { + sql: CREATE_TABLE_SQL, + }); + + // Se o RPC não existir, tenta via query direta (menos seguro, mas funcional) + if ( + createError?.message?.includes("function") || + createError?.code === "42883" + ) { + console.log( + " ⚠️ RPC exec_sql não disponível, tentando criar tabela via select...", + ); + + // Verifica se a tabela existe tentando uma query + const { error: checkError } = await supabase + .from("mcp_servers") + .select("name") + .limit(1); + + if (checkError?.code === "42P01") { + // Tabela não existe - precisa criar manualmente + console.error("\n❌ Tabela mcp_servers não existe!"); + console.error(" Execute o SQL em: registry/scripts/create-table.sql"); + console.error(" No Supabase Dashboard → SQL Editor\n"); + process.exit(1); + } else if (checkError) { + throw new Error(`Erro ao verificar tabela: ${checkError.message}`); + } else { + console.log(" ✅ Tabela mcp_servers já existe\n"); + } + } else if (createError) { + throw new Error(`Erro ao criar tabela: ${createError.message}`); + } else { + console.log(" ✅ Tabela mcp_servers pronta\n"); + + // Tenta habilitar RLS (pode falhar se já estiver habilitado) + await supabase.rpc("exec_sql", { sql: ENABLE_RLS_SQL }).catch(() => { + // Ignora erros de RLS - provavelmente já está configurado + }); + } +} + +// ═══════════════════════════════════════════════════════════════ +// Types +// ═══════════════════════════════════════════════════════════════ + +interface RegistryServer { + server: { + $schema?: string; + name: string; + description?: string; + version: string; + repository?: { url: string; source?: string; subfolder?: string }; + remotes?: Array<{ type: string; url: string }>; + packages?: Array<{ type: string; name: string; version?: string }>; + icons?: Array<{ src: string; mimeType?: string; theme?: string }>; + websiteUrl?: string; + }; + _meta: { + "io.modelcontextprotocol.registry/official"?: { + status: string; + publishedAt: string; + updatedAt: string; + isLatest: boolean; + }; + [key: string]: unknown; + }; +} + +interface RegistryResponse { + servers: RegistryServer[]; + metadata: { + nextCursor?: string; + count: number; + }; +} + +interface McpServerRow { + name: string; + version: string; + schema_url: string | null; + description: string | null; + website_url: string | null; + repository: { url: string; source?: string; subfolder?: string } | null; + remotes: Array<{ type: string; url: string }> | null; + packages: Array<{ type: string; name: string; version?: string }> | null; + icons: Array<{ src: string; mimeType?: string; theme?: string }> | null; + registry_status: string; + published_at: string | null; + registry_updated_at: string | null; + is_latest: boolean; + friendly_name: string | null; + short_description: string | null; + mesh_description: string | null; + tags: string[] | null; + categories: string[] | null; + verified: boolean; + unlisted: boolean; + has_oauth: boolean; + has_remote: boolean; + is_npm: boolean; + is_local_repo: boolean; +} + +// ═══════════════════════════════════════════════════════════════ +// Helper Functions +// ═══════════════════════════════════════════════════════════════ + +/** + * Busca todos os nomes de servidores (apenas latest para obter a lista) + */ +async function fetchAllServerNames(): Promise { + const serverNames: string[] = []; + let cursor: string | undefined; + let pageCount = 0; + + console.log("🔍 Fetching server names from MCP Registry...\n"); + + do { + const url = new URL(REGISTRY_URL); + url.searchParams.set("limit", "100"); + url.searchParams.set("version", "latest"); + if (cursor) { + url.searchParams.set("cursor", cursor); + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT); + + try { + const response = await fetch(url.toString(), { + signal: controller.signal, + headers: { Accept: "application/json" }, + }); + + if (!response.ok) { + throw new Error(`Registry API returned status ${response.status}`); + } + + const data: RegistryResponse = await response.json(); + const names = data.servers.map((s) => s.server.name); + serverNames.push(...names); + cursor = data.metadata.nextCursor; + pageCount++; + + console.log( + ` Page ${pageCount}: +${data.servers.length} servers (total names: ${serverNames.length})`, + ); + } finally { + clearTimeout(timeoutId); + } + } while (cursor); + + console.log(`\n✅ Total server names: ${serverNames.length}`); + return serverNames; +} + +/** + * Busca todas as versões de um servidor com retry para 429 + */ +async function fetchServerVersions( + name: string, + retries = 3, +): Promise { + const baseUrl = REGISTRY_URL.replace("/servers", ""); + const url = `${baseUrl}/servers/${encodeURIComponent(name)}/versions`; + + for (let attempt = 0; attempt <= retries; attempt++) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT); + + try { + const response = await fetch(url, { + signal: controller.signal, + headers: { Accept: "application/json" }, + }); + + if (response.status === 404) { + clearTimeout(timeoutId); + return []; + } + + if (response.status === 429) { + clearTimeout(timeoutId); + // Rate limited - wait exponentially before retry + if (attempt < retries) { + const waitTime = Math.pow(2, attempt) * 2000; // 2s, 4s, 8s + console.log( + ` ⏳ Rate limited on ${name}, waiting ${waitTime}ms (attempt ${attempt + 1}/${retries})`, + ); + await new Promise((resolve) => setTimeout(resolve, waitTime)); + continue; + } + throw new Error("Rate limit exceeded"); + } + + if (!response.ok) { + clearTimeout(timeoutId); + throw new Error( + `Registry API returned status ${response.status}: ${response.statusText}`, + ); + } + + const data = (await response.json()) as { + servers: RegistryServer[]; + metadata: { count: number }; + }; + clearTimeout(timeoutId); + return data.servers; + } catch (error) { + clearTimeout(timeoutId); + if (error instanceof Error) { + if (error.name === "AbortError") { + throw new Error(`Timeout fetching versions for ${name}`); + } + if (attempt === retries) { + throw new Error( + `Error fetching versions for ${name}: ${error.message}`, + ); + } + } + } + } + + return []; +} + +/** + * Busca servidores que precisam ser atualizados (não estão no banco) + */ +async function getServersToUpdate( + supabase: SupabaseClient, + allServerNames: string[], + forceUpdate = false, +): Promise { + // Se forceUpdate = true, retornar todos + if (forceUpdate) { + console.log(" 🔄 Force update enabled - will update all servers"); + return allServerNames; + } + + // Buscar nomes únicos já no banco + const { data: existingServers } = await supabase + .from("mcp_servers") + .select("name") + .eq("is_latest", true); + + const existingNames = new Set( + (existingServers || []).map((s: { name: string }) => s.name), + ); + + // Retornar apenas os que faltam + return allServerNames.filter((name) => !existingNames.has(name)); +} + +/** + * Busca todas as versões de todos os servidores (com controle de concorrência e retry) + */ +async function fetchAllServersWithVersions( + supabase: SupabaseClient, + resumeFrom?: number, + forceUpdate = false, +): Promise { + // 1. Buscar lista de nomes + const allServerNames = await fetchAllServerNames(); + + // 2. Identificar quais precisam ser atualizados + console.log("\n🔍 Checking which servers need to be fetched..."); + const serversToFetch = await getServersToUpdate( + supabase, + allServerNames, + forceUpdate, + ); + + if (serversToFetch.length === 0) { + console.log("✅ All servers are up to date!\n"); + return []; + } + + console.log( + `📦 Need to fetch ${serversToFetch.length} servers (${allServerNames.length - serversToFetch.length} already in DB)\n`, + ); + + // 3. Buscar versões com concorrência reduzida e retry + const CONCURRENT_REQUESTS = 3; // Reduzido para evitar 429 + const BATCH_DELAY = 1000; // 1s entre batches + const allServers: RegistryServer[] = []; + const startFrom = resumeFrom || 0; + + console.log( + `📦 Fetching versions starting from server ${startFrom}/${serversToFetch.length}...\n`, + ); + + for (let i = startFrom; i < serversToFetch.length; i += CONCURRENT_REQUESTS) { + const batch = serversToFetch.slice(i, i + CONCURRENT_REQUESTS); + const promises = batch.map(async (name) => { + try { + const versions = await fetchServerVersions(name); + return { name, versions, success: true }; + } catch (error) { + console.error(` ❌ Failed to fetch ${name}: ${error}`); + return { name, versions: [], success: false }; + } + }); + + const results = await Promise.all(promises); + + // Coletar versões bem-sucedidas + const successfulResults = results.filter((r) => r.success); + const batchServers = successfulResults.flatMap((r) => r.versions); + allServers.push(...batchServers); + + const processed = i + batch.length; + console.log( + ` Processed ${processed}/${serversToFetch.length} servers (${allServers.length} total versions)`, + ); + + // Delay entre batches para evitar rate limiting + if (i + CONCURRENT_REQUESTS < serversToFetch.length) { + await new Promise((resolve) => setTimeout(resolve, BATCH_DELAY)); + } + } + + console.log(`\n✅ Total server versions fetched: ${allServers.length}`); + return allServers; +} + +function transformServerToRow( + server: RegistryServer, + verifiedSet: Set, +): McpServerRow { + const officialMeta = + server._meta["io.modelcontextprotocol.registry/official"]; + const name = server.server.name; + + // Get icon override if exists + const override = VERIFIED_SERVER_OVERRIDES[name]; + const icons = server.server.icons ?? override?.icons ?? null; + const repository = server.server.repository ?? override?.repository ?? null; + + // Compute flags + const hasRemote = (server.server.remotes?.length ?? 0) > 0; + const isNpm = server.server.packages?.some((p) => p.type === "npm") ?? false; + const isLocalRepo = !hasRemote && !isNpm && !!server.server.repository; + + // All new servers are unlisted by default (must be manually approved) + const unlisted = true; + + return { + // Registry data + name, + version: server.server.version, + schema_url: server.server.$schema ?? null, + description: server.server.description ?? null, // Descrição original da API + website_url: server.server.websiteUrl ?? null, + repository, + remotes: server.server.remotes ?? null, + packages: server.server.packages ?? null, + icons, + registry_status: officialMeta?.status ?? "active", + published_at: officialMeta?.publishedAt ?? null, + registry_updated_at: officialMeta?.updatedAt ?? null, + is_latest: officialMeta?.isLatest ?? true, + + // Mesh data + verified: verifiedSet.has(name), + unlisted, + has_oauth: false, + + // Computed flags + has_remote: hasRemote, + is_npm: isNpm, + is_local_repo: isLocalRepo, + + // Duplicar description em short_description (para consistência) + short_description: server.server.description ?? null, + + // To be filled later (manually or AI) + friendly_name: null, + mesh_description: null, + tags: null, + categories: null, + }; +} + +// ═══════════════════════════════════════════════════════════════ +// Main +// ═══════════════════════════════════════════════════════════════ + +async function main() { + console.log("═══════════════════════════════════════════════════════════"); + console.log(" MCP Registry → Supabase Sync"); + console.log("═══════════════════════════════════════════════════════════\n"); + + // Check environment variables + const supabaseUrl = process.env.SUPABASE_URL; + const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + + if (!supabaseUrl || !supabaseKey) { + console.error("❌ Missing environment variables:"); + if (!supabaseUrl) console.error(" - SUPABASE_URL"); + if (!supabaseKey) console.error(" - SUPABASE_SERVICE_ROLE_KEY"); + console.error("\nSet these in your .env file or environment."); + process.exit(1); + } + + // Create Supabase client with service role key (for write access) + const supabase = createClient(supabaseUrl, supabaseKey); + + // Check for FORCE_UPDATE flag + const forceUpdate = process.env.FORCE_UPDATE === "true"; + if (forceUpdate) { + console.log("⚠️ FORCE_UPDATE=true - Will update ALL servers\n"); + } + + try { + // 0. Ensure table exists + await ensureTableExists(supabase); + + // 1. Fetch all server versions from Registry API (only missing ones, or all if force) + const allServers = await fetchAllServersWithVersions( + supabase, + undefined, + forceUpdate, + ); + + // Se não há nada novo, finalizar + if (allServers.length === 0) { + console.log("✅ Nenhum servidor novo para adicionar!"); + return; + } + + // 2. Load verified servers data + const verifiedSet = new Set(VERIFIED_SERVERS); + + console.log(`\n📋 Static data loaded:`); + console.log(` Verified servers: ${verifiedSet.size}`); + + // 3. Transform servers to rows + console.log("\n🔄 Transforming servers..."); + const rows = allServers.map((server) => + transformServerToRow(server, verifiedSet), + ); + + // 4. Upsert to Supabase in batches + console.log("\n📤 Upserting to Supabase..."); + const BATCH_SIZE = 500; + let upsertedCount = 0; + + for (let i = 0; i < rows.length; i += BATCH_SIZE) { + const batch = rows.slice(i, i + BATCH_SIZE); + const { error } = await supabase + .from("mcp_servers") + .upsert(batch, { onConflict: "name,version" }); + + if (error) { + throw new Error(`Upsert error: ${error.message}`); + } + + upsertedCount += batch.length; + console.log(` Upserted ${upsertedCount}/${rows.length} servers`); + } + + // 5. Print stats + console.log( + "\n═══════════════════════════════════════════════════════════", + ); + console.log(" DONE!"); + console.log( + "═══════════════════════════════════════════════════════════\n", + ); + + console.log("📊 Summary:"); + console.log(` Total servers: ${rows.length}`); + console.log(` Verified: ${rows.filter((r) => r.verified).length}`); + console.log( + ` Visible (allowlist): ${rows.filter((r) => !r.unlisted).length}`, + ); + console.log( + ` Hidden (unlisted): ${rows.filter((r) => r.unlisted).length}`, + ); + console.log(` With remote: ${rows.filter((r) => r.has_remote).length}`); + console.log(` With NPM: ${rows.filter((r) => r.is_npm).length}`); + console.log( + ` Local repo only: ${rows.filter((r) => r.is_local_repo).length}`, + ); + } catch (error) { + console.error("\n❌ Error:", error); + process.exit(1); + } +} + +main(); diff --git a/registry/server/lib/supabase-client.ts b/registry/server/lib/supabase-client.ts new file mode 100644 index 00000000..6cc6ec79 --- /dev/null +++ b/registry/server/lib/supabase-client.ts @@ -0,0 +1,391 @@ +/** + * Supabase Client for MCP Registry + * + * Provides functions to query and manage MCP servers in Supabase. + * This replaces the need to call the MCP Registry API at runtime. + */ + +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; + +// ═══════════════════════════════════════════════════════════════ +// Types that reflect EXACTLY the database table +// ═══════════════════════════════════════════════════════════════ + +export interface McpServerRow { + // Registry data + name: string; + version: string; + schema_url: string | null; + description: string | null; + website_url: string | null; + repository: { url: string; source?: string; subfolder?: string } | null; + remotes: Array<{ type: string; url: string }> | null; + packages: Array<{ type: string; name: string; version?: string }> | null; + icons: Array<{ src: string; mimeType?: string; theme?: string }> | null; + registry_status: string; + published_at: string | null; + registry_updated_at: string | null; + is_latest: boolean; + + // Mesh data + friendly_name: string | null; + short_description: string | null; + mesh_description: string | null; + tags: string[] | null; + categories: string[] | null; + verified: boolean; + unlisted: boolean; + has_oauth: boolean; + has_remote: boolean; + is_npm: boolean; + is_local_repo: boolean; + + // Control + created_at: string; + updated_at: string; +} + +// ═══════════════════════════════════════════════════════════════ +// Registry Server type (API response format) +// ═══════════════════════════════════════════════════════════════ + +export interface RegistryServer { + server: { + $schema: string; + name: string; + description: string; + version: string; + repository?: { url: string; source?: string; subfolder?: string }; + remotes?: Array<{ type: string; url: string }>; + packages?: Array<{ type: string; name: string; version?: string }>; + icons?: Array<{ src: string; mimeType?: string; theme?: string }>; + websiteUrl?: string; + [key: string]: unknown; + }; + _meta: { + "io.modelcontextprotocol.registry/official"?: { + status: string; + publishedAt: string; + updatedAt: string; + isLatest: boolean; + }; + "mcp.mesh"?: McpMeshMeta; + [key: string]: unknown; + }; +} + +export interface McpMeshMeta { + friendly_name: string | null; + short_description: string | null; + mesh_description: string | null; + tags: string[] | null; + categories: string[] | null; + verified: boolean; + unlisted: boolean; + has_oauth: boolean; + has_remote: boolean; + is_npm: boolean; + is_local_repo: boolean; +} + +// ═══════════════════════════════════════════════════════════════ +// Client Creation +// ═══════════════════════════════════════════════════════════════ + +export function createSupabaseClient( + supabaseUrl: string, + supabaseKey: string, +): SupabaseClient { + return createClient(supabaseUrl, supabaseKey); +} + +// ═══════════════════════════════════════════════════════════════ +// Row to API Response Conversion +// ═══════════════════════════════════════════════════════════════ + +const DEFAULT_SCHEMA = + "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json"; + +export function rowToRegistryServer(row: McpServerRow): RegistryServer { + return { + server: { + $schema: row.schema_url ?? DEFAULT_SCHEMA, + name: row.name, + description: row.description ?? "", // Descrição original do registry + version: row.version, + ...(row.repository && { repository: row.repository }), + ...(row.remotes && { remotes: row.remotes }), + ...(row.packages && { packages: row.packages }), + ...(row.icons && { icons: row.icons }), + ...(row.website_url && { websiteUrl: row.website_url }), + }, + _meta: { + "io.modelcontextprotocol.registry/official": { + status: row.registry_status ?? "active", + publishedAt: row.published_at ?? new Date().toISOString(), + updatedAt: row.registry_updated_at ?? new Date().toISOString(), + isLatest: row.is_latest ?? true, + }, + "mcp.mesh": { + friendly_name: row.friendly_name, + short_description: row.short_description, + mesh_description: row.mesh_description, + tags: row.tags, + categories: row.categories, + verified: row.verified ?? false, + unlisted: row.unlisted ?? false, + has_oauth: row.has_oauth ?? false, + has_remote: row.has_remote ?? false, + is_npm: row.is_npm ?? false, + is_local_repo: row.is_local_repo ?? false, + }, + }, + }; +} + +// ═══════════════════════════════════════════════════════════════ +// Query Options +// ═══════════════════════════════════════════════════════════════ + +export interface ListServersOptions { + limit?: number; + offset?: number; + search?: string; + tags?: string[]; + categories?: string[]; + verified?: boolean; + hasRemote?: boolean; + includeUnlisted?: boolean; +} + +export interface ListServersResult { + servers: RegistryServer[]; + count: number; + hasMore: boolean; +} + +// ═══════════════════════════════════════════════════════════════ +// Query Functions +// ═══════════════════════════════════════════════════════════════ + +/** + * List servers from Supabase with filters + */ +export async function listServers( + client: SupabaseClient, + options: ListServersOptions = {}, +): Promise { + const { + limit = 30, + offset = 0, + search, + tags, + categories, + verified, + hasRemote, + includeUnlisted = false, + } = options; + + let query = client.from("mcp_servers").select("*", { count: "exact" }); + + // SEMPRE filtrar apenas a última versão (is_latest: true) + query = query.eq("is_latest", true); + + // Filter unlisted unless explicitly included + if (!includeUnlisted) { + query = query.eq("unlisted", false); + } + + // Filter by verified + if (verified !== undefined) { + query = query.eq("verified", verified); + } + + // Filter by has_remote + if (hasRemote !== undefined) { + query = query.eq("has_remote", hasRemote); + } + + // Filter by tags (contains any) + if (tags && tags.length > 0) { + query = query.overlaps("tags", tags); + } + + // Filter by categories (contains any) + if (categories && categories.length > 0) { + query = query.overlaps("categories", categories); + } + + // Full-text search + if (search) { + query = query.or( + `name.ilike.%${search}%,description.ilike.%${search}%,friendly_name.ilike.%${search}%,short_description.ilike.%${search}%`, + ); + } + + // Order: verified first, then by name + query = query + .order("verified", { ascending: false }) + .order("name", { ascending: true }); + + // Pagination + query = query.range(offset, offset + limit - 1); + + const { data, error, count } = await query; + + if (error) { + throw new Error(`Error listing servers from Supabase: ${error.message}`); + } + + const rows = (data as McpServerRow[]) || []; + const servers = rows.map(rowToRegistryServer); + const totalCount = count ?? 0; + + return { + servers, + count: totalCount, + hasMore: offset + rows.length < totalCount, + }; +} + +/** + * Get a single server by name + */ +export async function getServer( + client: SupabaseClient, + name: string, +): Promise { + const { data, error } = await client + .from("mcp_servers") + .select("*") + .eq("name", name) + .eq("is_latest", true) + .single(); + + if (error) { + if (error.code === "PGRST116") { + // Not found + return null; + } + throw new Error(`Error getting server from Supabase: ${error.message}`); + } + + return data ? rowToRegistryServer(data as McpServerRow) : null; +} + +/** + * Get all versions of a server + */ +export async function getServerVersions( + client: SupabaseClient, + name: string, +): Promise { + const { data, error } = await client + .from("mcp_servers") + .select("*") + .eq("name", name) + .order("version", { ascending: false }); + + if (error) { + throw new Error( + `Error getting server versions from Supabase: ${error.message}`, + ); + } + + const rows = (data as McpServerRow[]) || []; + return rows.map(rowToRegistryServer); +} + +/** + * Upsert a server (insert or update) + */ +export async function upsertServer( + client: SupabaseClient, + data: Partial & { name: string; version: string }, +): Promise { + const { error } = await client + .from("mcp_servers") + .upsert(data, { onConflict: "name,version" }); + + if (error) { + throw new Error(`Error upserting server to Supabase: ${error.message}`); + } +} + +/** + * Upsert multiple servers in batch + */ +export async function upsertServers( + client: SupabaseClient, + servers: Array & { name: string; version: string }>, +): Promise { + // Supabase has a limit of ~1000 rows per upsert, batch if needed + const BATCH_SIZE = 500; + + for (let i = 0; i < servers.length; i += BATCH_SIZE) { + const batch = servers.slice(i, i + BATCH_SIZE); + const { error } = await client + .from("mcp_servers") + .upsert(batch, { onConflict: "name,version" }); + + if (error) { + throw new Error( + `Error upserting servers batch to Supabase: ${error.message}`, + ); + } + } +} + +/** + * Get server count by status + */ +export async function getServerStats(client: SupabaseClient): Promise<{ + total: number; + verified: number; + withRemote: number; + withNpm: number; + unlisted: number; +}> { + const { data, error } = await client.rpc("get_mcp_server_stats"); + + if (error) { + // Fallback to manual count if RPC doesn't exist + const { count: total } = await client + .from("mcp_servers") + .select("*", { count: "exact", head: true }) + .eq("unlisted", false); + + const { count: verified } = await client + .from("mcp_servers") + .select("*", { count: "exact", head: true }) + .eq("unlisted", false) + .eq("verified", true); + + const { count: withRemote } = await client + .from("mcp_servers") + .select("*", { count: "exact", head: true }) + .eq("unlisted", false) + .eq("has_remote", true); + + const { count: withNpm } = await client + .from("mcp_servers") + .select("*", { count: "exact", head: true }) + .eq("unlisted", false) + .eq("is_npm", true); + + const { count: unlisted } = await client + .from("mcp_servers") + .select("*", { count: "exact", head: true }) + .eq("unlisted", true); + + return { + total: total ?? 0, + verified: verified ?? 0, + withRemote: withRemote ?? 0, + withNpm: withNpm ?? 0, + unlisted: unlisted ?? 0, + }; + } + + return data; +} From 2a3fe27e57966cddc8d2a8c35aad8198ea5f3f29 Mon Sep 17 00:00:00 2001 From: Jonas Jesus Date: Fri, 2 Jan 2026 13:20:51 -0300 Subject: [PATCH 2/8] feat(registry): update to full Supabase-based implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Atualiza o registry completo com implementação baseada em Supabase: Changes: - package.json: adiciona @supabase/supabase-js e scripts de sync - main.ts: remove registryUrl do StateSchema (usa env vars) - registry-binding.ts: simplifica drasticamente (-365/+159 linhas) - Usa Supabase client diretamente ao invés da API do Registry - Remove lógica complexa de fallback e cache - Mantém apenas allowlist e blacklist Migração completa de API fetch → Supabase queries para melhor performance. --- registry/package.json | 5 +- registry/server/main.ts | 17 +- registry/server/tools/registry-binding.ts | 524 +++++++--------------- 3 files changed, 168 insertions(+), 378 deletions(-) diff --git a/registry/package.json b/registry/package.json index f448ae47..76438f69 100644 --- a/registry/package.json +++ b/registry/package.json @@ -13,11 +13,14 @@ "check": "tsc --noEmit", "build:server": "NODE_ENV=production bun build server/main.ts --target=bun --outfile=dist/server/main.js", "build": "bun run build:server", - "publish": "cat app.json | deco registry publish -w /shared/deco -y" + "publish": "cat app.json | deco registry publish -w /shared/deco -y", + "sync:supabase": "bun run scripts/populate-supabase.ts", + "sync:supabase:force": "FORCE_UPDATE=true bun run scripts/populate-supabase.ts" }, "dependencies": { "@decocms/bindings": "^1.0.3", "@decocms/runtime": "^1.0.3", + "@supabase/supabase-js": "^2.89.0", "zod": "^3.24.3" }, "devDependencies": { diff --git a/registry/server/main.ts b/registry/server/main.ts index 3186ab9a..ce46edf5 100644 --- a/registry/server/main.ts +++ b/registry/server/main.ts @@ -10,22 +10,15 @@ import { type Env as DecoEnv, StateSchema as BaseStateSchema, } from "../shared/deco.gen.ts"; -import { z } from "zod"; import { tools } from "./tools/index.ts"; /** - * StateSchema with MCP Registry configuration. - * Users can customize the registry URL when installing the MCP. + * StateSchema for MCP Registry. + * Supabase configuration comes from environment variables: + * - SUPABASE_URL + * - SUPABASE_ANON_KEY */ -export const StateSchema = BaseStateSchema.extend({ - registryUrl: z - .string() - .url() - .optional() - .describe( - "MCP registry servers URL (default: https://registry.modelcontextprotocol.io/v0.1/servers)", - ), -}); +export const StateSchema = BaseStateSchema; /** * This Env type is the main context object that is passed to diff --git a/registry/server/tools/registry-binding.ts b/registry/server/tools/registry-binding.ts index 3d963e10..c9c176f3 100644 --- a/registry/server/tools/registry-binding.ts +++ b/registry/server/tools/registry-binding.ts @@ -3,129 +3,49 @@ * * Implements COLLECTION_REGISTRY_LIST and COLLECTION_REGISTRY_GET tools * - * Supports two modes: - * - ALLOWLIST_MODE: Uses pre-generated allowlist for accurate pagination - * - DYNAMIC_MODE: Filters on-the-fly (may lose items between pages) + * Uses Supabase as the single source of truth for all MCP server data */ -import { createTool } from "@decocms/runtime/tools"; +import { createPrivateTool } from "@decocms/runtime/tools"; import { z } from "zod"; import type { Env } from "../main.ts"; -import { StateSchema } from "../main.ts"; import { - listServers, - getServer, - getServerVersions, - parseServerId, - formatServerId, - type RegistryServer, -} from "../lib/registry-client.ts"; -import { BLACKLISTED_SERVERS } from "../lib/blacklist.ts"; -import { ALLOWED_SERVERS } from "../lib/allowlist.ts"; -import { - isServerVerified, - createMeshMeta, - applyServerOverrides, - VERIFIED_SERVERS, -} from "../lib/verified.ts"; - -/** - * Inject mcp.mesh metadata into any _meta object - */ -function injectMeshMeta( - originalMeta: unknown, - serverName: string, -): Record { - const meta = - typeof originalMeta === "object" && originalMeta !== null - ? (originalMeta as Record) - : {}; - - return { - ...meta, - "mcp.mesh": createMeshMeta(serverName), - }; -} - -/** - * Process server data: apply overrides for verified servers (icons, repository) - */ -function processServerData( - serverName: string, - serverData: unknown, -): Record { - const data = - typeof serverData === "object" && serverData !== null - ? (serverData as Record) - : {}; - - // Only apply overrides for verified servers - if (isServerVerified(serverName)) { - return applyServerOverrides(serverName, data); - } - - return data; -} + createSupabaseClient, + listServers as listServersFromSupabase, + getServer as getServerFromSupabase, + getServerVersions as getServerVersionsFromSupabase, +} from "../lib/supabase-client.ts"; // ============================================================================ -// Configuration +// Schema Definitions // ============================================================================ /** - * Enable allowlist mode for accurate pagination - * Set to false to use dynamic filtering (original behavior) + * Server data schema - flexible to accept data from Supabase */ -const USE_ALLOWLIST_MODE = true; +const ServerDataSchema = z.record(z.unknown()).describe("Server data"); -// ============================================================================ -// Schema Definitions -// ============================================================================ +/** + * Meta data schema - flexible to accept metadata + */ +const MetaDataSchema = z.record(z.unknown()).describe("Metadata"); /** - * Schema for a collection item - original API data with 4 additional fields + * Schema for a collection item */ const RegistryServerSchema = z.object({ id: z.string().describe("Unique item identifier (UUID)"), title: z.string().describe("Server name/title"), created_at: z.string().describe("Creation timestamp"), updated_at: z.string().describe("Last update timestamp"), - server: z.any().describe("Original server data from API"), - _meta: z.any().describe("Original metadata from API"), + server: ServerDataSchema, + _meta: MetaDataSchema, }); /** - * Standard WhereExpression schema compatible with @decocms/bindings/collections - * Note: The API only supports simple text search, so all filters are converted to search terms + * WhereExpression schema - using z.unknown() to avoid deep type instantiation */ -const FieldComparisonSchema = z.object({ - field: z.array(z.string()), - operator: z.enum([ - "eq", - "ne", - "gt", - "gte", - "lt", - "lte", - "contains", - "startsWith", - "endsWith", - ]), - value: z.unknown(), -}); - -const WhereExpressionSchema: z.ZodType = z.lazy(() => - z.union([ - FieldComparisonSchema, - z.object({ - operator: z.enum(["and", "or"]), - conditions: z.array(WhereExpressionSchema), - }), - z.object({ - operator: z.literal("not"), - condition: WhereExpressionSchema, - }), - ]), -); +const WhereExpressionSchema = z.unknown(); /** * Legacy simplified where schema for easier filtering @@ -196,15 +116,26 @@ const ListOutputSchema = z.object({ const GetInputSchema = z.object({ id: z .string() - .describe("Server ID (format: 'ai.exa/exa' or 'ai.exa/exa:3.1.1')"), + .describe("Server ID (format: 'ai.exa/exa' or 'ai.exa/exa@3.1.1')"), }); /** - * Output schema para GET - returns original API format + * Output schema para GET */ const GetOutputSchema = z.object({ - server: z.any(), - _meta: z.any(), + server: ServerDataSchema.describe("Server data"), + _meta: MetaDataSchema.describe("Metadata"), +}); + +/** + * Input schema for VERSIONS + */ +const VersionsInputSchema = z.object({ + name: z + .string() + .describe( + "Server name to list versions for (e.g., 'ai.exa/exa' or 'com.example/my-server')", + ), }); // ============================================================================ @@ -213,18 +144,15 @@ const GetOutputSchema = z.object({ /** * Extract search term from WhereExpression or Legacy format - * Since API only supports simple text search, we extract the first value found */ function extractSearchTerm(where: unknown): string | undefined { if (!where || typeof where !== "object") return undefined; const w = where as { - // WhereExpression fields operator?: string; conditions?: unknown[]; field?: string[]; value?: unknown; - // Legacy fields appName?: string; title?: string; binder?: string | string[]; @@ -254,233 +182,92 @@ function extractSearchTerm(where: unknown): string | undefined { return undefined; } -// ============================================================================ -// Tool Implementations -// ============================================================================ - -/** - * ALLOWLIST MODE: Fetch servers by name from the pre-generated allowlist - * This ensures accurate pagination without losing items - */ -async function listServersFromAllowlist( - registryUrl: string | undefined, - startIndex: number, - limit: number, - searchTerm: string | undefined, - version: string, -): Promise<{ - items: Array<{ - id: string; - title: string; - created_at: string; - updated_at: string; - server: unknown; - _meta: unknown; - }>; - nextCursor?: string; -}> { - // Get the list of server names to fetch - // Sort verified servers first, then rest alphabetically - const verifiedInAllowlist = VERIFIED_SERVERS.filter((name) => - ALLOWED_SERVERS.includes(name), - ); - const nonVerified = ALLOWED_SERVERS.filter((name) => !isServerVerified(name)); - let serverNames = [...verifiedInAllowlist, ...nonVerified]; - - // Apply search filter if provided - if (searchTerm) { - const term = searchTerm.toLowerCase(); - serverNames = serverNames.filter((name) => - name.toLowerCase().includes(term), - ); - } - - // Get the slice for this page - const endIndex = startIndex + limit; - const pageNames = serverNames.slice(startIndex, endIndex); - - // Fetch each server in parallel - // Note: version="latest" means get latest, so we pass undefined to getServer - const versionToFetch = version === "latest" ? undefined : version; - - const serverPromises = pageNames.map(async (name) => { - try { - const server = await getServer(name, versionToFetch, registryUrl); - return server; - } catch { - // Server not found or error - skip it - return null; - } - }); - - const servers = await Promise.all(serverPromises); - - // Filter out nulls and map to output format - const items = servers - .filter((s): s is RegistryServer => s !== null) - .map((server) => { - const officialMeta = - server._meta["io.modelcontextprotocol.registry/official"]; - - return { - id: formatServerId(server.server.name, server.server.version), - title: server.server.name, - created_at: - (officialMeta as { publishedAt?: string })?.publishedAt || - new Date().toISOString(), - updated_at: - (officialMeta as { updatedAt?: string })?.updatedAt || - new Date().toISOString(), - server: processServerData(server.server.name, server.server), - _meta: injectMeshMeta(server._meta, server.server.name), - }; - }); - - // Calculate next cursor - only include if there are more items - const hasMore = endIndex < serverNames.length; - - // Don't include nextCursor in response when there are no more items - if (hasMore) { - return { items, nextCursor: String(endIndex) }; - } - return { items }; -} - /** - * DYNAMIC MODE: Filter servers on-the-fly (may lose items between pages) + * Parse server ID into name and version */ -async function listServersDynamic( - registryUrl: string | undefined, - cursor: string | undefined, - limit: number, - searchTerm: string | undefined, - version: string, -): Promise<{ - items: Array<{ - id: string; - title: string; - created_at: string; - updated_at: string; - server: unknown; - _meta: unknown; - }>; - nextCursor?: string; -}> { - const isOfficialRegistry = !registryUrl; - const excludedWords = ["local", "test", "demo", "example"]; - const hasExcludedWord = (name: string) => - excludedWords.some((word) => name.toLowerCase().includes(word)); - - const filterServer = (s: RegistryServer) => { - if (isOfficialRegistry) { - if ( - !s.server.remotes || - !Array.isArray(s.server.remotes) || - s.server.remotes.length === 0 || - BLACKLISTED_SERVERS.includes(s.server.name) || - hasExcludedWord(s.server.name) - ) { - return false; - } - } - return true; - }; - - const allFilteredServers: RegistryServer[] = []; - let currentCursor: string | undefined = cursor; - let lastNextCursor: string | undefined; - - do { - const response = await listServers({ - registryUrl, - cursor: currentCursor, - limit: Math.max(limit, 30), - search: searchTerm, - version, - }); - - const filtered = response.servers.filter(filterServer); - allFilteredServers.push(...filtered); - - lastNextCursor = response.metadata.nextCursor; - currentCursor = lastNextCursor; - } while (allFilteredServers.length < limit && lastNextCursor); - - const items = allFilteredServers.slice(0, limit).map((server) => { - const officialMeta = - server._meta["io.modelcontextprotocol.registry/official"]; +function parseServerId(id: string): { name: string; version?: string } { + const separator = "@"; + const parts = id.split(separator); + if (parts.length === 1) { return { - id: formatServerId(server.server.name, server.server.version), - title: server.server.name, - created_at: - (officialMeta as { publishedAt?: string })?.publishedAt || - new Date().toISOString(), - updated_at: - (officialMeta as { updatedAt?: string })?.updatedAt || - new Date().toISOString(), - server: processServerData(server.server.name, server.server), - _meta: injectMeshMeta(server._meta, server.server.name), + name: parts[0], + version: undefined, }; - }); - - // Don't include nextCursor when there are no more items - if (lastNextCursor) { - return { items, nextCursor: lastNextCursor }; } - return { items }; + + const version = parts[parts.length - 1]; + const name = parts.slice(0, -1).join(separator); + + return { + name, + version, + }; } +// ============================================================================ +// Tool Implementations +// ============================================================================ + /** - * COLLECTION_REGISTRY_LIST - Lists all servers from the registry + * COLLECTION_REGISTRY_LIST - Lists all servers from Supabase */ -export const createListRegistryTool = (env: Env) => - createTool({ +export const createListRegistryTool = (_env: Env) => + createPrivateTool({ id: "COLLECTION_REGISTRY_APP_LIST", description: "Lists MCP servers available in the registry with support for pagination, search, and boolean filters (has_remotes, has_packages, is_latest, etc.)", inputSchema: ListInputSchema, outputSchema: ListOutputSchema, - execute: async ({ context }: { context: any }) => { - const { - limit = 30, - cursor, - where, - version = "latest", - } = context as z.infer; + execute: async ({ + context, + }: { + context: z.infer; + }) => { + const { limit = 30, cursor, where } = context; try { - // Get registry URL from configuration - const registryUrl = - (env.state as z.infer | undefined)?.registryUrl || - undefined; + // Get configuration from environment + const supabaseUrl = process.env.SUPABASE_URL; + const supabaseKey = process.env.SUPABASE_ANON_KEY; + + if (!supabaseUrl || !supabaseKey) { + throw new Error( + "Supabase not configured. Please set SUPABASE_URL and SUPABASE_ANON_KEY environment variables.", + ); + } // Extract search term from where clause const apiSearch = where ? extractSearchTerm(where) : undefined; - // Use allowlist mode for official registry (no custom registryUrl) - const useAllowlist = USE_ALLOWLIST_MODE && !registryUrl; - - if (useAllowlist) { - // ALLOWLIST MODE: Use pre-generated list for accurate pagination - // Cursor is the index in the allowlist - const startIndex = cursor ? parseInt(cursor, 10) : 0; - return await listServersFromAllowlist( - registryUrl, - startIndex, - limit, - apiSearch, - version, - ); - } else { - // DYNAMIC MODE: Filter on-the-fly (original behavior) - return await listServersDynamic( - registryUrl, - cursor, - limit, - apiSearch, - version, - ); + // Query directly from Supabase + const offset = cursor ? parseInt(cursor, 10) : 0; + const client = createSupabaseClient(supabaseUrl, supabaseKey); + + const result = await listServersFromSupabase(client, { + limit, + offset, + search: apiSearch, + hasRemote: true, // Only show servers with remotes + }); + + const items = result.servers.map((server) => ({ + id: `${server.server.name}@${server.server.version}`, + title: server.server.name, + created_at: + server._meta["io.modelcontextprotocol.registry/official"] + ?.publishedAt || new Date().toISOString(), + updated_at: + server._meta["io.modelcontextprotocol.registry/official"] + ?.updatedAt || new Date().toISOString(), + server: server.server, + _meta: server._meta, + })); + + // Calculate next cursor + if (result.hasMore) { + return { items, nextCursor: String(offset + limit) }; } + return { items }; } catch (error) { throw new Error( `Error listing servers: ${error instanceof Error ? error.message : "Unknown error"}`, @@ -490,40 +277,46 @@ export const createListRegistryTool = (env: Env) => }); /** - * COLLECTION_REGISTRY_GET - Gets a specific server from the registry + * COLLECTION_REGISTRY_GET - Gets a specific server from Supabase */ -export const createGetRegistryTool = (env: Env) => - createTool({ +export const createGetRegistryTool = (_env: Env) => + createPrivateTool({ id: "COLLECTION_REGISTRY_APP_GET", description: "Gets a specific MCP server from the registry by ID (format: 'name' or 'name@version')", inputSchema: GetInputSchema, outputSchema: GetOutputSchema, - execute: async ({ context }: { context: any }) => { - const id = context?.id; + execute: async ({ + context, + }: { + context: z.infer; + }) => { + const { id } = context; try { - if (!id) { - throw new Error("Server ID not provided"); - } // Parse ID - const { name, version } = parseServerId(id); + const { name } = parseServerId(id); - // Get registry URL from configuration - const registryUrl = - (env.state as z.infer | undefined)?.registryUrl || - undefined; + // Get configuration from environment + const supabaseUrl = process.env.SUPABASE_URL; + const supabaseKey = process.env.SUPABASE_ANON_KEY; - // Fetch from API - const server = await getServer(name, version, registryUrl); + if (!supabaseUrl || !supabaseKey) { + throw new Error( + "Supabase not configured. Please set SUPABASE_URL and SUPABASE_ANON_KEY environment variables.", + ); + } + + // Query directly from Supabase + const client = createSupabaseClient(supabaseUrl, supabaseKey); + const server = await getServerFromSupabase(client, name); if (!server) { throw new Error(`Server not found: ${id}`); } - // Return with mesh metadata and overrides return { - server: processServerData(server.server.name, server.server), - _meta: injectMeshMeta(server._meta, server.server.name), + server: server.server, + _meta: server._meta, }; } catch (error) { throw new Error( @@ -536,53 +329,54 @@ export const createGetRegistryTool = (env: Env) => /** * COLLECTION_REGISTRY_APP_VERSIONS - Lists all versions of a specific server */ -export const createVersionsRegistryTool = (env: Env) => - createTool({ +export const createVersionsRegistryTool = (_env: Env) => + createPrivateTool({ id: "COLLECTION_REGISTRY_APP_VERSIONS", description: "Lists all available versions of a specific MCP server from the registry", - inputSchema: z.object({ - name: z - .string() - .describe( - "Server name to list versions for (e.g., 'ai.exa/exa' or 'com.example/my-server')", - ), - }), + inputSchema: VersionsInputSchema, outputSchema: z.object({ versions: z .array(RegistryServerSchema) .describe("Array of all available versions for the server"), count: z.number().describe("Total number of versions available"), }), - execute: async ({ context }: { context: any }) => { - const name = context?.name; + execute: async ({ + context, + }: { + context: z.infer; + }) => { + const { name } = context; try { - if (!name) { - throw new Error("Server name not provided"); + // Get configuration from environment + const supabaseUrl = process.env.SUPABASE_URL; + const supabaseKey = process.env.SUPABASE_ANON_KEY; + + if (!supabaseUrl || !supabaseKey) { + throw new Error( + "Supabase not configured. Please set SUPABASE_URL and SUPABASE_ANON_KEY environment variables.", + ); } - // Get registry URL from configuration - const registryUrl = - (env.state as z.infer | undefined)?.registryUrl || - undefined; - - // Fetch from API - const serverVersions = await getServerVersions(name, registryUrl); - - // Map servers to output format with ID and mesh metadata - const versions = serverVersions.map((server) => { - const officialMeta = - server._meta["io.modelcontextprotocol.registry/official"]; - - return { - id: formatServerId(server.server.name, server.server.version), - title: server.server.name, - created_at: officialMeta?.publishedAt || new Date().toISOString(), - updated_at: officialMeta?.updatedAt || new Date().toISOString(), - server: processServerData(server.server.name, server.server), - _meta: injectMeshMeta(server._meta, server.server.name), - }; - }); + // Query directly from Supabase + const client = createSupabaseClient(supabaseUrl, supabaseKey); + const serverVersions = await getServerVersionsFromSupabase( + client, + name, + ); + + const versions = serverVersions.map((server) => ({ + id: `${server.server.name}@${server.server.version}`, + title: server.server.name, + created_at: + server._meta["io.modelcontextprotocol.registry/official"] + ?.publishedAt || new Date().toISOString(), + updated_at: + server._meta["io.modelcontextprotocol.registry/official"] + ?.updatedAt || new Date().toISOString(), + server: server.server, + _meta: server._meta, + })); return { versions, From da17066beaf9cc888bf9b9bb8082cdf1335c68b5 Mon Sep 17 00:00:00 2001 From: Jonas Jesus Date: Fri, 2 Jan 2026 12:57:28 -0300 Subject: [PATCH 3/8] fix(registry): code review fixes and translate PT-BR to EN Code review fixes: - Remove unused 'version' parameter from ListInputSchema - Add is_latest filter to stats fallback queries - Add sanitization for search input to prevent PostgREST injection - Fix RLS policy to hide unlisted items from public access Translations: - Translate all comments and strings from PT-BR to English - Update scripts: enrich-with-ai.ts, populate-supabase.ts - Update server files: supabase-client.ts, registry-binding.ts - Keep code consistent and professional in English --- registry/package.json | 6 +- registry/scripts/create-table.sql | 4 +- registry/scripts/enrich-with-ai.ts | 434 ++++++++++++++++++++++ registry/scripts/populate-supabase.ts | 76 ++-- registry/server/lib/supabase-client.ts | 31 +- registry/server/tools/registry-binding.ts | 11 +- 6 files changed, 510 insertions(+), 52 deletions(-) create mode 100755 registry/scripts/enrich-with-ai.ts diff --git a/registry/package.json b/registry/package.json index 76438f69..cf59e119 100644 --- a/registry/package.json +++ b/registry/package.json @@ -15,7 +15,11 @@ "build": "bun run build:server", "publish": "cat app.json | deco registry publish -w /shared/deco -y", "sync:supabase": "bun run scripts/populate-supabase.ts", - "sync:supabase:force": "FORCE_UPDATE=true bun run scripts/populate-supabase.ts" + "sync:supabase:force": "FORCE_UPDATE=true bun run scripts/populate-supabase.ts", + "enrich:ai": "bun run scripts/enrich-with-ai.ts", + "enrich:ai:force": "bun run scripts/enrich-with-ai.ts --force", + "enrich:ai:test": "bun run scripts/enrich-with-ai.ts --limit=3", + "enrich:ai:retry": "bun run scripts/enrich-with-ai.ts --limit=400" }, "dependencies": { "@decocms/bindings": "^1.0.3", diff --git a/registry/scripts/create-table.sql b/registry/scripts/create-table.sql index c3f8c114..f0e039c6 100644 --- a/registry/scripts/create-table.sql +++ b/registry/scripts/create-table.sql @@ -116,10 +116,10 @@ CREATE TRIGGER update_mcp_servers_updated_at -- Enable RLS ALTER TABLE mcp_servers ENABLE ROW LEVEL SECURITY; --- Allow public read access (anon key) +-- Allow public read access (anon key) - only visible (non-unlisted) items CREATE POLICY "Allow public read access" ON mcp_servers FOR SELECT - USING (true); + USING (unlisted = false); -- Allow authenticated users to insert/update (service role key) CREATE POLICY "Allow service role full access" ON mcp_servers diff --git a/registry/scripts/enrich-with-ai.ts b/registry/scripts/enrich-with-ai.ts new file mode 100755 index 00000000..7492fe1f --- /dev/null +++ b/registry/scripts/enrich-with-ai.ts @@ -0,0 +1,434 @@ +#!/usr/bin/env bun +/** + * Script to enrich Registry MCPs with AI-generated data + * + * Uses OpenRouter MCP with free models to generate: + * - friendly_name: User-friendly display name + * - mesh_description: Detailed markdown description + * - tags: Array of relevant tags + * - categories: Array of categories + * + * Usage: + * bun run scripts/enrich-with-ai.ts [--force] [--limit=10] + * + * Flags: + * --force: Regenerate even for MCPs that already have data + * --limit: Limit how many MCPs to process (default: all) + * + * Environment variables: + * SUPABASE_URL - Supabase project URL + * SUPABASE_SERVICE_ROLE_KEY - Supabase service role key + * OPENROUTER_API_KEY - OpenRouter API key + */ + +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; + +// ═══════════════════════════════════════════════════════════════ +// Configuration +// ═══════════════════════════════════════════════════════════════ + +const OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions"; +const OPENROUTER_API_KEY = + "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"; + +// Recommended models (cheap and always available) +const RECOMMENDED_MODELS = [ + "meta-llama/llama-3.3-70b-instruct", // ~$0.35/1M tokens, excellent quality + "meta-llama/llama-3.1-8b-instruct", // ~$0.05/1M tokens, good quality + "google/gemini-flash-1.5-8b", // ~$0.05/1M tokens +]; + +// Use default model or one specified in env +const MODEL = process.env.OPENROUTER_MODEL || RECOMMENDED_MODELS[1]; // llama-3.1-8b by default + +// ═══════════════════════════════════════════════════════════════ +// Types +// ═══════════════════════════════════════════════════════════════ + +interface McpServer { + name: string; + version: string; + description: string | null; + short_description: string | null; + friendly_name: string | null; + mesh_description: string | null; + tags: string[] | null; + categories: string[] | null; + repository: { url: string } | null; + remotes: Array<{ type: string }> | null; + verified: boolean; +} + +interface EnrichedData { + friendly_name: string; + mesh_description: string; + tags: string[]; + categories: string[]; +} + +// ═══════════════════════════════════════════════════════════════ +// OpenRouter API Client +// ═══════════════════════════════════════════════════════════════ + +/** + * Call LLM via OpenRouter API directly + */ +async function generateWithLLM(prompt: string): Promise { + try { + const response = await fetch(OPENROUTER_API_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${OPENROUTER_API_KEY}`, + "HTTP-Referer": "https://github.com/decocms/mcps", + "X-Title": "MCP Registry AI Enrichment", + }, + body: JSON.stringify({ + model: MODEL, + messages: [ + { + role: "user", + content: prompt, + }, + ], + temperature: 0.7, + max_tokens: 1500, // Increased to avoid truncated responses + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `OpenRouter API error (${response.status}): ${errorText}`, + ); + } + + const result = await response.json(); + return result.choices?.[0]?.message?.content || ""; + } catch (error) { + console.error(`Error calling LLM: ${error}`); + throw error; + } +} + +// ═══════════════════════════════════════════════════════════════ +// AI Enrichment Logic +// ═══════════════════════════════════════════════════════════════ + +/** + * Generate enriched data for an MCP using AI + */ +async function enrichMcpWithAI(server: McpServer): Promise { + const name = server.name; + const description = server.description || server.short_description || ""; + const repoUrl = server.repository?.url || ""; + const hasRemote = (server.remotes?.length ?? 0) > 0; + const isNpm = server.remotes?.some((r) => r.type === "npm") ?? false; + const isVerified = server.verified; + + // Serialize remotes for the prompt + const remotesInfo = + server.remotes?.map((r) => `${r.type}`).join(", ") || "none"; + + const prompt = `You are an expert at analyzing MCP (Model Context Protocol) servers and generating metadata for them. + +## MCP Technical Information: +- **Full Name**: ${name} +- **Description**: ${description} +- **Version**: ${server.version} +- **Repository**: ${repoUrl} +- **Remotes**: ${remotesInfo} +- **Has Remote Support**: ${hasRemote} +- **Is NPM Package**: ${isNpm} +- **Is Verified**: ${isVerified} + +## Your Task: +Generate metadata in JSON format (respond ONLY with valid JSON, no markdown blocks): + +{ + "friendly_name": "Extract the official/brand name from the technical name", + "mesh_description": "Detailed markdown description (100-200 words)", + "tags": ["relevant", "lowercase", "tags"], + "categories": ["1-3", "high-level", "categories"] +} + +## IMPORTANT - Language: +- ALL content MUST be in ENGLISH +- If the original description is in another language (Portuguese, Spanish, Chinese, etc.), TRANSLATE it to English +- Keep technical terms and brand names as-is +- Use clear, professional English + +## Instructions: + +### 1. friendly_name: +- Extract the REAL brand/company name from the technical identifier +- Examples: + * "com.cloudflare.mcp/mcp" → "Cloudflare" + * "ai.exa/exa" → "Exa" + * "com.microsoft/microsoft-learn-mcp" → "Microsoft Learn" + * "io.github.user/project-name" → "Project Name" +- Keep it short (1-3 words max) +- Use proper capitalization + +### 2. mesh_description: +- Write 100-200 words in markdown +- Explain what this MCP does +- Include main features and use cases +- Be professional and informative +- Use bullet points or sections if helpful + +### 3. tags: +- 5-8 specific, relevant tags +- All lowercase +- Examples: "search", "database", "ai", "monitoring", "cloud", "api" +- Focus on functionality and technology + +### 4. categories: +- Pick 1-3 from this list ONLY: + * productivity, development, data, ai, communication, infrastructure, security, monitoring, analytics, automation +- Choose the most relevant ones + +## Response Format: +- ONLY valid JSON +- NO markdown code blocks +- NO explanations outside the JSON`; + + // Retry loop - retry LLM call if it fails + const maxAttempts = 2; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + console.log( + ` 🤖 Calling LLM for ${name}... (attempt ${attempt}/${maxAttempts})`, + ); + const response = await generateWithLLM(prompt); + + // Try to extract JSON from response (in case it comes with markdown) + const jsonMatch = response.match(/\{[\s\S]*\}/); + if (!jsonMatch) { + throw new Error("No JSON found in response"); + } + + let jsonStr = jsonMatch[0]; + + // Try to repair common JSON issues: unterminated strings at the end + // Pattern: "field": "text without closing + if (jsonStr.match(/:\s*"[^"]*$/)) { + console.log(` 🔧 Attempting to fix unterminated string...`); + jsonStr = jsonStr + '"}'; + } + + const data = JSON.parse(jsonStr); + + // Validate required fields + if ( + !data.friendly_name || + !data.mesh_description || + !Array.isArray(data.tags) || + !Array.isArray(data.categories) + ) { + throw new Error("Invalid response format - missing required fields"); + } + + // Success! + return { + friendly_name: data.friendly_name, + mesh_description: data.mesh_description, + tags: data.tags, + categories: data.categories, + }; + } catch (error) { + if (attempt === maxAttempts) { + console.error(` ❌ Failed after ${maxAttempts} attempts`); + throw error; + } + console.log(` ⚠️ Attempt ${attempt} failed, retrying...`); + // Wait 1s before retrying LLM call + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } + + // TypeScript needs this (will never reach here) + throw new Error("Unreachable"); +} + +// ═══════════════════════════════════════════════════════════════ +// Database Operations +// ═══════════════════════════════════════════════════════════════ + +/** + * Fetch MCPs that need to be enriched + */ +async function getMcpsToEnrich( + supabase: SupabaseClient, + force: boolean, + limit?: number, +): Promise { + let query = supabase + .from("mcp_servers") + .select( + "name, version, description, short_description, friendly_name, mesh_description, tags, categories, repository, remotes, verified", + ) + .eq("is_latest", true) // Only latest versions + .order("verified", { ascending: false }) // Verified first + .order("name"); + + if (!force) { + // Only MCPs without data + query = query.or( + "friendly_name.is.null,mesh_description.is.null,tags.is.null,categories.is.null", + ); + } + + if (limit) { + query = query.limit(limit); + } + + const { data, error } = await query; + + if (error) { + throw new Error(`Error fetching MCPs: ${error.message}`); + } + + return (data || []) as McpServer[]; +} + +/** + * Update an MCP with enriched data (ALL versions) + */ +async function updateMcp( + supabase: SupabaseClient, + name: string, + data: EnrichedData, +): Promise { + // Update ALL versions with this name + const { + data: updated, + error, + count, + } = await supabase + .from("mcp_servers") + .update({ + friendly_name: data.friendly_name, + mesh_description: data.mesh_description, + tags: data.tags, + categories: data.categories, + updated_at: new Date().toISOString(), + }) + .eq("name", name) // name doesn't include version, so it gets all versions + .select(); + + if (error) { + throw new Error(`Error updating MCP ${name}: ${error.message}`); + } + + const versionsUpdated = count || updated?.length || 0; + return versionsUpdated; +} + +// ═══════════════════════════════════════════════════════════════ +// Main +// ═══════════════════════════════════════════════════════════════ + +async function main() { + console.log("═══════════════════════════════════════════════════════════"); + console.log(" MCP Registry AI Enrichment"); + console.log("═══════════════════════════════════════════════════════════\n"); + + // Parse arguments + const args = process.argv.slice(2); + const force = args.includes("--force"); + const limitArg = args.find((arg) => arg.startsWith("--limit=")); + const limit = limitArg ? parseInt(limitArg.split("=")[1]) : undefined; + + console.log("⚙️ Configuration:"); + console.log(` Model: ${MODEL}`); + console.log(` Force re-generate: ${force}`); + console.log(` Limit: ${limit || "no limit"}\n`); + + // Check environment variables + const supabaseUrl = process.env.SUPABASE_URL; + const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + + if (!supabaseUrl || !supabaseKey) { + console.error("❌ Missing environment variables:"); + if (!supabaseUrl) console.error(" - SUPABASE_URL"); + if (!supabaseKey) console.error(" - SUPABASE_SERVICE_ROLE_KEY"); + process.exit(1); + } + + // Create Supabase client + const supabase = createClient(supabaseUrl, supabaseKey); + + try { + // 1. Fetch MCPs to enrich + console.log("📋 Fetching MCPs to enrich..."); + const mcps = await getMcpsToEnrich(supabase, force, limit); + console.log(` Found ${mcps.length} MCPs to process\n`); + + if (mcps.length === 0) { + console.log("✅ All MCPs are already enriched!"); + return; + } + + // 2. Process each MCP + let successCount = 0; + let errorCount = 0; + + for (let i = 0; i < mcps.length; i++) { + const mcp = mcps[i]; + console.log( + `\n[${i + 1}/${mcps.length}] Processing: ${mcp.name}${mcp.verified ? " ⭐" : ""}`, + ); + + try { + // Generate enriched data + const enriched = await enrichMcpWithAI(mcp); + + // Update database (ALL versions) + const versionsUpdated = await updateMcp(supabase, mcp.name, enriched); + + console.log( + ` ✅ Updated ${versionsUpdated} version${versionsUpdated > 1 ? "s" : ""} successfully`, + ); + console.log(` Name: ${enriched.friendly_name}`); + console.log( + ` Tags: ${enriched.tags.slice(0, 3).join(", ")}${enriched.tags.length > 3 ? "..." : ""}`, + ); + console.log(` Categories: ${enriched.categories.join(", ")}`); + + successCount++; + + // Rate limiting - wait 2s between requests + if (i < mcps.length - 1) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + } catch (error) { + console.error(` ❌ Error: ${error}`); + errorCount++; + + // Continue with next ones + continue; + } + } + + // 3. Print summary + console.log( + "\n═══════════════════════════════════════════════════════════", + ); + console.log(" DONE!"); + console.log( + "═══════════════════════════════════════════════════════════\n", + ); + + console.log("📊 Summary:"); + console.log(` Total processed: ${mcps.length}`); + console.log(` Success: ${successCount}`); + console.log(` Errors: ${errorCount}`); + } catch (error) { + console.error("\n❌ Error:", error); + process.exit(1); + } +} + +main(); diff --git a/registry/scripts/populate-supabase.ts b/registry/scripts/populate-supabase.ts index cf15ea03..1b95b16f 100644 --- a/registry/scripts/populate-supabase.ts +++ b/registry/scripts/populate-supabase.ts @@ -1,14 +1,14 @@ #!/usr/bin/env bun /** - * Script para popular o Supabase com TODOS os MCPs do Registry + * Script to populate Supabase with ALL MCPs from the Registry * - * Funcionalidades: - * 1. Cria a tabela mcp_servers se não existir - * 2. Busca todos os servidores da API do Registry - * 3. Computa flags (has_remote, is_npm, is_local_repo) - * 4. Define unlisted baseado na allowlist (allowlist = visible, resto = hidden) - * 5. Migra dados de verified.ts - * 6. Upsert no Supabase + * Features: + * 1. Create mcp_servers table if it doesn't exist + * 2. Fetch all servers from the Registry API + * 3. Compute flags (has_remote, is_npm, is_local_repo) + * 4. Set unlisted based on allowlist (allowlist = visible, rest = hidden) + * 5. Migrate data from verified.ts + * 6. Upsert to Supabase * * Usage: * bun run scripts/populate-supabase.ts @@ -25,11 +25,11 @@ import { } from "../server/lib/verified.ts"; // ═══════════════════════════════════════════════════════════════ -// SQL para criar a tabela +// SQL to create the table // ═══════════════════════════════════════════════════════════════ const CREATE_TABLE_SQL = ` --- Tabela principal (chave primária composta para suportar múltiplas versões) +-- Main table (composite primary key to support multiple versions) CREATE TABLE IF NOT EXISTS mcp_servers ( name TEXT NOT NULL, version TEXT NOT NULL, @@ -69,7 +69,7 @@ CREATE INDEX IF NOT EXISTS idx_mcp_servers_listing ON mcp_servers(is_latest, unl CREATE INDEX IF NOT EXISTS idx_mcp_servers_tags ON mcp_servers USING GIN(tags); CREATE INDEX IF NOT EXISTS idx_mcp_servers_categories ON mcp_servers USING GIN(categories); --- Trigger para updated_at +-- Trigger for updated_at CREATE OR REPLACE FUNCTION update_updated_at_column() RETURNS TRIGGER AS $$ BEGIN @@ -114,45 +114,45 @@ const REQUEST_TIMEOUT = 30000; async function ensureTableExists(supabase: SupabaseClient): Promise { console.log("🗄️ Verificando/criando tabela mcp_servers...\n"); - // Executa o SQL para criar tabela (IF NOT EXISTS garante idempotência) + // Execute SQL to create table (IF NOT EXISTS ensures idempotency) const { error: createError } = await supabase.rpc("exec_sql", { sql: CREATE_TABLE_SQL, }); - // Se o RPC não existir, tenta via query direta (menos seguro, mas funcional) + // If RPC doesn't exist, try via direct query (less secure, but functional) if ( createError?.message?.includes("function") || createError?.code === "42883" ) { console.log( - " ⚠️ RPC exec_sql não disponível, tentando criar tabela via select...", + " ⚠️ RPC exec_sql not available, trying to create table via select...", ); - // Verifica se a tabela existe tentando uma query + // Check if table exists by trying a query const { error: checkError } = await supabase .from("mcp_servers") .select("name") .limit(1); if (checkError?.code === "42P01") { - // Tabela não existe - precisa criar manualmente - console.error("\n❌ Tabela mcp_servers não existe!"); + // Table doesn't exist - needs manual creation + console.error("\n❌ mcp_servers table doesn't exist!"); console.error(" Execute o SQL em: registry/scripts/create-table.sql"); console.error(" No Supabase Dashboard → SQL Editor\n"); process.exit(1); } else if (checkError) { - throw new Error(`Erro ao verificar tabela: ${checkError.message}`); + throw new Error(`Error checking table: ${checkError.message}`); } else { - console.log(" ✅ Tabela mcp_servers já existe\n"); + console.log(" ✅ mcp_servers table already exists\n"); } } else if (createError) { - throw new Error(`Erro ao criar tabela: ${createError.message}`); + throw new Error(`Error creating table: ${createError.message}`); } else { - console.log(" ✅ Tabela mcp_servers pronta\n"); + console.log(" ✅ mcp_servers table ready\n"); - // Tenta habilitar RLS (pode falhar se já estiver habilitado) + // Try to enable RLS (may fail if already enabled) await supabase.rpc("exec_sql", { sql: ENABLE_RLS_SQL }).catch(() => { - // Ignora erros de RLS - provavelmente já está configurado + // Ignore RLS errors - probably already configured }); } } @@ -224,7 +224,7 @@ interface McpServerRow { // ═══════════════════════════════════════════════════════════════ /** - * Busca todos os nomes de servidores (apenas latest para obter a lista) + * Fetch all server names (only latest to get the list) */ async function fetchAllServerNames(): Promise { const serverNames: string[] = []; @@ -273,7 +273,7 @@ async function fetchAllServerNames(): Promise { } /** - * Busca todas as versões de um servidor com retry para 429 + * Fetch all versions of a server with retry for 429 */ async function fetchServerVersions( name: string, @@ -343,20 +343,20 @@ async function fetchServerVersions( } /** - * Busca servidores que precisam ser atualizados (não estão no banco) + * Fetch servers that need to be updated (not in database) */ async function getServersToUpdate( supabase: SupabaseClient, allServerNames: string[], forceUpdate = false, ): Promise { - // Se forceUpdate = true, retornar todos + // If forceUpdate = true, return all if (forceUpdate) { console.log(" 🔄 Force update enabled - will update all servers"); return allServerNames; } - // Buscar nomes únicos já no banco + // Fetch unique names already in database const { data: existingServers } = await supabase .from("mcp_servers") .select("name") @@ -366,19 +366,19 @@ async function getServersToUpdate( (existingServers || []).map((s: { name: string }) => s.name), ); - // Retornar apenas os que faltam + // Return only missing ones return allServerNames.filter((name) => !existingNames.has(name)); } /** - * Busca todas as versões de todos os servidores (com controle de concorrência e retry) + * Fetch all versions of all servers (with concurrency control and retry) */ async function fetchAllServersWithVersions( supabase: SupabaseClient, resumeFrom?: number, forceUpdate = false, ): Promise { - // 1. Buscar lista de nomes + // 1. Fetch list of names const allServerNames = await fetchAllServerNames(); // 2. Identificar quais precisam ser atualizados @@ -398,9 +398,9 @@ async function fetchAllServersWithVersions( `📦 Need to fetch ${serversToFetch.length} servers (${allServerNames.length - serversToFetch.length} already in DB)\n`, ); - // 3. Buscar versões com concorrência reduzida e retry - const CONCURRENT_REQUESTS = 3; // Reduzido para evitar 429 - const BATCH_DELAY = 1000; // 1s entre batches + // 3. Fetch versions with reduced concurrency and retry + const CONCURRENT_REQUESTS = 3; // Reduced to avoid 429 + const BATCH_DELAY = 1000; // 1s between batches const allServers: RegistryServer[] = []; const startFrom = resumeFrom || 0; @@ -432,7 +432,7 @@ async function fetchAllServersWithVersions( ` Processed ${processed}/${serversToFetch.length} servers (${allServers.length} total versions)`, ); - // Delay entre batches para evitar rate limiting + // Delay between batches to avoid rate limiting if (i + CONCURRENT_REQUESTS < serversToFetch.length) { await new Promise((resolve) => setTimeout(resolve, BATCH_DELAY)); } @@ -489,7 +489,7 @@ function transformServerToRow( is_npm: isNpm, is_local_repo: isLocalRepo, - // Duplicar description em short_description (para consistência) + // Duplicate description in short_description (for consistency) short_description: server.server.description ?? null, // To be filled later (manually or AI) @@ -541,9 +541,9 @@ async function main() { forceUpdate, ); - // Se não há nada novo, finalizar + // If nothing new, finish if (allServers.length === 0) { - console.log("✅ Nenhum servidor novo para adicionar!"); + console.log("✅ No new servers to add!"); return; } diff --git a/registry/server/lib/supabase-client.ts b/registry/server/lib/supabase-client.ts index 6cc6ec79..9f5d05a4 100644 --- a/registry/server/lib/supabase-client.ts +++ b/registry/server/lib/supabase-client.ts @@ -111,7 +111,7 @@ export function rowToRegistryServer(row: McpServerRow): RegistryServer { server: { $schema: row.schema_url ?? DEFAULT_SCHEMA, name: row.name, - description: row.description ?? "", // Descrição original do registry + description: row.description ?? "", // Original description from registry version: row.version, ...(row.repository && { repository: row.repository }), ...(row.remotes && { remotes: row.remotes }), @@ -168,6 +168,22 @@ export interface ListServersResult { // Query Functions // ═══════════════════════════════════════════════════════════════ +/** + * Sanitize search input to prevent PostgREST query injection + * Escapes special characters that have meaning in PostgREST queries + */ +function sanitizeSearchInput(input: string): string { + // Escape special PostgREST characters: , . ( ) * % \ + return input + .replace(/\\/g, "\\\\") // Backslash first + .replace(/,/g, "\\,") // Comma (separates OR conditions) + .replace(/\./g, "\\.") // Period (operator separator) + .replace(/\(/g, "\\(") // Left paren (grouping) + .replace(/\)/g, "\\)") // Right paren (grouping) + .replace(/\*/g, "\\*") // Asterisk (wildcard) + .replace(/%/g, "\\%"); // Percent (wildcard in LIKE) +} + /** * List servers from Supabase with filters */ @@ -188,7 +204,7 @@ export async function listServers( let query = client.from("mcp_servers").select("*", { count: "exact" }); - // SEMPRE filtrar apenas a última versão (is_latest: true) + // ALWAYS filter only the latest version (is_latest: true) query = query.eq("is_latest", true); // Filter unlisted unless explicitly included @@ -216,10 +232,11 @@ export async function listServers( query = query.overlaps("categories", categories); } - // Full-text search + // Full-text search (sanitize input to prevent PostgREST query injection) if (search) { + const sanitized = sanitizeSearchInput(search); query = query.or( - `name.ilike.%${search}%,description.ilike.%${search}%,friendly_name.ilike.%${search}%,short_description.ilike.%${search}%`, + `name.ilike.%${sanitized}%,description.ilike.%${sanitized}%,friendly_name.ilike.%${sanitized}%,short_description.ilike.%${sanitized}%`, ); } @@ -350,32 +367,38 @@ export async function getServerStats(client: SupabaseClient): Promise<{ if (error) { // Fallback to manual count if RPC doesn't exist + // ALWAYS filter by is_latest to count only the latest version of each server const { count: total } = await client .from("mcp_servers") .select("*", { count: "exact", head: true }) + .eq("is_latest", true) .eq("unlisted", false); const { count: verified } = await client .from("mcp_servers") .select("*", { count: "exact", head: true }) + .eq("is_latest", true) .eq("unlisted", false) .eq("verified", true); const { count: withRemote } = await client .from("mcp_servers") .select("*", { count: "exact", head: true }) + .eq("is_latest", true) .eq("unlisted", false) .eq("has_remote", true); const { count: withNpm } = await client .from("mcp_servers") .select("*", { count: "exact", head: true }) + .eq("is_latest", true) .eq("unlisted", false) .eq("is_npm", true); const { count: unlisted } = await client .from("mcp_servers") .select("*", { count: "exact", head: true }) + .eq("is_latest", true) .eq("unlisted", true); return { diff --git a/registry/server/tools/registry-binding.ts b/registry/server/tools/registry-binding.ts index c9c176f3..1c5ddefe 100644 --- a/registry/server/tools/registry-binding.ts +++ b/registry/server/tools/registry-binding.ts @@ -67,6 +67,10 @@ const WhereSchema = z /** * Input schema para LIST + * + * Note: This tool always returns the latest version of each server (is_latest: true). + * To get a specific version, use COLLECTION_REGISTRY_APP_GET with 'name@version'. + * To get all versions, use COLLECTION_REGISTRY_APP_VERSIONS. */ const ListInputSchema = z .object({ @@ -87,13 +91,6 @@ const ListInputSchema = z where: WhereSchema.optional().describe( "Standard WhereExpression filter (converted to simple search internally)", ), - version: z - .string() - .optional() - .default("latest") - .describe( - "Filter by specific version (e.g., '1.0.0' or 'latest', default: 'latest')", - ), }) .describe("Filtering, sorting, and pagination context"); From b27be61ce777c1e37d6398d7a446412361895e25 Mon Sep 17 00:00:00 2001 From: Jonas Jesus Date: Fri, 2 Jan 2026 13:02:38 -0300 Subject: [PATCH 4/8] fix(registry): add underscore escape to search sanitization - Add escape for underscore (_) character in sanitizeSearchInput - Underscore is a single-char wildcard in SQL LIKE/ILIKE - Without escaping, users could inject wildcard patterns - Example: 'ai_exa' would match 'ai.exa', 'ai-exa', 'ai exa', etc. --- registry/server/lib/supabase-client.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/registry/server/lib/supabase-client.ts b/registry/server/lib/supabase-client.ts index 9f5d05a4..6add3183 100644 --- a/registry/server/lib/supabase-client.ts +++ b/registry/server/lib/supabase-client.ts @@ -173,7 +173,7 @@ export interface ListServersResult { * Escapes special characters that have meaning in PostgREST queries */ function sanitizeSearchInput(input: string): string { - // Escape special PostgREST characters: , . ( ) * % \ + // Escape special PostgREST characters: , . ( ) * % _ \ return input .replace(/\\/g, "\\\\") // Backslash first .replace(/,/g, "\\,") // Comma (separates OR conditions) @@ -181,7 +181,8 @@ function sanitizeSearchInput(input: string): string { .replace(/\(/g, "\\(") // Left paren (grouping) .replace(/\)/g, "\\)") // Right paren (grouping) .replace(/\*/g, "\\*") // Asterisk (wildcard) - .replace(/%/g, "\\%"); // Percent (wildcard in LIKE) + .replace(/%/g, "\\%") // Percent (wildcard in LIKE) + .replace(/_/g, "\\_"); // Underscore (single-char wildcard in LIKE) } /** From ff8f6aee05c507c186315c541d43b9ffd37ecb53 Mon Sep 17 00:00:00 2001 From: Jonas Jesus Date: Fri, 2 Jan 2026 13:04:50 -0300 Subject: [PATCH 5/8] security: remove hardcoded OpenRouter API key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🚨 CRITICAL SECURITY FIX 🚨 - Remove hardcoded OPENROUTER_API_KEY from source code - Use process.env.OPENROUTER_API_KEY instead - Add validation to check for missing API key at startup - Pass API key as parameter through function calls ⚠️ ACTION REQUIRED: The exposed API key (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) must be IMMEDIATELY REVOKED at OpenRouter dashboard and a new key generated. The old key is now exposed in git history and should be considered compromised. --- registry/scripts/enrich-with-ai.ts | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/registry/scripts/enrich-with-ai.ts b/registry/scripts/enrich-with-ai.ts index 7492fe1f..65f82499 100755 --- a/registry/scripts/enrich-with-ai.ts +++ b/registry/scripts/enrich-with-ai.ts @@ -28,8 +28,6 @@ import { createClient, type SupabaseClient } from "@supabase/supabase-js"; // ═══════════════════════════════════════════════════════════════ const OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions"; -const OPENROUTER_API_KEY = - "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"; // Recommended models (cheap and always available) const RECOMMENDED_MODELS = [ @@ -73,13 +71,16 @@ interface EnrichedData { /** * Call LLM via OpenRouter API directly */ -async function generateWithLLM(prompt: string): Promise { +async function generateWithLLM( + prompt: string, + apiKey: string, +): Promise { try { const response = await fetch(OPENROUTER_API_URL, { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${OPENROUTER_API_KEY}`, + Authorization: `Bearer ${apiKey}`, "HTTP-Referer": "https://github.com/decocms/mcps", "X-Title": "MCP Registry AI Enrichment", }, @@ -118,7 +119,10 @@ async function generateWithLLM(prompt: string): Promise { /** * Generate enriched data for an MCP using AI */ -async function enrichMcpWithAI(server: McpServer): Promise { +async function enrichMcpWithAI( + server: McpServer, + apiKey: string, +): Promise { const name = server.name; const description = server.description || server.short_description || ""; const repoUrl = server.repository?.url || ""; @@ -201,7 +205,7 @@ Generate metadata in JSON format (respond ONLY with valid JSON, no markdown bloc console.log( ` 🤖 Calling LLM for ${name}... (attempt ${attempt}/${maxAttempts})`, ); - const response = await generateWithLLM(prompt); + const response = await generateWithLLM(prompt, apiKey); // Try to extract JSON from response (in case it comes with markdown) const jsonMatch = response.match(/\{[\s\S]*\}/); @@ -349,11 +353,13 @@ async function main() { // Check environment variables const supabaseUrl = process.env.SUPABASE_URL; const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + const openrouterApiKey = process.env.OPENROUTER_API_KEY; - if (!supabaseUrl || !supabaseKey) { + if (!supabaseUrl || !supabaseKey || !openrouterApiKey) { console.error("❌ Missing environment variables:"); if (!supabaseUrl) console.error(" - SUPABASE_URL"); if (!supabaseKey) console.error(" - SUPABASE_SERVICE_ROLE_KEY"); + if (!openrouterApiKey) console.error(" - OPENROUTER_API_KEY"); process.exit(1); } @@ -383,7 +389,7 @@ async function main() { try { // Generate enriched data - const enriched = await enrichMcpWithAI(mcp); + const enriched = await enrichMcpWithAI(mcp, openrouterApiKey); // Update database (ALL versions) const versionsUpdated = await updateMcp(supabase, mcp.name, enriched); From 181f60f042888b491979047ff84373539ab8aa18 Mon Sep 17 00:00:00 2001 From: Jonas Jesus Date: Fri, 2 Jan 2026 13:06:56 -0300 Subject: [PATCH 6/8] chore(registry): add .env to gitignore and create setup docs - Add .env to .gitignore to prevent accidental commits - Create ENV_SETUP.md with instructions for environment setup - Create .env template file (not tracked by git) --- registry/.gitignore | 3 ++- registry/ENV_SETUP.md | 55 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 registry/ENV_SETUP.md diff --git a/registry/.gitignore b/registry/.gitignore index 583fce75..b2f1bd33 100644 --- a/registry/.gitignore +++ b/registry/.gitignore @@ -1 +1,2 @@ -.dev.vars +.dev.vars +.env diff --git a/registry/ENV_SETUP.md b/registry/ENV_SETUP.md new file mode 100644 index 00000000..b093a1cc --- /dev/null +++ b/registry/ENV_SETUP.md @@ -0,0 +1,55 @@ +# Environment Variables Setup + +## Required Environment Variables + +Create a `.env` file in the `registry/` directory with the following variables: + +```bash +# Supabase Configuration +SUPABASE_URL=https://your-project.supabase.co +SUPABASE_ANON_KEY=your-anon-key-here +SUPABASE_SERVICE_ROLE_KEY=your-service-role-key-here + +# OpenRouter API (for AI enrichment script) +# Get your key at: https://openrouter.ai/keys +OPENROUTER_API_KEY=sk-or-v1-your-key-here + +# Optional: Override default model for AI enrichment +# OPENROUTER_MODEL=meta-llama/llama-3.1-8b-instruct +``` + +## Quick Setup + +```bash +# 1. Copy this template to .env +cd registry +cat > .env << 'EOF' +SUPABASE_URL= +SUPABASE_ANON_KEY= +SUPABASE_SERVICE_ROLE_KEY= +OPENROUTER_API_KEY= +EOF + +# 2. Edit .env and fill in your actual keys +nano .env # or use your favorite editor +``` + +## Getting the Keys + +### Supabase Keys +1. Go to your Supabase project dashboard +2. Settings → API +3. Copy `URL`, `anon/public key`, and `service_role key` + +### OpenRouter API Key +1. Go to https://openrouter.ai/keys +2. Create a new API key +3. Copy the key (starts with `sk-or-v1-`) + +## Security Notes + +⚠️ **NEVER commit the `.env` file to git!** +- The `.env` file is already in `.gitignore` +- Never hardcode API keys in source code +- Revoke any exposed keys immediately + From 99f0833f4248157b9dcca53809f3badda3466940 Mon Sep 17 00:00:00 2001 From: Jonas Jesus Date: Fri, 2 Jan 2026 13:07:25 -0300 Subject: [PATCH 7/8] chore(registry): remove ENV_SETUP.md file --- registry/ENV_SETUP.md | 55 ------------------------------------------- 1 file changed, 55 deletions(-) delete mode 100644 registry/ENV_SETUP.md diff --git a/registry/ENV_SETUP.md b/registry/ENV_SETUP.md deleted file mode 100644 index b093a1cc..00000000 --- a/registry/ENV_SETUP.md +++ /dev/null @@ -1,55 +0,0 @@ -# Environment Variables Setup - -## Required Environment Variables - -Create a `.env` file in the `registry/` directory with the following variables: - -```bash -# Supabase Configuration -SUPABASE_URL=https://your-project.supabase.co -SUPABASE_ANON_KEY=your-anon-key-here -SUPABASE_SERVICE_ROLE_KEY=your-service-role-key-here - -# OpenRouter API (for AI enrichment script) -# Get your key at: https://openrouter.ai/keys -OPENROUTER_API_KEY=sk-or-v1-your-key-here - -# Optional: Override default model for AI enrichment -# OPENROUTER_MODEL=meta-llama/llama-3.1-8b-instruct -``` - -## Quick Setup - -```bash -# 1. Copy this template to .env -cd registry -cat > .env << 'EOF' -SUPABASE_URL= -SUPABASE_ANON_KEY= -SUPABASE_SERVICE_ROLE_KEY= -OPENROUTER_API_KEY= -EOF - -# 2. Edit .env and fill in your actual keys -nano .env # or use your favorite editor -``` - -## Getting the Keys - -### Supabase Keys -1. Go to your Supabase project dashboard -2. Settings → API -3. Copy `URL`, `anon/public key`, and `service_role key` - -### OpenRouter API Key -1. Go to https://openrouter.ai/keys -2. Create a new API key -3. Copy the key (starts with `sk-or-v1-`) - -## Security Notes - -⚠️ **NEVER commit the `.env` file to git!** -- The `.env` file is already in `.gitignore` -- Never hardcode API keys in source code -- Revoke any exposed keys immediately - From 715d3bb15018fe098dc5780328d26296ba15bef5 Mon Sep 17 00:00:00 2001 From: Jonas Jesus Date: Fri, 2 Jan 2026 13:08:09 -0300 Subject: [PATCH 8/8] docs(registry): fix incorrect GET tool documentation - Update COLLECTION_REGISTRY_APP_GET docs to reflect actual behavior - GET always returns LATEST version (is_latest: true) - Version suffix in 'name@version' is accepted but IGNORED - Remove misleading reference to GET supporting specific versions - Clarify that COLLECTION_REGISTRY_APP_VERSIONS should be used for version queries The implementation was correct, only the documentation was inaccurate. --- registry/server/tools/registry-binding.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/registry/server/tools/registry-binding.ts b/registry/server/tools/registry-binding.ts index 1c5ddefe..9bd4259e 100644 --- a/registry/server/tools/registry-binding.ts +++ b/registry/server/tools/registry-binding.ts @@ -69,8 +69,7 @@ const WhereSchema = z * Input schema para LIST * * Note: This tool always returns the latest version of each server (is_latest: true). - * To get a specific version, use COLLECTION_REGISTRY_APP_GET with 'name@version'. - * To get all versions, use COLLECTION_REGISTRY_APP_VERSIONS. + * To get all versions of a server, use COLLECTION_REGISTRY_APP_VERSIONS. */ const ListInputSchema = z .object({ @@ -113,7 +112,9 @@ const ListOutputSchema = z.object({ const GetInputSchema = z.object({ id: z .string() - .describe("Server ID (format: 'ai.exa/exa' or 'ai.exa/exa@3.1.1')"), + .describe( + "Server name (format: 'ai.exa/exa' or 'ai.exa/exa@3.1.1'). Note: version suffix is ignored, always returns latest version.", + ), }); /** @@ -275,12 +276,16 @@ export const createListRegistryTool = (_env: Env) => /** * COLLECTION_REGISTRY_GET - Gets a specific server from Supabase + * + * Note: This tool always returns the LATEST version (is_latest: true). + * The version suffix in 'name@version' is accepted but ignored. + * To get all versions of a server, use COLLECTION_REGISTRY_APP_VERSIONS. */ export const createGetRegistryTool = (_env: Env) => createPrivateTool({ id: "COLLECTION_REGISTRY_APP_GET", description: - "Gets a specific MCP server from the registry by ID (format: 'name' or 'name@version')", + "Gets the latest version of a specific MCP server from the registry by name (accepts 'name' or 'name@version', but always returns latest)", inputSchema: GetInputSchema, outputSchema: GetOutputSchema, execute: async ({