- 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.
545 lines
15 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|