Files
OllaHub/database/repositories/mcp-cache.ts
T
Evandro Fonseca Junior f19bf10ef3 feat(mcp): enhance MCP installation with status tracking and custom configs
- Add /mcp-servers/ to .gitignore for MCP server installations
- Extend mcp_installations table with status, logs, and validation fields
- Refactor MCP install route to support custom configs, enable logs, and async installation
- Improve MCPSyncService with promise-based syncing to prevent concurrent operations

This update improves MCP management by adding robust installation tracking, custom configuration support, and better error handling for a more reliable user experience.
2025-10-28 21:59:00 -03:00

545 lines
15 KiB
TypeScript

import { getDatabase } from "@/database/database";
import { PulseMCPService } from "@/lib/services/pulsemcp";
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 pulseMCPItem of mcps) {
// Transformar dados PulseMCP para formato interno
const mcpProvider =
PulseMCPService.transformToMCPProvider(pulseMCPItem);
// Extrair owner/repo do package_name ou usar name como fallback
const packageName = pulseMCPItem.package_name || pulseMCPItem.name;
const [owner, repo] = packageName.includes("/")
? packageName.split("/")
: ["", packageName];
stmt.run(
mcpProvider.id,
owner,
repo,
mcpProvider.name,
mcpProvider.author,
mcpProvider.description,
mcpProvider.category,
mcpProvider.subfield,
mcpProvider.field,
mcpProvider.rating,
mcpProvider.totalRatings,
mcpProvider.tags.join(","),
null, // Não temos ícones disponíveis
mcpProvider.homepage || null,
pulseMCPItem.source_code_url || null,
JSON.stringify({
package_registry: pulseMCPItem.package_registry,
package_name: pulseMCPItem.package_name,
github_stars: pulseMCPItem.github_stars,
package_download_count: pulseMCPItem.package_download_count,
config: mcpProvider.config,
tools: mcpProvider.tools,
pulseMCP_meta: pulseMCPItem._meta,
}),
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: string;
const queryParams: any[] = [];
if (params.query) {
// Usar FTS5 para busca textual
query = `
SELECT c.* FROM mcp_marketplace_cache c
INNER JOIN mcp_search_fts f ON c.rowid = f.rowid
WHERE mcp_search_fts MATCH ?
`;
// Escapar caracteres especiais e adicionar wildcard
const searchTerm = params.query.replace(/[^a-zA-Z0-9\s]/g, " ").trim();
queryParams.push(`${searchTerm}*`); // Prefix search
// Filtro de categoria (se houver)
if (params.category) {
query += " AND (c.category = ? OR c.subfield = ?)";
queryParams.push(params.category, params.category);
}
// Ordenar por relevância FTS + rating
query += " ORDER BY f.rank, c.rating DESC";
} else {
// Query sem busca textual (usar índices normais)
query = "SELECT * FROM mcp_marketplace_cache WHERE 1=1";
if (params.category) {
query += " AND (category = ? OR subfield = ?)";
queryParams.push(params.category, params.category);
}
// Ordenação por índice apropriado
const order = params.order || "desc";
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 ? OFFSET ?";
queryParams.push(params.limit, params.offset || 0);
}
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<string, number>;
} {
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<string, number>);
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<string, number>;
} {
try {
const categories = this.getCategories();
const totalMCPs = this.count();
const categoryBreakdown: Record<string, number> = {};
// 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: {},
};
}
}
/**
* Buscar MCP por owner e repo
*/
static findByOwnerRepo(owner: string, repo: string): MCPCacheItem | null {
try {
const stmt = db.prepare(`
SELECT * FROM mcp_marketplace_cache
WHERE owner = ? AND repo = ?
`);
const result = stmt.get(owner, repo) as MCPCacheItem | undefined;
return result || null;
} catch (error) {
console.error("Error finding MCP by owner/repo:", error);
return null;
}
}
/**
* Buscar MCP por nome
*/
static findByName(name: string): MCPCacheItem | null {
try {
const stmt = db.prepare(`
SELECT * FROM mcp_marketplace_cache
WHERE content_name = ? OR repo = ?
`);
const result = stmt.get(name, name) as MCPCacheItem | undefined;
return result || null;
} catch (error) {
console.error("Error finding MCP by name:", error);
return null;
}
}
/**
* Buscar MCP por ID
*/
static findById(id: string): MCPCacheItem | null {
try {
const stmt = db.prepare(`
SELECT * FROM mcp_marketplace_cache
WHERE id = ?
`);
const result = stmt.get(id) as MCPCacheItem | undefined;
return result || null;
} catch (error) {
console.error("Error finding MCP by ID:", error);
return null;
}
}
}