- No models available
+
+ {/* Header com busca e filtros */}
+
+
+
+
+ setSearchTerm(e.target.value)}
+ className="w-full rounded-md border border-[var(--border)] bg-[var(--background)] pl-8 pr-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
+ />
+ {searchTerm && (
+
+ )}
- ) : (
- models.map((model) => (
-
+
+ {/* Toggle para busca remota */}
+
+
+
- ))
+
+ {showRemote
+ ? `${remoteModels.length} remote`
+ : `${filteredModels.length} local`}{" "}
+ model
+ {(showRemote
+ ? remoteModels.length
+ : filteredModels.length) !== 1
+ ? "s"
+ : ""}{" "}
+ found
+
+
+
+
+
+ {/* Lista de modelos com scroll customizado */}
+
+ {showRemote ? (
+ // Lista de modelos remotos
+ <>
+ {isSearchingRemote ? (
+
+
+ Searching remote models...
+
+ ) : remoteModels.length === 0 ? (
+
+
+ No remote models found
+
+ ) : (
+
+ {remoteModels.map((model) => (
+
+
+
+
+
+ {model.name}
+
+
+
+ Remote
+
+ {model.installed && (
+
+
+ Installed
+
+ )}
+
+
+
+
+
+
+ Available for download
+
+
+
+
+ ))}
+
+ )}
+ >
+ ) : (
+ // Lista de modelos locais
+ <>
+ {filteredModels.length === 0 ? (
+
+
+ No models found
+
+ ) : (
+
+ {filteredModels.map((model) => (
+
+
+
+
+
+ {model.name}
+
+
+ {getPerformanceIcon(model)}
+ {categorizeModel(model)}
+
+
+
+
+
+ {model.sizeGB} GB
+
+
+ {model.quantization}
+
+
+ {model.device === "GPU" ? (
+
+ ) : (
+
+ )}
+ {model.device}
+
+
+
+
+
+ {/* Informações técnicas e ações */}
+
+
+ {model.estCpuUsagePct && (
+
+
+ {model.estCpuUsagePct}% CPU
+
+ )}
+ {model.estVramGB && model.estVramGB > 0 && (
+
+
+ {model.estVramGB} GB VRAM
+
+ )}
+
+
+
+
+ {onDeleteModel && (
+
+ )}
+
+
+
+ {/* Confirmação de exclusão */}
+ {showDeleteConfirm === model.id && (
+
+
+ Are you sure you want to delete this model?
+
+
+
+
+
+
+ )}
+
+ ))}
+
+ )}
+ >
)}
diff --git a/database/database.ts b/database/database.ts
new file mode 100644
index 0000000..346e99b
--- /dev/null
+++ b/database/database.ts
@@ -0,0 +1,34 @@
+import Database from "better-sqlite3";
+import path from "path";
+import fs from "fs";
+
+let db: Database.Database | null = null;
+
+export function getDatabase(): Database.Database {
+ if (!db) {
+ const dbPath = path.join(process.cwd(), "database", "ollahub.db");
+
+ // Garantir que o diretório existe
+ const dbDir = path.dirname(dbPath);
+ if (!fs.existsSync(dbDir)) {
+ fs.mkdirSync(dbDir, { recursive: true });
+ }
+
+ db = new Database(dbPath);
+
+ // Configurações de performance
+ db.pragma("journal_mode = WAL");
+ db.pragma("synchronous = NORMAL");
+ db.pragma("foreign_keys = ON");
+ db.pragma("cache_size = 10000");
+ db.pragma("temp_store = memory");
+ }
+ return db;
+}
+
+export function closeDatabase(): void {
+ if (db) {
+ db.close();
+ db = null;
+ }
+}
diff --git a/database/ollahub-preferences.db-shm b/database/ollahub-preferences.db-shm
index 08697f4..1b5d52b 100644
Binary files a/database/ollahub-preferences.db-shm and b/database/ollahub-preferences.db-shm differ
diff --git a/database/ollahub.db-shm b/database/ollahub.db-shm
index b2d03fa..29ed79e 100644
Binary files a/database/ollahub.db-shm and b/database/ollahub.db-shm differ
diff --git a/database/ollahub.db-wal b/database/ollahub.db-wal
index 12c8693..f12de47 100644
Binary files a/database/ollahub.db-wal and b/database/ollahub.db-wal differ
diff --git a/database/repositories/mcp-cache.ts b/database/repositories/mcp-cache.ts
new file mode 100644
index 0000000..110431e
--- /dev/null
+++ b/database/repositories/mcp-cache.ts
@@ -0,0 +1,464 @@
+import { getDatabase } from "@/database/database";
+
+const db = getDatabase();
+
+export interface MCPSearchParams {
+ query?: string;
+ category?: string;
+ limit?: number;
+ offset?: number;
+ sortBy?: string;
+ order?: "asc" | "desc";
+}
+
+export interface MCPCacheItem {
+ id: string;
+ owner: string;
+ repo: string;
+ content_name: string;
+ publisher_id: string;
+ description?: string;
+ category?: string;
+ subfield?: string;
+ field?: string;
+ rating: number;
+ review_cnt: number;
+ content_tag_list?: string;
+ thumbnail_picture?: string;
+ website?: string;
+ detail_url?: string;
+ ext_info?: any;
+ created_at: number;
+ updated_at: number;
+}
+
+export class MCPCacheRepository {
+ private static readonly CACHE_TTL = 24 * 60 * 60 * 1000; // 24 horas em millisegundos
+
+ /**
+ * Verificar se cache está válido (< 24h)
+ */
+ static isCacheValid(): boolean {
+ try {
+ const lastSync = this.getLastSync();
+ if (!lastSync) return false;
+
+ const now = Date.now();
+ return now - lastSync < this.CACHE_TTL;
+ } catch (error) {
+ console.error("Error checking cache validity:", error);
+ return false;
+ }
+ }
+
+ /**
+ * Obter timestamp da última sincronização
+ */
+ static getLastSync(): number | null {
+ try {
+ const stmt = db.prepare(
+ "SELECT value FROM mcp_cache_metadata WHERE key = 'last_sync'"
+ );
+ const result = stmt.get() as { value: string } | undefined;
+ return result ? parseInt(result.value) : null;
+ } catch (error) {
+ console.error("Error getting last sync:", error);
+ return null;
+ }
+ }
+
+ /**
+ * Salvar múltiplos MCPs em batch (performance)
+ */
+ static saveBatch(mcps: any[]): void {
+ try {
+ const stmt = db.prepare(`
+ INSERT INTO mcp_marketplace_cache (
+ id, owner, repo, content_name, publisher_id, description,
+ category, subfield, field, rating, review_cnt, content_tag_list,
+ thumbnail_picture, website, detail_url, ext_info, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(id) DO UPDATE SET
+ owner = EXCLUDED.owner,
+ repo = EXCLUDED.repo,
+ content_name = EXCLUDED.content_name,
+ publisher_id = EXCLUDED.publisher_id,
+ description = EXCLUDED.description,
+ category = EXCLUDED.category,
+ subfield = EXCLUDED.subfield,
+ field = EXCLUDED.field,
+ rating = EXCLUDED.rating,
+ review_cnt = EXCLUDED.review_cnt,
+ content_tag_list = EXCLUDED.content_tag_list,
+ thumbnail_picture = EXCLUDED.thumbnail_picture,
+ website = EXCLUDED.website,
+ detail_url = EXCLUDED.detail_url,
+ ext_info = EXCLUDED.ext_info,
+ updated_at = EXCLUDED.updated_at
+ `);
+
+ const now = Date.now();
+
+ for (const mcp of mcps) {
+ const [owner, repo] = (mcp.id || "").split("/");
+ stmt.run(
+ mcp.id || "",
+ owner || "",
+ repo || "",
+ mcp.content_name || "",
+ mcp.publisher_id || "",
+ mcp.description || null,
+ mcp.category || null,
+ mcp.subfield || null,
+ mcp.field || null,
+ parseFloat(mcp.rating || "0"),
+ parseInt(mcp.review_cnt || "0"),
+ mcp.content_tag_list || null,
+ mcp.thumbnail_picture || null,
+ mcp.website || null,
+ mcp.detail_url || null,
+ mcp.ext_info ? JSON.stringify(mcp.ext_info) : null,
+ now,
+ now
+ );
+ }
+
+ console.log(`Saved batch of ${mcps.length} MCPs to cache`);
+ } catch (error) {
+ console.error("Error saving MCP batch:", error);
+ throw error;
+ }
+ }
+
+ /**
+ * Limpar cache antigo
+ */
+ static clearCache(): void {
+ try {
+ db.exec("DELETE FROM mcp_marketplace_cache");
+ db.exec("DELETE FROM mcp_cache_metadata");
+ console.log("MCP cache cleared");
+ } catch (error) {
+ console.error("Error clearing cache:", error);
+ throw error;
+ }
+ }
+
+ /**
+ * Buscar MCPs no cache com filtros
+ */
+ static search(params: MCPSearchParams): MCPCacheItem[] {
+ try {
+ let query = "SELECT * FROM mcp_marketplace_cache WHERE 1=1";
+ const queryParams: any[] = [];
+
+ // Filtro por categoria
+ if (params.category) {
+ query += " AND (category = ? OR subfield = ?)";
+ queryParams.push(params.category, params.category);
+ }
+
+ // Filtro por busca textual
+ if (params.query) {
+ query +=
+ " AND (content_name LIKE ? OR description LIKE ? OR content_tag_list LIKE ?)";
+ const searchTerm = `%${params.query}%`;
+ queryParams.push(searchTerm, searchTerm, searchTerm);
+ }
+
+ // Ordenação dinâmica
+ const order = params.order || "desc"; // Padrão descrescente
+ let orderClause = "";
+
+ switch (params.sortBy) {
+ case "rating":
+ orderClause = `ORDER BY rating ${order.toUpperCase()}`;
+ break;
+ case "downloads":
+ orderClause = `ORDER BY review_cnt ${order.toUpperCase()}`;
+ break;
+ case "name":
+ orderClause = `ORDER BY content_name ${order.toUpperCase()}`;
+ break;
+ case "recent":
+ orderClause = `ORDER BY updated_at ${order.toUpperCase()}`;
+ break;
+ case "total_ratings":
+ orderClause = `ORDER BY review_cnt ${order.toUpperCase()}`;
+ break;
+ case "updated_at":
+ orderClause = `ORDER BY updated_at ${order.toUpperCase()}`;
+ break;
+ default:
+ orderClause = `ORDER BY rating DESC`; // Padrão
+ }
+
+ query += ` ${orderClause}`;
+
+ // Paginação
+ if (params.limit) {
+ query += " LIMIT ?";
+ queryParams.push(params.limit);
+
+ if (params.offset) {
+ query += " OFFSET ?";
+ queryParams.push(params.offset);
+ }
+ }
+
+ const stmt = db.prepare(query);
+ const results = stmt.all(...queryParams) as MCPCacheItem[];
+
+ console.log(`Found ${results.length} MCPs in cache for search:`, params);
+ return results;
+ } catch (error) {
+ console.error("Error searching MCP cache:", error);
+ return [];
+ }
+ }
+
+ /**
+ * Contar total de MCPs no cache
+ */
+ static count(filters?: { category?: string }): number {
+ try {
+ let query =
+ "SELECT COUNT(*) as count FROM mcp_marketplace_cache WHERE 1=1";
+ const queryParams: any[] = [];
+
+ if (filters?.category) {
+ query += " AND (category = ? OR subfield = ?)";
+ queryParams.push(filters.category, filters.category);
+ }
+
+ const stmt = db.prepare(query);
+ const result = stmt.get(...queryParams) as { count: number };
+ return result.count;
+ } catch (error) {
+ console.error("Error counting MCP cache:", error);
+ return 0;
+ }
+ }
+
+ /**
+ * Atualizar metadados do cache
+ */
+ static updateMetadata(key: string, value: string): void {
+ try {
+ const stmt = db.prepare(`
+ INSERT INTO mcp_cache_metadata (key, value, updated_at)
+ VALUES (?, ?, ?)
+ ON CONFLICT(key) DO UPDATE SET
+ value = EXCLUDED.value,
+ updated_at = EXCLUDED.updated_at
+ `);
+ stmt.run(key, value, Date.now());
+ } catch (error) {
+ console.error("Error updating cache metadata:", error);
+ throw error;
+ }
+ }
+
+ /**
+ * Verificar se precisa sincronizar
+ */
+ static needsSync(): boolean {
+ try {
+ // Se não há dados no cache, precisa sincronizar
+ const count = this.count();
+ if (count === 0) return true;
+
+ // Se cache expirou, precisa sincronizar
+ return !this.isCacheValid();
+ } catch (error) {
+ console.error("Error checking if sync is needed:", error);
+ return true;
+ }
+ }
+
+ /**
+ * Obter estatísticas do cache
+ */
+ static getStats(): {
+ totalItems: number;
+ lastSync: number | null;
+ isValid: boolean;
+ categories: Record
;
+ } {
+ try {
+ const totalItems = this.count();
+ const lastSync = this.getLastSync();
+ const isValid = this.isCacheValid();
+
+ // Contar por categoria
+ const categoryStmt = db.prepare(`
+ SELECT category, COUNT(*) as count
+ FROM mcp_marketplace_cache
+ WHERE category IS NOT NULL
+ GROUP BY category
+ `);
+ const categoryResults = categoryStmt.all() as Array<{
+ category: string;
+ count: number;
+ }>;
+
+ const categories = categoryResults.reduce((acc, row) => {
+ acc[row.category] = row.count;
+ return acc;
+ }, {} as Record);
+
+ return {
+ totalItems,
+ lastSync,
+ isValid,
+ categories,
+ };
+ } catch (error) {
+ console.error("Error getting cache stats:", error);
+ return {
+ totalItems: 0,
+ lastSync: null,
+ isValid: false,
+ categories: {},
+ };
+ }
+ }
+
+ /**
+ * Verificar se uma tabela existe
+ */
+ static tableExists(tableName: string): boolean {
+ try {
+ const stmt = db.prepare(`
+ SELECT name FROM sqlite_master
+ WHERE type='table' AND name=?
+ `);
+ const result = stmt.get(tableName);
+ return !!result;
+ } catch (error) {
+ console.error("Error checking if table exists:", error);
+ return false;
+ }
+ }
+
+ /**
+ * Extrair categorias dinamicamente do cache
+ * Retorna categorias principais e outras categorias menos comuns
+ */
+ static getCategories(): {
+ primary: Array<{ category: string; count: number }>;
+ others: Array<{ category: string; count: number }>;
+ } {
+ try {
+ // Categorias principais definidas (baseadas nos dados reais da API)
+ const primaryCategories = [
+ "MAP",
+ "BROWSER",
+ "OFFICE",
+ "CODE",
+ "DATABASE",
+ "SEARCH",
+ "PAYMENT",
+ "CHART",
+ "FINANCE",
+ "OTHER",
+ ];
+
+ // Query para obter todas as categorias com contadores
+ const stmt = db.prepare(`
+ SELECT
+ COALESCE(subfield, category, 'other') as category,
+ COUNT(*) as count
+ FROM mcp_marketplace_cache
+ WHERE COALESCE(subfield, category, 'other') IS NOT NULL
+ GROUP BY COALESCE(subfield, category, 'other')
+ ORDER BY count DESC
+ `);
+
+ const allCategories = stmt.all() as Array<{
+ category: string;
+ count: number;
+ }>;
+
+ // Separar em categorias principais e outras
+ const primary: Array<{ category: string; count: number }> = [];
+ const others: Array<{ category: string; count: number }> = [];
+
+ for (const cat of allCategories) {
+ const normalizedCategory = cat.category.toUpperCase();
+
+ if (primaryCategories.includes(normalizedCategory)) {
+ primary.push({
+ category: normalizedCategory,
+ count: cat.count,
+ });
+ } else {
+ others.push({
+ category: cat.category,
+ count: cat.count,
+ });
+ }
+ }
+
+ // Ordenar categorias principais por contagem (desc)
+ primary.sort((a, b) => b.count - a.count);
+
+ // Ordenar outras categorias por contagem (desc)
+ others.sort((a, b) => b.count - a.count);
+
+ console.log(
+ `📊 Extracted categories: ${primary.length} primary, ${others.length} others`
+ );
+
+ return { primary, others };
+ } catch (error) {
+ console.error("Error extracting categories:", error);
+ return { primary: [], others: [] };
+ }
+ }
+
+ /**
+ * Obter estatísticas detalhadas de categorias
+ */
+ static getCategoryStats(): {
+ totalCategories: number;
+ primaryCategories: number;
+ otherCategories: number;
+ totalMCPs: number;
+ categoryBreakdown: Record;
+ } {
+ try {
+ const categories = this.getCategories();
+ const totalMCPs = this.count();
+
+ const categoryBreakdown: Record = {};
+
+ // Adicionar categorias principais
+ for (const cat of categories.primary) {
+ categoryBreakdown[cat.category] = cat.count;
+ }
+
+ // Adicionar outras categorias
+ for (const cat of categories.others) {
+ categoryBreakdown[cat.category] = cat.count;
+ }
+
+ return {
+ totalCategories: categories.primary.length + categories.others.length,
+ primaryCategories: categories.primary.length,
+ otherCategories: categories.others.length,
+ totalMCPs,
+ categoryBreakdown,
+ };
+ } catch (error) {
+ console.error("Error getting category stats:", error);
+ return {
+ totalCategories: 0,
+ primaryCategories: 0,
+ otherCategories: 0,
+ totalMCPs: 0,
+ categoryBreakdown: {},
+ };
+ }
+ }
+}
diff --git a/database/repositories/mcp.ts b/database/repositories/mcp.ts
new file mode 100644
index 0000000..1a64407
--- /dev/null
+++ b/database/repositories/mcp.ts
@@ -0,0 +1,226 @@
+import Database from "better-sqlite3";
+import path from "path";
+
+const dbPath = path.join(process.cwd(), "database", "ollahub.db");
+const db = new Database(dbPath);
+
+export class MCPRepository {
+ /**
+ * Verificar se um MCP está instalado localmente
+ */
+ static isInstalled(mcpId: string): boolean {
+ try {
+ const stmt = db.prepare("SELECT id FROM mcp_installations WHERE id = ?");
+ const result = stmt.get(mcpId);
+ return !!result;
+ } catch (error) {
+ console.error(`Error checking if MCP ${mcpId} is installed:`, error);
+ return false;
+ }
+ }
+
+ /**
+ * Salvar instalação de MCP no banco local
+ */
+ static saveInstallation(mcpId: string, config: any, tools: any[]): void {
+ try {
+ const [owner, repo] = mcpId.split('/');
+ if (!owner || !repo) {
+ throw new Error(`Invalid MCP ID format: ${mcpId}`);
+ }
+
+ const stmt = db.prepare(`
+ INSERT OR REPLACE INTO mcp_installations
+ (id, owner, repo, name, config, tools, installed_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
+ `);
+
+ stmt.run(
+ mcpId,
+ owner,
+ repo,
+ config.name || repo,
+ JSON.stringify(config),
+ JSON.stringify(tools)
+ );
+
+ console.log(`MCP ${mcpId} saved to local database`);
+ } catch (error) {
+ console.error(`Error saving MCP ${mcpId} installation:`, error);
+ throw error;
+ }
+ }
+
+ /**
+ * Remover instalação de MCP do banco local
+ */
+ static removeInstallation(mcpId: string): void {
+ try {
+ const stmt = db.prepare("DELETE FROM mcp_installations WHERE id = ?");
+ const result = stmt.run(mcpId);
+
+ if (result.changes > 0) {
+ console.log(`MCP ${mcpId} removed from local database`);
+ } else {
+ console.log(`MCP ${mcpId} was not found in local database`);
+ }
+ } catch (error) {
+ console.error(`Error removing MCP ${mcpId} installation:`, error);
+ throw error;
+ }
+ }
+
+ /**
+ * Listar todos os MCPs instalados localmente
+ */
+ static listInstalled(): string[] {
+ try {
+ const stmt = db.prepare("SELECT id FROM mcp_installations ORDER BY installed_at DESC");
+ const results = stmt.all() as { id: string }[];
+ return results.map(row => row.id);
+ } catch (error) {
+ console.error("Error listing installed MCPs:", error);
+ return [];
+ }
+ }
+
+ /**
+ * Obter configuração local de um MCP
+ */
+ static getLocalConfig(mcpId: string): any {
+ try {
+ const stmt = db.prepare("SELECT config FROM mcp_installations WHERE id = ?");
+ const result = stmt.get(mcpId) as { config: string } | undefined;
+
+ if (result) {
+ return JSON.parse(result.config);
+ }
+ return null;
+ } catch (error) {
+ console.error(`Error getting local config for MCP ${mcpId}:`, error);
+ return null;
+ }
+ }
+
+ /**
+ * Obter ferramentas locais de um MCP
+ */
+ static getLocalTools(mcpId: string): any[] {
+ try {
+ const stmt = db.prepare("SELECT tools FROM mcp_installations WHERE id = ?");
+ const result = stmt.get(mcpId) as { tools: string } | undefined;
+
+ if (result) {
+ return JSON.parse(result.tools);
+ }
+ return [];
+ } catch (error) {
+ console.error(`Error getting local tools for MCP ${mcpId}:`, error);
+ return [];
+ }
+ }
+
+ /**
+ * Obter informações completas de um MCP instalado
+ */
+ static getInstalledMCP(mcpId: string): any {
+ try {
+ const stmt = db.prepare(`
+ SELECT id, owner, repo, name, config, tools, installed_at, updated_at
+ FROM mcp_installations WHERE id = ?
+ `);
+ const result = stmt.get(mcpId) as any;
+
+ if (result) {
+ return {
+ ...result,
+ config: JSON.parse(result.config),
+ tools: JSON.parse(result.tools)
+ };
+ }
+ return null;
+ } catch (error) {
+ console.error(`Error getting installed MCP ${mcpId}:`, error);
+ return null;
+ }
+ }
+
+ /**
+ * Listar todos os MCPs instalados com informações completas
+ */
+ static listInstalledWithDetails(): any[] {
+ try {
+ const stmt = db.prepare(`
+ SELECT id, owner, repo, name, config, tools, installed_at, updated_at
+ FROM mcp_installations ORDER BY installed_at DESC
+ `);
+ const results = stmt.all() as any[];
+
+ return results.map(row => ({
+ ...row,
+ config: JSON.parse(row.config),
+ tools: JSON.parse(row.tools)
+ }));
+ } catch (error) {
+ console.error("Error listing installed MCPs with details:", error);
+ return [];
+ }
+ }
+
+ /**
+ * Atualizar timestamp de um MCP instalado
+ */
+ static updateTimestamp(mcpId: string): void {
+ try {
+ const stmt = db.prepare("UPDATE mcp_installations SET updated_at = CURRENT_TIMESTAMP WHERE id = ?");
+ stmt.run(mcpId);
+ } catch (error) {
+ console.error(`Error updating timestamp for MCP ${mcpId}:`, error);
+ }
+ }
+
+ /**
+ * Verificar integridade do banco de dados
+ */
+ static checkDatabaseIntegrity(): boolean {
+ try {
+ const result = db.prepare("PRAGMA integrity_check").get() as { integrity_check: string };
+ return result.integrity_check === "ok";
+ } catch (error) {
+ console.error("Error checking database integrity:", error);
+ return false;
+ }
+ }
+
+ /**
+ * Obter estatísticas das instalações
+ */
+ static getInstallationStats(): any {
+ try {
+ const totalStmt = db.prepare("SELECT COUNT(*) as total FROM mcp_installations");
+ const totalResult = totalStmt.get() as { total: number };
+
+ const recentStmt = db.prepare(`
+ SELECT COUNT(*) as recent
+ FROM mcp_installations
+ WHERE installed_at > datetime('now', '-7 days')
+ `);
+ const recentResult = recentStmt.get() as { recent: number };
+
+ const categoriesStmt = db.prepare(`
+ SELECT COUNT(DISTINCT owner) as unique_owners
+ FROM mcp_installations
+ `);
+ const categoriesResult = categoriesStmt.get() as { unique_owners: number };
+
+ return {
+ total: totalResult.total,
+ recent: recentResult.recent,
+ uniqueOwners: categoriesResult.unique_owners
+ };
+ } catch (error) {
+ console.error("Error getting installation stats:", error);
+ return { total: 0, recent: 0, uniqueOwners: 0 };
+ }
+ }
+}
diff --git a/database/schema.sql b/database/schema.sql
index ae3ddc0..8447872 100644
--- a/database/schema.sql
+++ b/database/schema.sql
@@ -26,7 +26,55 @@ CREATE TABLE embeddings (
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE
);
+-- Tabela para gerenciar MCPs instalados
+CREATE TABLE IF NOT EXISTS mcp_installations (
+ id TEXT PRIMARY KEY,
+ owner TEXT NOT NULL,
+ repo TEXT NOT NULL,
+ name TEXT NOT NULL,
+ config JSON NOT NULL,
+ tools JSON,
+ installed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Cache de todos os servidores MCP do marketplace
+CREATE TABLE IF NOT EXISTS mcp_marketplace_cache (
+ id TEXT PRIMARY KEY,
+ owner TEXT NOT NULL,
+ repo TEXT NOT NULL,
+ content_name TEXT NOT NULL,
+ publisher_id TEXT NOT NULL,
+ description TEXT,
+ category TEXT,
+ subfield TEXT,
+ field TEXT,
+ rating REAL DEFAULT 0,
+ review_cnt INTEGER DEFAULT 0,
+ content_tag_list TEXT,
+ thumbnail_picture TEXT,
+ website TEXT,
+ detail_url TEXT,
+ ext_info JSON,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Metadados do cache (última sincronização, total de registros)
+CREATE TABLE IF NOT EXISTS mcp_cache_metadata (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
-- Índices para performance
CREATE INDEX idx_messages_conversation ON messages(conversation_id, timestamp);
CREATE INDEX idx_conversations_updated ON conversations(updated_at DESC);
CREATE INDEX idx_embeddings_message ON embeddings(message_id);
+CREATE INDEX idx_mcp_owner_repo ON mcp_installations(owner, repo);
+CREATE INDEX idx_mcp_installed_at ON mcp_installations(installed_at DESC);
+CREATE INDEX idx_mcp_cache_category ON mcp_marketplace_cache(category);
+CREATE INDEX idx_mcp_cache_subfield ON mcp_marketplace_cache(subfield);
+CREATE INDEX idx_mcp_cache_rating ON mcp_marketplace_cache(rating DESC);
+CREATE INDEX idx_mcp_cache_owner_repo ON mcp_marketplace_cache(owner, repo);
+CREATE INDEX idx_mcp_cache_search ON mcp_marketplace_cache(content_name, description);
diff --git a/hooks/useMCPMarketplace.ts b/hooks/useMCPMarketplace.ts
new file mode 100644
index 0000000..2397811
--- /dev/null
+++ b/hooks/useMCPMarketplace.ts
@@ -0,0 +1,192 @@
+"use client";
+
+import { useState, useEffect, useCallback } from "react";
+import { MCPProvider, MCPSearchParams } from "@/lib/types/mcp";
+
+export function useMCPMarketplace() {
+ const [mcps, setMcps] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [selectedCategory, setSelectedCategory] = useState("all");
+ const [searchTerm, setSearchTerm] = useState("");
+ const [sortBy, setSortBy] = useState("rating");
+ const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
+ const [selectedMCP, setSelectedMCP] = useState(null);
+ const [showDetailModal, setShowDetailModal] = useState(false);
+
+ const fetchMCPs = useCallback(
+ async (params?: Partial) => {
+ setLoading(true);
+ setError(null);
+
+ try {
+ const searchParams = new URLSearchParams();
+
+ const category = params?.category || selectedCategory;
+ const search = params?.search || searchTerm;
+ const sort = params?.sort || sortBy;
+ const order = params?.order || sortOrder;
+
+ if (category && category !== "all") {
+ searchParams.append("category", category);
+ }
+ if (search) {
+ searchParams.append("search", search);
+ }
+ if (sort) {
+ searchParams.append("sort", sort);
+ }
+ if (order) {
+ searchParams.append("order", order);
+ }
+ searchParams.append("limit", "2000"); // Aumentado para 2000
+
+ const response = await fetch(`/api/mcp/list?${searchParams}`);
+ const data = await response.json();
+
+ if (data.success) {
+ setMcps(data.mcps);
+ } else {
+ setError(data.error || "Failed to fetch MCPs");
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to fetch MCPs");
+ } finally {
+ setLoading(false);
+ }
+ },
+ [selectedCategory, searchTerm, sortBy, sortOrder]
+ );
+
+ const installMCP = useCallback(async (mcpId: string) => {
+ try {
+ const response = await fetch("/api/mcp/install", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ mcpId }),
+ });
+
+ const data = await response.json();
+ if (data.success) {
+ // Atualizar estado local
+ setMcps((prev) =>
+ prev.map((mcp) =>
+ mcp.id === mcpId ? { ...mcp, installed: true } : mcp
+ )
+ );
+ return { success: true, message: data.installation.message };
+ } else {
+ return { success: false, message: data.error };
+ }
+ } catch (err) {
+ return {
+ success: false,
+ message: err instanceof Error ? err.message : "Failed to install MCP",
+ };
+ }
+ }, []);
+
+ const uninstallMCP = useCallback(async (mcpId: string) => {
+ try {
+ const response = await fetch("/api/mcp/uninstall", {
+ method: "DELETE",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ mcpId }),
+ });
+
+ const data = await response.json();
+ if (data.success) {
+ // Atualizar estado local
+ setMcps((prev) =>
+ prev.map((mcp) =>
+ mcp.id === mcpId ? { ...mcp, installed: false } : mcp
+ )
+ );
+ return { success: true, message: data.installation.message };
+ } else {
+ return { success: false, message: data.error };
+ }
+ } catch (err) {
+ return {
+ success: false,
+ message: err instanceof Error ? err.message : "Failed to uninstall MCP",
+ };
+ }
+ }, []);
+
+ const viewMCPDetails = useCallback((mcp: MCPProvider) => {
+ setSelectedMCP(mcp);
+ setShowDetailModal(true);
+ }, []);
+
+ const closeDetailModal = useCallback(() => {
+ setShowDetailModal(false);
+ setSelectedMCP(null);
+ }, []);
+
+ const handleSearch = useCallback(
+ (search: string) => {
+ setSearchTerm(search);
+ fetchMCPs({ search });
+ },
+ [fetchMCPs]
+ );
+
+ const handleCategoryChange = useCallback(
+ (category: string) => {
+ setSelectedCategory(category);
+ fetchMCPs({ category });
+ },
+ [fetchMCPs]
+ );
+
+ const handleSortChange = useCallback(
+ (sort: MCPSearchParams["sort"]) => {
+ setSortBy(sort);
+ fetchMCPs({ sort });
+ },
+ [fetchMCPs]
+ );
+
+ const handleOrderChange = useCallback(
+ (order: "asc" | "desc") => {
+ setSortOrder(order);
+ fetchMCPs({ order });
+ },
+ [fetchMCPs]
+ );
+
+ // Carregar MCPs na inicialização
+ useEffect(() => {
+ fetchMCPs();
+ }, []);
+
+ return {
+ // Estado
+ mcps,
+ loading,
+ error,
+ selectedCategory,
+ searchTerm,
+ sortBy,
+ sortOrder,
+ selectedMCP,
+ showDetailModal,
+
+ // Ações
+ fetchMCPs,
+ installMCP,
+ uninstallMCP,
+ viewMCPDetails,
+ closeDetailModal,
+ handleSearch,
+ handleCategoryChange,
+ handleSortChange,
+ handleOrderChange,
+
+ // Setters diretos
+ setSearchTerm,
+ setSortBy,
+ setSortOrder,
+ };
+}
diff --git a/lib/cache/mcp-cache.ts b/lib/cache/mcp-cache.ts
new file mode 100644
index 0000000..497bf4f
--- /dev/null
+++ b/lib/cache/mcp-cache.ts
@@ -0,0 +1,173 @@
+import { DeepNLPSearchParams } from "@/lib/types/mcp";
+
+interface CacheEntry {
+ data: any;
+ timestamp: number;
+ ttl: number;
+}
+
+export class MCPCache {
+ private static cache = new Map();
+ private static TTL = 5 * 60 * 1000; // 5 minutos em millisegundos
+
+ /**
+ * Obter dados do cache
+ */
+ static get(key: string): any | null {
+ const entry = this.cache.get(key);
+
+ if (!entry) {
+ return null;
+ }
+
+ // Verificar se expirou
+ const now = Date.now();
+ if (now - entry.timestamp > entry.ttl) {
+ this.cache.delete(key);
+ return null;
+ }
+
+ console.log(`Cache hit for key: ${key}`);
+ return entry.data;
+ }
+
+ /**
+ * Salvar dados no cache
+ */
+ static set(key: string, value: any, ttl?: number): void {
+ const entry: CacheEntry = {
+ data: value,
+ timestamp: Date.now(),
+ ttl: ttl || this.TTL
+ };
+
+ this.cache.set(key, entry);
+ console.log(`Cache set for key: ${key}, TTL: ${entry.ttl}ms`);
+ }
+
+ /**
+ * Limpar cache
+ */
+ static clear(): void {
+ this.cache.clear();
+ console.log("Cache cleared");
+ }
+
+ /**
+ * Remover entrada específica do cache
+ */
+ static delete(key: string): void {
+ this.cache.delete(key);
+ console.log(`Cache entry deleted for key: ${key}`);
+ }
+
+ /**
+ * Gerar chave de cache baseada nos parâmetros de busca
+ */
+ static generateKey(params: DeepNLPSearchParams): string {
+ const sortedParams = Object.keys(params)
+ .sort()
+ .reduce((result, key) => {
+ const value = params[key as keyof DeepNLPSearchParams];
+ if (value !== undefined && value !== null) {
+ result[key] = value;
+ }
+ return result;
+ }, {} as Record);
+
+ return `mcp_search_${JSON.stringify(sortedParams)}`;
+ }
+
+ /**
+ * Gerar chave para configuração de servidor
+ */
+ static generateServerConfigKey(mcpId: string): string {
+ return `mcp_config_${mcpId}`;
+ }
+
+ /**
+ * Gerar chave para ferramentas de servidor
+ */
+ static generateServerToolsKey(mcpId: string): string {
+ return `mcp_tools_${mcpId}`;
+ }
+
+ /**
+ * Obter estatísticas do cache
+ */
+ static getStats(): {
+ size: number;
+ entries: Array<{ key: string; age: number; ttl: number }>;
+ } {
+ const now = Date.now();
+ const entries = Array.from(this.cache.entries()).map(([key, entry]) => ({
+ key,
+ age: now - entry.timestamp,
+ ttl: entry.ttl
+ }));
+
+ return {
+ size: this.cache.size,
+ entries
+ };
+ }
+
+ /**
+ * Limpar entradas expiradas
+ */
+ static cleanup(): number {
+ const now = Date.now();
+ let cleaned = 0;
+
+ for (const [key, entry] of this.cache.entries()) {
+ if (now - entry.timestamp > entry.ttl) {
+ this.cache.delete(key);
+ cleaned++;
+ }
+ }
+
+ if (cleaned > 0) {
+ console.log(`Cache cleanup: removed ${cleaned} expired entries`);
+ }
+
+ return cleaned;
+ }
+
+ /**
+ * Verificar se uma chave existe no cache (sem verificar TTL)
+ */
+ static has(key: string): boolean {
+ return this.cache.has(key);
+ }
+
+ /**
+ * Obter todas as chaves do cache
+ */
+ static keys(): string[] {
+ return Array.from(this.cache.keys());
+ }
+
+ /**
+ * Definir TTL personalizado para diferentes tipos de dados
+ */
+ static getTTLForDataType(dataType: 'search' | 'config' | 'tools'): number {
+ switch (dataType) {
+ case 'search':
+ return 5 * 60 * 1000; // 5 minutos para buscas
+ case 'config':
+ return 30 * 60 * 1000; // 30 minutos para configurações
+ case 'tools':
+ return 15 * 60 * 1000; // 15 minutos para ferramentas
+ default:
+ return this.TTL;
+ }
+ }
+
+ /**
+ * Cache com TTL específico para tipo de dados
+ */
+ static setWithDataType(key: string, value: any, dataType: 'search' | 'config' | 'tools'): void {
+ const ttl = this.getTTLForDataType(dataType);
+ this.set(key, value, ttl);
+ }
+}
diff --git a/lib/ollama.ts b/lib/ollama.ts
index 965c09d..848c7ba 100644
--- a/lib/ollama.ts
+++ b/lib/ollama.ts
@@ -102,3 +102,72 @@ export async function ensureModelExists(
console.log(`✅ Modelo ${modelName} já está disponível`);
}
}
+
+export async function deleteModel(modelName: string): Promise {
+ const client = new Ollama({ host: OLLAMA_HOST });
+ await client.delete({ model: modelName });
+}
+
+export async function searchRemoteModels(query?: string): Promise {
+ try {
+ // Ollama não tem API oficial para buscar modelos remotos
+ // Vamos usar uma lista conhecida de modelos populares
+ const popularModels = [
+ "llama3.2:1b",
+ "llama3.2:3b",
+ "llama3.2:11b",
+ "llama3.2:70b",
+ "llama3.1:8b",
+ "llama3.1:70b",
+ "llama3.1:405b",
+ "qwen2.5:1.5b",
+ "qwen2.5:3b",
+ "qwen2.5:7b",
+ "qwen2.5:14b",
+ "qwen2.5:32b",
+ "qwen2.5:72b",
+ "qwen2:0.5b",
+ "qwen2:1.5b",
+ "qwen2:3b",
+ "qwen2:7b",
+ "qwen2:14b",
+ "qwen2:32b",
+ "qwen2:72b",
+ "mistral:7b",
+ "mixtral:8x7b",
+ "mixtral:8x22b",
+ "phi3:mini",
+ "phi3:medium",
+ "phi3:large",
+ "gemma2:2b",
+ "gemma2:9b",
+ "gemma2:27b",
+ "codellama:7b",
+ "codellama:13b",
+ "codellama:34b",
+ "deepseek-coder:1.3b",
+ "deepseek-coder:6.7b",
+ "deepseek-coder:33b",
+ ];
+
+ // Filtrar por query se fornecida
+ const filteredModels = query
+ ? popularModels.filter((model) =>
+ model.toLowerCase().includes(query.toLowerCase())
+ )
+ : popularModels;
+
+ // Verificar quais já estão instalados
+ const installedModels = await listModelsViaSdk();
+ const installedNames = installedModels.map((m) => m.name);
+
+ return filteredModels.map((modelName) => ({
+ name: modelName,
+ installed: installedNames.includes(modelName),
+ remote: true,
+ }));
+ } catch (error) {
+ console.error("Erro ao buscar modelos remotos:", error);
+ return [];
+ }
+}
diff --git a/lib/services/deepnlp.ts b/lib/services/deepnlp.ts
new file mode 100644
index 0000000..5c8a7b6
--- /dev/null
+++ b/lib/services/deepnlp.ts
@@ -0,0 +1,288 @@
+import {
+ DeepNLPSearchParams,
+ DeepNLPResponse,
+ MCPServerConfig,
+ MCPTool,
+ MCPProvider
+} from "@/lib/types/mcp";
+import { MCPCache } from "@/lib/cache/mcp-cache";
+
+const DEEPNLP_API_BASE = "https://www.deepnlp.org/api/mcp_marketplace/v1";
+
+export class DeepNLPService {
+ // Mapear categorias do projeto para categorias DeepNLP
+ private static categoryMap = {
+ productivity: ['office'],
+ development: ['code', 'browser'],
+ data: ['database'],
+ integration: ['search', 'payment'],
+ other: ['map', 'chart', 'finance']
+ };
+
+ // Mapear categorias DeepNLP para categorias internas
+ private static reverseCategoryMap: Record = {
+ 'office': 'office',
+ 'code': 'code',
+ 'browser': 'browser',
+ 'database': 'database',
+ 'search': 'search',
+ 'payment': 'payment',
+ 'map': 'map',
+ 'chart': 'chart',
+ 'finance': 'finance'
+ };
+
+ /**
+ * Buscar servidores MCP na API DeepNLP
+ */
+ static async searchMCPs(params: DeepNLPSearchParams, retryCount = 0): Promise {
+ const maxRetries = 3;
+ const baseDelay = 1000; // 1 segundo
+
+ try {
+ // Verificar cache primeiro
+ const cacheKey = MCPCache.generateKey(params);
+ const cachedResult = MCPCache.get(cacheKey);
+
+ if (cachedResult) {
+ console.log('Returning cached search results');
+ return cachedResult;
+ }
+
+ const url = new URL(DEEPNLP_API_BASE);
+
+ // Adicionar parâmetros de busca
+ if (params.query) url.searchParams.set('query', params.query);
+ if (params.category) url.searchParams.set('category', params.category);
+ if (params.id) url.searchParams.set('id', params.id);
+ if (params.page_id !== undefined) url.searchParams.set('page_id', params.page_id.toString());
+ if (params.count_per_page !== undefined) url.searchParams.set('count_per_page', params.count_per_page.toString());
+ if (params.offset !== undefined) url.searchParams.set('offset', params.offset.toString());
+ if (params.mode) url.searchParams.set('mode', params.mode);
+
+ console.log(`DeepNLP API Request (attempt ${retryCount + 1}):`, url.toString());
+
+ const response = await fetch(url.toString(), {
+ method: 'GET',
+ headers: {
+ 'Accept': 'application/json',
+ 'User-Agent': 'Ollahub-MCP-Client/1.0'
+ },
+ // Timeout de 10 segundos
+ signal: AbortSignal.timeout(10000)
+ });
+
+ if (!response.ok) {
+ throw new Error(`DeepNLP API error: ${response.status} ${response.statusText}`);
+ }
+
+ const data = await response.json();
+ console.log('DeepNLP API Response:', data);
+
+ // Salvar no cache
+ MCPCache.setWithDataType(cacheKey, data, 'search');
+
+ return data;
+ } catch (error) {
+ console.error(`Error calling DeepNLP API (attempt ${retryCount + 1}):`, error);
+
+ // Se ainda temos tentativas restantes, tentar novamente com backoff exponencial
+ if (retryCount < maxRetries) {
+ const delay = baseDelay * Math.pow(2, retryCount); // Backoff exponencial
+ console.log(`Retrying in ${delay}ms...`);
+
+ await new Promise(resolve => setTimeout(resolve, delay));
+ return this.searchMCPs(params, retryCount + 1);
+ }
+
+ // Se esgotamos as tentativas, lançar erro
+ throw new Error(`Failed to fetch MCPs after ${maxRetries + 1} attempts: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+ }
+
+ /**
+ * Obter configuração completa de um servidor específico
+ */
+ static async getServerConfig(id: string, retryCount = 0): Promise {
+ const maxRetries = 3;
+ const baseDelay = 1000;
+
+ try {
+ // Verificar cache primeiro
+ const cacheKey = MCPCache.generateServerConfigKey(id);
+ const cachedResult = MCPCache.get(cacheKey);
+
+ if (cachedResult) {
+ console.log('Returning cached server config');
+ return cachedResult;
+ }
+
+ const [owner, repo] = id.split('/');
+ if (!owner || !repo) {
+ throw new Error(`Invalid MCP ID format: ${id}. Expected format: owner/repo`);
+ }
+
+ const url = `${DEEPNLP_API_BASE}/server/${owner}/${repo}`;
+ console.log(`DeepNLP Server Config Request (attempt ${retryCount + 1}):`, url);
+
+ const response = await fetch(url, {
+ method: 'GET',
+ headers: {
+ 'Accept': 'application/json',
+ 'User-Agent': 'Ollahub-MCP-Client/1.0'
+ },
+ signal: AbortSignal.timeout(10000)
+ });
+
+ if (!response.ok) {
+ throw new Error(`DeepNLP Server Config API error: ${response.status} ${response.statusText}`);
+ }
+
+ const data = await response.json();
+ console.log('DeepNLP Server Config Response:', data);
+
+ // Salvar no cache
+ MCPCache.setWithDataType(cacheKey, data, 'config');
+
+ return data;
+ } catch (error) {
+ console.error(`Error getting server config from DeepNLP (attempt ${retryCount + 1}):`, error);
+
+ if (retryCount < maxRetries) {
+ const delay = baseDelay * Math.pow(2, retryCount);
+ console.log(`Retrying server config in ${delay}ms...`);
+
+ await new Promise(resolve => setTimeout(resolve, delay));
+ return this.getServerConfig(id, retryCount + 1);
+ }
+
+ throw new Error(`Failed to get server config after ${maxRetries + 1} attempts: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+ }
+
+ /**
+ * Listar ferramentas de um servidor específico
+ */
+ static async getServerTools(id: string, retryCount = 0): Promise {
+ const maxRetries = 3;
+ const baseDelay = 1000;
+
+ try {
+ // Verificar cache primeiro
+ const cacheKey = MCPCache.generateServerToolsKey(id);
+ const cachedResult = MCPCache.get(cacheKey);
+
+ if (cachedResult) {
+ console.log('Returning cached server tools');
+ return cachedResult;
+ }
+
+ const [owner, repo] = id.split('/');
+ if (!owner || !repo) {
+ throw new Error(`Invalid MCP ID format: ${id}. Expected format: owner/repo`);
+ }
+
+ const url = `${DEEPNLP_API_BASE}/tools/${owner}/${repo}`;
+ console.log(`DeepNLP Server Tools Request (attempt ${retryCount + 1}):`, url);
+
+ const response = await fetch(url, {
+ method: 'GET',
+ headers: {
+ 'Accept': 'application/json',
+ 'User-Agent': 'Ollahub-MCP-Client/1.0'
+ },
+ signal: AbortSignal.timeout(10000)
+ });
+
+ if (!response.ok) {
+ throw new Error(`DeepNLP Server Tools API error: ${response.status} ${response.statusText}`);
+ }
+
+ const data = await response.json();
+ console.log('DeepNLP Server Tools Response:', data);
+
+ const tools = data.tools || [];
+
+ // Salvar no cache
+ MCPCache.setWithDataType(cacheKey, tools, 'tools');
+
+ return tools;
+ } catch (error) {
+ console.error(`Error getting server tools from DeepNLP (attempt ${retryCount + 1}):`, error);
+
+ if (retryCount < maxRetries) {
+ const delay = baseDelay * Math.pow(2, retryCount);
+ console.log(`Retrying server tools in ${delay}ms...`);
+
+ await new Promise(resolve => setTimeout(resolve, delay));
+ return this.getServerTools(id, retryCount + 1);
+ }
+
+ throw new Error(`Failed to get server tools after ${maxRetries + 1} attempts: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+ }
+
+ /**
+ * Transformar resposta DeepNLP para formato interno MCPProvider
+ */
+ static transformToMCPProvider(deepnlpItem: any): MCPProvider {
+ const category = this.mapCategory(deepnlpItem.subfield || 'other');
+
+ return {
+ id: deepnlpItem.id || '',
+ name: deepnlpItem.content_name || '',
+ author: deepnlpItem.publisher_id || '',
+ description: deepnlpItem.description || '',
+ version: '1.0.0', // DeepNLP não fornece versão específica
+ category,
+ tags: deepnlpItem.content_tag_list ? deepnlpItem.content_tag_list.split(',').map((tag: string) => tag.trim()) : [],
+ rating: parseFloat(deepnlpItem.rating || '0'),
+ totalRatings: parseInt(deepnlpItem.review_cnt || '0'),
+ downloads: parseInt(deepnlpItem.review_cnt || '0') * 10, // Estimativa baseada em reviews
+ iconUrl: deepnlpItem.thumbnail_picture,
+ screenshotUrls: [],
+ capabilities: deepnlpItem.ext_info?.tools || [],
+ repository: deepnlpItem.website,
+ homepage: deepnlpItem.website,
+ installed: false, // Será verificado localmente
+ subfield: deepnlpItem.subfield,
+ field: deepnlpItem.field,
+ config: deepnlpItem.config,
+ tools: deepnlpItem.ext_info?.tools || []
+ };
+ }
+
+ /**
+ * Mapear categoria DeepNLP para categoria interna
+ */
+ static mapCategory(subfield: string): MCPProvider['category'] {
+ return this.reverseCategoryMap[subfield?.toLowerCase()] || 'other';
+ }
+
+ /**
+ * Obter mapeamento de categoria interna para DeepNLP
+ */
+ static getCategoryMapping(internalCategory: string): string | undefined {
+ const mappings = this.categoryMap[internalCategory as keyof typeof this.categoryMap];
+ return mappings?.[0]; // Retorna a primeira categoria DeepNLP correspondente
+ }
+
+ /**
+ * Verificar se a API está disponível
+ */
+ static async checkHealth(): Promise {
+ try {
+ const response = await fetch(`${DEEPNLP_API_BASE}?count_per_page=1`, {
+ method: 'GET',
+ headers: {
+ 'Accept': 'application/json',
+ 'User-Agent': 'Ollahub-MCP-Client/1.0'
+ }
+ });
+ return response.ok;
+ } catch (error) {
+ console.error('DeepNLP API health check failed:', error);
+ return false;
+ }
+ }
+}
diff --git a/lib/services/mcp-sync.ts b/lib/services/mcp-sync.ts
new file mode 100644
index 0000000..1e942cc
--- /dev/null
+++ b/lib/services/mcp-sync.ts
@@ -0,0 +1,330 @@
+import { DeepNLPService } from "./deepnlp";
+import { MCPCacheRepository } from "@/database/repositories/mcp-cache";
+
+export interface SyncProgress {
+ current: number;
+ total: number;
+ page: number;
+ totalPages: number;
+}
+
+export interface SyncResult {
+ success: boolean;
+ totalDownloaded: number;
+ errors: string[];
+ duration: number;
+}
+
+export class MCPSyncService {
+ private static isSyncingFlag = false;
+ private static abortController: AbortController | null = null;
+ private static currentProgress: SyncProgress | null = null;
+
+ /**
+ * Download completo com paginação incremental
+ */
+ static async syncAll(
+ onProgress?: (progress: SyncProgress) => void
+ ): Promise {
+ const startTime = Date.now();
+ const errors: string[] = [];
+ let totalDownloaded = 0;
+
+ try {
+ if (this.isSyncingFlag) {
+ throw new Error("Sync already in progress");
+ }
+
+ this.isSyncingFlag = true;
+ this.abortController = new AbortController();
+
+ console.log("🔄 Starting MCP marketplace sync...");
+
+ // Limpar cache antigo antes de começar
+ MCPCacheRepository.clearCache();
+
+ const allMCPs = await this.downloadAllPages(onProgress);
+ totalDownloaded = allMCPs.length;
+
+ // Salvar todos os MCPs no banco
+ if (allMCPs.length > 0) {
+ MCPCacheRepository.saveBatch(allMCPs);
+
+ // Atualizar metadados
+ MCPCacheRepository.updateMetadata("last_sync", Date.now().toString());
+ MCPCacheRepository.updateMetadata(
+ "total_items",
+ allMCPs.length.toString()
+ );
+
+ console.log(`✅ Sync completed: ${totalDownloaded} MCPs downloaded`);
+ }
+
+ const duration = Date.now() - startTime;
+ return {
+ success: true,
+ totalDownloaded,
+ errors,
+ duration,
+ };
+ } catch (error: any) {
+ console.error("❌ Sync failed:", error);
+ errors.push(error.message);
+
+ const duration = Date.now() - startTime;
+ return {
+ success: false,
+ totalDownloaded,
+ errors,
+ duration,
+ };
+ } finally {
+ this.isSyncingFlag = false;
+ this.abortController = null;
+ this.currentProgress = null;
+ }
+ }
+
+ /**
+ * Sincronização em background (usado automaticamente)
+ */
+ static async backgroundSync(): Promise {
+ try {
+ if (this.isSyncingFlag) {
+ console.log("Sync already in progress, skipping background sync");
+ return;
+ }
+
+ console.log("🔄 Starting background MCP sync...");
+ await this.syncAll((progress) => {
+ console.log(
+ `📥 Downloaded ${progress.current}/${progress.total} MCPs (page ${progress.page}/${progress.totalPages})`
+ );
+ });
+ } catch (error) {
+ console.error("Background sync failed:", error);
+ }
+ }
+
+ /**
+ * Verificar se sincronização está em andamento
+ */
+ static isSyncing(): boolean {
+ return this.isSyncingFlag;
+ }
+
+ /**
+ * Cancelar sincronização atual
+ */
+ static cancelSync(): void {
+ if (this.abortController) {
+ this.abortController.abort();
+ console.log("🛑 Sync cancelled by user");
+ }
+ }
+
+ /**
+ * Obter progresso atual da sincronização
+ */
+ static getCurrentProgress(): SyncProgress | null {
+ return this.currentProgress;
+ }
+
+ /**
+ * Download paginado de todas as páginas - SEM LIMITE ARTIFICIAL
+ */
+ private static async downloadAllPages(
+ onProgress?: (progress: SyncProgress) => void
+ ): Promise {
+ const allMCPs: any[] = [];
+ let page = 0;
+ let COUNT_PER_PAGE = 100; // Começar com 100
+ let totalHits = 0;
+ let consecutiveEmptyPages = 0; // Contador para páginas vazias consecutivas
+
+ console.log("📡 Starting unlimited paginated download...");
+
+ while (!this.abortController?.signal.aborted) {
+ try {
+ const params = {
+ mode: "list" as const,
+ page_id: page,
+ count_per_page: COUNT_PER_PAGE,
+ };
+
+ console.log(
+ `📄 Downloading page ${
+ page + 1
+ } (${COUNT_PER_PAGE} items per page)...`
+ );
+
+ const response = await DeepNLPService.searchMCPs(params);
+
+ // Se não há itens na resposta, incrementar contador de páginas vazias
+ if (!response.items || response.items.length === 0) {
+ consecutiveEmptyPages++;
+ console.log(
+ `⚠️ Empty page ${
+ page + 1
+ } (${consecutiveEmptyPages} consecutive empty pages)`
+ );
+
+ // Se temos 3 páginas vazias consecutivas, parar o download
+ if (consecutiveEmptyPages >= 3) {
+ console.log(
+ "🛑 Stopping download: 3 consecutive empty pages detected"
+ );
+ break;
+ }
+
+ page++;
+ continue;
+ }
+
+ // Reset contador de páginas vazias se encontramos dados
+ consecutiveEmptyPages = 0;
+ allMCPs.push(...response.items);
+
+ // Atualizar total hits na primeira página
+ if (page === 0) {
+ totalHits = response.total_hits;
+ console.log(`📊 Total MCPs available: ${totalHits}`);
+ }
+
+ // Notificar progresso
+ this.currentProgress = {
+ current: allMCPs.length,
+ total: totalHits,
+ page: page + 1,
+ totalPages:
+ totalHits > 0 ? Math.ceil(totalHits / COUNT_PER_PAGE) : page + 1,
+ };
+
+ if (onProgress) {
+ onProgress(this.currentProgress);
+ }
+
+ // Incrementar página
+ page++;
+
+ // Salvar em batch a cada 500 itens para evitar bloqueio
+ if (allMCPs.length % 500 === 0) {
+ console.log(`💾 Saving batch of 500 MCPs to database...`);
+ MCPCacheRepository.saveBatch(allMCPs.slice(-500));
+ }
+
+ // Pequena pausa entre requisições para não sobrecarregar a API
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ // Aumentar tamanho da página progressivamente para otimizar
+ if (page === 5 && COUNT_PER_PAGE === 100) {
+ COUNT_PER_PAGE = 500;
+ console.log("🚀 Increasing page size to 500 for faster download");
+ } else if (page === 20 && COUNT_PER_PAGE === 500) {
+ COUNT_PER_PAGE = 1000;
+ console.log("🚀 Increasing page size to 1000 for maximum efficiency");
+ }
+
+ // Log de progresso a cada 10 páginas
+ if (page % 10 === 0) {
+ console.log(
+ `📈 Progress: ${allMCPs.length} MCPs downloaded (page ${page})`
+ );
+ }
+ } catch (error: any) {
+ console.error(`❌ Error downloading page ${page + 1}:`, error);
+
+ // Se for erro de abort, parar
+ if (this.abortController?.signal.aborted) {
+ console.log("🛑 Download cancelled");
+ break;
+ }
+
+ // Se for erro de rede, tentar continuar
+ if (
+ error.message.includes("fetch") ||
+ error.message.includes("network")
+ ) {
+ console.log("🔄 Network error, retrying in 2 seconds...");
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+ continue;
+ }
+
+ // Para outros erros, parar
+ throw error;
+ }
+ }
+
+ // Salvar último batch se houver
+ if (allMCPs.length > 0) {
+ const remainingItems = allMCPs.length % 500;
+ if (remainingItems > 0) {
+ console.log(`💾 Saving final batch of ${remainingItems} MCPs...`);
+ MCPCacheRepository.saveBatch(allMCPs.slice(-remainingItems));
+ }
+ }
+
+ console.log(
+ `✅ Download completed: ${allMCPs.length} total MCPs downloaded`
+ );
+ return allMCPs;
+ }
+
+ /**
+ * Verificar se precisa sincronizar
+ */
+ static needsSync(): boolean {
+ return MCPCacheRepository.needsSync();
+ }
+
+ /**
+ * Obter estatísticas do cache
+ */
+ static getCacheStats() {
+ return MCPCacheRepository.getStats();
+ }
+
+ /**
+ * Forçar limpeza do cache
+ */
+ static clearCache(): void {
+ MCPCacheRepository.clearCache();
+ console.log("🗑️ Cache cleared");
+ }
+
+ /**
+ * Verificar saúde do sistema de cache
+ */
+ static async healthCheck(): Promise<{
+ cacheExists: boolean;
+ cacheValid: boolean;
+ totalItems: number;
+ lastSync: number | null;
+ needsSync: boolean;
+ }> {
+ try {
+ const cacheExists = MCPCacheRepository.tableExists(
+ "mcp_marketplace_cache"
+ );
+ const stats = MCPCacheRepository.getStats();
+ const needsSync = MCPCacheRepository.needsSync();
+
+ return {
+ cacheExists,
+ cacheValid: stats.isValid,
+ totalItems: stats.totalItems,
+ lastSync: stats.lastSync,
+ needsSync,
+ };
+ } catch (error) {
+ console.error("Health check failed:", error);
+ return {
+ cacheExists: false,
+ cacheValid: false,
+ totalItems: 0,
+ lastSync: null,
+ needsSync: true,
+ };
+ }
+ }
+}
diff --git a/lib/types/mcp.ts b/lib/types/mcp.ts
new file mode 100644
index 0000000..397bbfe
--- /dev/null
+++ b/lib/types/mcp.ts
@@ -0,0 +1,94 @@
+export interface MCPProvider {
+ id: string; // formato: "owner/repo"
+ name: string; // mapeado de content_name
+ author: string; // mapeado de publisher_id
+ description: string;
+ version: string;
+ category:
+ | "map"
+ | "browser"
+ | "office"
+ | "search"
+ | "database"
+ | "finance"
+ | "code"
+ | "chart"
+ | "payment"
+ | "other";
+ tags: string[]; // mapeado de content_tag_list
+ rating: number; // já existe na API
+ totalRatings: number; // mapeado de review_cnt
+ downloads: number; // opcional, calcular baseado em reviews
+ iconUrl?: string; // mapeado de thumbnail_picture
+ screenshotUrls?: string[];
+ capabilities: string[]; // mapeado de ext_info.tools
+ repository?: string; // mapeado de website
+ homepage?: string;
+ installed: boolean; // verificar localmente
+ // Novos campos da API DeepNLP
+ subfield?: string; // MAP, BROWSER, etc
+ field?: string; // sempre "MCP SERVER"
+ config?: any[]; // configuração JSON do servidor
+ tools?: MCPTool[]; // schemas das ferramentas
+}
+
+export interface MCPCategory {
+ id: string;
+ name: string;
+ icon: string;
+ count: number;
+}
+
+export interface MCPInstallationStatus {
+ success: boolean;
+ message: string;
+ mcpId: string;
+}
+
+export interface MCPTool {
+ name: string;
+ description: string;
+ input_schema: {
+ type: string;
+ properties: Record;
+ required?: string[];
+ };
+}
+
+export interface DeepNLPSearchParams {
+ query?: string;
+ category?: string;
+ id?: string;
+ page_id?: number;
+ count_per_page?: number;
+ offset?: number;
+ mode?: "list" | "dict";
+}
+
+export interface DeepNLPResponse {
+ query?: string;
+ items: any[];
+ count: number;
+ total_hits: number;
+}
+
+export interface MCPServerConfig {
+ total_hits: number;
+ id: string;
+ items: any[];
+}
+
+export interface MCPSearchParams {
+ category?: string;
+ search?: string;
+ sort?:
+ | "rating"
+ | "downloads"
+ | "name"
+ | "recent"
+ | "total_ratings"
+ | "updated_at";
+ order?: "asc" | "desc";
+ limit?: number;
+ offset?: number;
+}
diff --git a/scripts/init-db.ts b/scripts/init-db.ts
index a5358bb..b75d2f6 100644
--- a/scripts/init-db.ts
+++ b/scripts/init-db.ts
@@ -61,6 +61,88 @@ function runMigrations(db: Database.Database): void {
console.log("✅ Migração 001_initial_schema aplicada");
}
+ // Aplicar migração para tabela MCP se não foi aplicada
+ if (!appliedNames.has("002_mcp_installations")) {
+ console.log("📋 Aplicando migração: 002_mcp_installations");
+
+ db.exec(`
+ -- Tabela para gerenciar MCPs instalados
+ CREATE TABLE IF NOT EXISTS mcp_installations (
+ id TEXT PRIMARY KEY,
+ owner TEXT NOT NULL,
+ repo TEXT NOT NULL,
+ name TEXT NOT NULL,
+ config JSON NOT NULL,
+ tools JSON,
+ installed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+
+ -- Índices para performance
+ CREATE INDEX IF NOT EXISTS idx_mcp_owner_repo ON mcp_installations(owner, repo);
+ CREATE INDEX IF NOT EXISTS idx_mcp_installed_at ON mcp_installations(installed_at DESC);
+ `);
+
+ // Registrar migração
+ db.prepare("INSERT INTO _migrations (name, applied_at) VALUES (?, ?)").run(
+ "002_mcp_installations",
+ Date.now()
+ );
+
+ console.log("✅ Migração 002_mcp_installations aplicada");
+ }
+
+ // Aplicar migração para tabela MCP marketplace cache se não foi aplicada
+ if (!appliedNames.has("003_mcp_marketplace_cache")) {
+ console.log("📋 Aplicando migração: 003_mcp_marketplace_cache");
+
+ db.exec(`
+ -- Tabela principal de cache
+ CREATE TABLE IF NOT EXISTS mcp_marketplace_cache (
+ id TEXT PRIMARY KEY,
+ owner TEXT NOT NULL,
+ repo TEXT NOT NULL,
+ content_name TEXT NOT NULL,
+ publisher_id TEXT NOT NULL,
+ description TEXT,
+ category TEXT,
+ subfield TEXT,
+ field TEXT,
+ rating REAL DEFAULT 0,
+ review_cnt INTEGER DEFAULT 0,
+ content_tag_list TEXT,
+ thumbnail_picture TEXT,
+ website TEXT,
+ detail_url TEXT,
+ ext_info JSON,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+
+ -- Metadados
+ CREATE TABLE IF NOT EXISTS mcp_cache_metadata (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+
+ -- Índices
+ CREATE INDEX IF NOT EXISTS idx_mcp_cache_category ON mcp_marketplace_cache(category);
+ CREATE INDEX IF NOT EXISTS idx_mcp_cache_subfield ON mcp_marketplace_cache(subfield);
+ CREATE INDEX IF NOT EXISTS idx_mcp_cache_rating ON mcp_marketplace_cache(rating DESC);
+ CREATE INDEX IF NOT EXISTS idx_mcp_cache_owner_repo ON mcp_marketplace_cache(owner, repo);
+ CREATE INDEX IF NOT EXISTS idx_mcp_cache_search ON mcp_marketplace_cache(content_name, description);
+ `);
+
+ // Registrar migração
+ db.prepare("INSERT INTO _migrations (name, applied_at) VALUES (?, ?)").run(
+ "003_mcp_marketplace_cache",
+ Date.now()
+ );
+
+ console.log("✅ Migração 003_mcp_marketplace_cache aplicada");
+ }
+
// Inicializar extensão vetorial se disponível
try {
const extPath = path.join(
@@ -147,8 +229,24 @@ if (require.main === module) {
initializeTitleModel(),
initializeEmbeddingModel(),
])
- .then(() => {
+ .then(async () => {
console.log("🎉 Inicialização concluída");
+
+ // Iniciar sincronização do marketplace se necessário
+ try {
+ const { MCPSyncService } = await import("@/lib/services/mcp-sync");
+ const { MCPCacheRepository } = await import("@/database/repositories/mcp-cache");
+
+ if (MCPCacheRepository.needsSync()) {
+ console.log("🔄 Iniciando sincronização inicial do marketplace...");
+ MCPSyncService.backgroundSync();
+ } else {
+ console.log("✅ Cache do marketplace já está atualizado");
+ }
+ } catch (error) {
+ console.log("⚠️ Não foi possível inicializar sincronização do marketplace:", error);
+ }
+
process.exit(0);
})
.catch((error) => {