refactor(api): remove MCP marketplace cache integration
Removed dependencies on MCPCacheRepository and PulseMCPService from MCP routes to simplify installation and listing logic, avoiding reliance on potentially stale cached data and streamlining the codebase. Updated available-for-chat filter to include MCPs without explicit status.
This commit is contained in:
1 parent
4739a13458
commit
f96d4ed696
34 files changed
+260
-3775
No files matched your search
+31
-2
@@ -9,20 +9,49 @@ export async function generateEmbedding(
|
||||
model: string = "nomic-embed-text"
|
||||
): Promise<Float32Array> {
|
||||
try {
|
||||
// Verificar se deve forçar uso de CPU (útil quando GPU tem problemas de memória)
|
||||
const forceCpu =
|
||||
process.env.OLLAMA_NO_GPU === "1" || process.env.OLLAMA_NUM_GPU === "0";
|
||||
|
||||
const response = await ollama.embeddings({
|
||||
model,
|
||||
prompt: text,
|
||||
options: forceCpu ? { num_gpu: 0 } : undefined,
|
||||
});
|
||||
|
||||
return new Float32Array(response.embedding);
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error("Erro ao gerar embedding:", error);
|
||||
|
||||
// Se erro de GPU e ainda não tentou CPU, tentar forçar CPU
|
||||
const isGpuError =
|
||||
error?.message?.includes("CUDA") ||
|
||||
error?.message?.includes("unable to allocate") ||
|
||||
error?.status_code === 500;
|
||||
|
||||
if (
|
||||
isGpuError &&
|
||||
process.env.OLLAMA_NO_GPU !== "1" &&
|
||||
process.env.OLLAMA_NUM_GPU !== "0"
|
||||
) {
|
||||
console.warn("Erro de GPU detectado, tentando usar CPU...");
|
||||
try {
|
||||
const response = await ollama.embeddings({
|
||||
model,
|
||||
prompt: text,
|
||||
options: { num_gpu: 0 },
|
||||
});
|
||||
return new Float32Array(response.embedding);
|
||||
} catch (cpuError) {
|
||||
console.error("Erro mesmo usando CPU:", cpuError);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: retornar vetor zero se modelo não estiver disponível
|
||||
console.warn(
|
||||
`Modelo ${model} não disponível, usando vetor zero como fallback`
|
||||
);
|
||||
return new Float32Array(384); // 384 dimensões padrão
|
||||
return new Float32Array(768); // 768 dimensões para nomic-embed-text
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -150,6 +150,7 @@ export function extractToolCallFromResponse(response: string): {
|
||||
|
||||
const parsed = JSON.parse(jsonText.trim());
|
||||
|
||||
// Formato atual suportado pelo prompt engineering
|
||||
if (parsed.tool_call && parsed.tool_call.name) {
|
||||
return {
|
||||
name: parsed.tool_call.name,
|
||||
@@ -157,14 +158,60 @@ export function extractToolCallFromResponse(response: string): {
|
||||
};
|
||||
}
|
||||
|
||||
// Formato OpenAI-like: { type: 'function', function: { name, parameters|arguments } }
|
||||
// Algumas implementações usam `arguments` como string JSON
|
||||
if (parsed.type === "function" && parsed.function && parsed.function.name) {
|
||||
const fn = parsed.function;
|
||||
let params: any = fn.parameters ?? fn.arguments ?? {};
|
||||
if (typeof params === "string") {
|
||||
try {
|
||||
params = JSON.parse(params);
|
||||
} catch {
|
||||
// se não for JSON válido, mantém string (executor fará fallback)
|
||||
}
|
||||
}
|
||||
return { name: fn.name, parameters: params || {} };
|
||||
}
|
||||
|
||||
// Formato simples: { name: "tool", parameters: {...} }
|
||||
if (parsed.name && (parsed.parameters || parsed.arguments)) {
|
||||
let params: any = parsed.parameters ?? parsed.arguments ?? {};
|
||||
if (typeof params === "string") {
|
||||
try {
|
||||
params = JSON.parse(params);
|
||||
} catch {
|
||||
// mantém string se não for JSON válido
|
||||
}
|
||||
}
|
||||
return { name: parsed.name, parameters: params || {} };
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
// Tentar extrair manualmente
|
||||
const nameMatch = response.match(/"name":\s*"([^"]+)"/);
|
||||
if (nameMatch) {
|
||||
const name = nameMatch[1];
|
||||
const paramsMatch = response.match(/"parameters":\s*({[^}]+})/);
|
||||
const parameters = paramsMatch ? JSON.parse(paramsMatch[1]) : {};
|
||||
// Captura tanto parameters quanto arguments
|
||||
const paramsMatch =
|
||||
response.match(/"parameters"\s*:\s*({[\s\S]*?})/) ||
|
||||
response.match(/"arguments"\s*:\s*("[\s\S]*?"|{[\s\S]*?})/);
|
||||
let parameters: any = {};
|
||||
if (paramsMatch) {
|
||||
const raw = paramsMatch[1];
|
||||
try {
|
||||
parameters = JSON.parse(raw);
|
||||
} catch {
|
||||
// caso venha como string com JSON dentro de aspas
|
||||
if (raw.startsWith('"') && raw.endsWith('"')) {
|
||||
try {
|
||||
parameters = JSON.parse(raw.slice(1, -1));
|
||||
} catch {
|
||||
parameters = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { name, parameters };
|
||||
}
|
||||
|
||||
@@ -31,16 +31,31 @@ export class MCPExecutor {
|
||||
}
|
||||
|
||||
// Verificar se está pronto
|
||||
if (mcpData.status !== "ready") {
|
||||
// Se status é undefined/null, considerar como "ready" (MCPs instalados antes da implementação de status)
|
||||
if (mcpData.status && mcpData.status !== "ready") {
|
||||
throw new Error(
|
||||
`MCP ${mcpId} is not ready (status: ${mcpData.status})`
|
||||
);
|
||||
}
|
||||
|
||||
// Obter comando executável
|
||||
const config = JSON.parse(mcpData.config);
|
||||
const command = config.command || mcpData.executable_command;
|
||||
const args = config.args || [];
|
||||
// mcpData.config já vem parseado do repositório, mas pode ser string em casos legados
|
||||
const config =
|
||||
typeof mcpData.config === "string"
|
||||
? JSON.parse(mcpData.config)
|
||||
: mcpData.config || {};
|
||||
const commandFromConfig = config.command;
|
||||
const argsFromConfig = Array.isArray(config.args) ? config.args : [];
|
||||
|
||||
let command = commandFromConfig;
|
||||
let args = argsFromConfig;
|
||||
|
||||
// Se não houver command em config, reconstruir a partir de executable_command (se existir)
|
||||
if (!command && mcpData.executable_command) {
|
||||
const parts = String(mcpData.executable_command).trim().split(/\s+/);
|
||||
command = parts[0];
|
||||
args = parts.slice(1);
|
||||
}
|
||||
|
||||
if (!command) {
|
||||
throw new Error(`No executable command found for MCP ${mcpId}`);
|
||||
@@ -100,14 +115,14 @@ export class MCPExecutor {
|
||||
): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const env = {
|
||||
...process.env,
|
||||
...globalThis.process.env,
|
||||
...(environmentPath && {
|
||||
PATH: `${environmentPath}/node_modules/.bin:${process.env.PATH}`,
|
||||
PATH: `${environmentPath}/node_modules/.bin:${globalThis.process.env.PATH}`,
|
||||
}),
|
||||
};
|
||||
|
||||
const process = spawn(command, args, {
|
||||
cwd: environmentPath || process.cwd(),
|
||||
const childProcess = spawn(command, args, {
|
||||
cwd: environmentPath || globalThis.process.cwd(),
|
||||
env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
@@ -119,26 +134,35 @@ export class MCPExecutor {
|
||||
// Timeout
|
||||
const timeout = setTimeout(() => {
|
||||
if (!responseReceived) {
|
||||
process.kill();
|
||||
childProcess.kill();
|
||||
reject(new Error(`Execution timeout after ${EXECUTION_TIMEOUT}ms`));
|
||||
}
|
||||
}, EXECUTION_TIMEOUT);
|
||||
|
||||
// Capturar stdout
|
||||
process.stdout?.on("data", (data) => {
|
||||
childProcess.stdout?.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
// Loga por linhas para diagnóstico
|
||||
const printedLines = data
|
||||
.toString()
|
||||
.split("\n")
|
||||
.filter((l: string) => l.trim().length > 0);
|
||||
for (const l of printedLines) {
|
||||
console.log(`📥 [MCP stdout] ${l}`);
|
||||
}
|
||||
|
||||
// Tentar parsear respostas JSON-RPC
|
||||
const lines = stdout.split("\n");
|
||||
for (const line of lines) {
|
||||
const parsedLines = stdout.split("\n");
|
||||
for (const line of parsedLines) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
console.log("🔎 [MCP stdout parsed]", parsed);
|
||||
if (parsed.id === 2 && parsed.result) {
|
||||
// Resposta do tools/call
|
||||
clearTimeout(timeout);
|
||||
responseReceived = true;
|
||||
process.kill();
|
||||
childProcess.kill();
|
||||
resolve(parsed.result);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -149,18 +173,25 @@ export class MCPExecutor {
|
||||
});
|
||||
|
||||
// Capturar stderr
|
||||
process.stderr?.on("data", (data) => {
|
||||
childProcess.stderr?.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
const errLines = data
|
||||
.toString()
|
||||
.split("\n")
|
||||
.filter((l: string) => l.trim().length > 0);
|
||||
for (const l of errLines) {
|
||||
console.warn(`⚠️ [MCP stderr] ${l}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Erro de processo
|
||||
process.on("error", (error) => {
|
||||
childProcess.on("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(`Process error: ${error.message}`));
|
||||
});
|
||||
|
||||
// Processo encerrou
|
||||
process.on("close", (code) => {
|
||||
childProcess.on("close", (code) => {
|
||||
clearTimeout(timeout);
|
||||
if (!responseReceived) {
|
||||
if (code !== 0) {
|
||||
@@ -186,7 +217,8 @@ export class MCPExecutor {
|
||||
},
|
||||
id: 1,
|
||||
};
|
||||
process.stdin?.write(JSON.stringify(initRequest) + "\n");
|
||||
console.log("📤 [MCP send] initialize", initRequest);
|
||||
childProcess.stdin?.write(JSON.stringify(initRequest) + "\n");
|
||||
|
||||
// Aguardar um pouco antes de enviar tool call
|
||||
setTimeout(() => {
|
||||
@@ -200,12 +232,13 @@ export class MCPExecutor {
|
||||
},
|
||||
id: 2,
|
||||
};
|
||||
process.stdin?.write(JSON.stringify(toolCallRequest) + "\n");
|
||||
process.stdin?.end();
|
||||
console.log("📤 [MCP send] tools/call", toolCallRequest);
|
||||
childProcess.stdin?.write(JSON.stringify(toolCallRequest) + "\n");
|
||||
childProcess.stdin?.end();
|
||||
}, 500);
|
||||
} catch (error: any) {
|
||||
clearTimeout(timeout);
|
||||
process.kill();
|
||||
childProcess.kill();
|
||||
reject(new Error(`Failed to send JSON-RPC request: ${error.message}`));
|
||||
}
|
||||
});
|
||||
@@ -274,6 +307,16 @@ export class MCPExecutor {
|
||||
|
||||
if (expectedType) {
|
||||
const actualType = typeof value;
|
||||
// Tratar objeto vazio como "ausente" quando tipo esperado não é object
|
||||
if (
|
||||
value &&
|
||||
actualType === "object" &&
|
||||
!Array.isArray(value) &&
|
||||
Object.keys(value).length === 0 &&
|
||||
expectedType !== "object"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const valid =
|
||||
(expectedType === "string" && actualType === "string") ||
|
||||
(expectedType === "number" && actualType === "number") ||
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
import { PulseMCPService } from "./pulsemcp";
|
||||
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 syncPromise: Promise<SyncResult> | null = null;
|
||||
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<SyncResult> {
|
||||
// Se já existe um sync em andamento, retornar a promise existente
|
||||
if (this.syncPromise) {
|
||||
console.log("⏳ Sync already in progress, waiting for completion...");
|
||||
return this.syncPromise;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const errors: string[] = [];
|
||||
let totalDownloaded = 0;
|
||||
|
||||
// Criar a promise de sync e armazená-la
|
||||
this.syncPromise = (async () => {
|
||||
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;
|
||||
this.syncPromise = null; // Limpar a promise compartilhada
|
||||
}
|
||||
})();
|
||||
|
||||
return this.syncPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sincronização em background (usado automaticamente)
|
||||
*/
|
||||
static async backgroundSync(): Promise<void> {
|
||||
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 completo de todos os servidores PulseMCP (uma única chamada)
|
||||
*/
|
||||
private static async downloadAllPages(
|
||||
onProgress?: (progress: SyncProgress) => void
|
||||
): Promise<any[]> {
|
||||
try {
|
||||
console.log("📡 Starting complete download from PulseMCP...");
|
||||
|
||||
// Notificar início
|
||||
this.currentProgress = {
|
||||
current: 0,
|
||||
total: 0,
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
if (onProgress) {
|
||||
onProgress(this.currentProgress);
|
||||
}
|
||||
|
||||
// Fazer uma única chamada para buscar todos os servidores
|
||||
const response = await PulseMCPService.getAllServers();
|
||||
|
||||
console.log(`📊 Total servers available: ${response.total_count}`);
|
||||
console.log(`📥 Downloaded ${response.servers.length} servers`);
|
||||
|
||||
// Atualizar progresso
|
||||
this.currentProgress = {
|
||||
current: response.servers.length,
|
||||
total: response.total_count,
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
if (onProgress) {
|
||||
onProgress(this.currentProgress);
|
||||
}
|
||||
|
||||
// Salvar em batches de 500 para evitar bloqueio
|
||||
const batchSize = 500;
|
||||
const totalBatches = Math.ceil(response.servers.length / batchSize);
|
||||
|
||||
for (let i = 0; i < totalBatches; i++) {
|
||||
const start = i * batchSize;
|
||||
const end = Math.min(start + batchSize, response.servers.length);
|
||||
const batch = response.servers.slice(start, end);
|
||||
|
||||
console.log(
|
||||
`💾 Saving batch ${i + 1}/${totalBatches} (${batch.length} items)...`
|
||||
);
|
||||
MCPCacheRepository.saveBatch(batch);
|
||||
|
||||
// Pequena pausa entre batches
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
console.log(
|
||||
`✅ Download completed: ${response.servers.length} total MCPs downloaded from PulseMCP`
|
||||
);
|
||||
return response.servers;
|
||||
} catch (error: any) {
|
||||
console.error(`❌ Error downloading from PulseMCP:`, error);
|
||||
|
||||
// Se for erro de abort, parar
|
||||
if (this.abortController?.signal.aborted) {
|
||||
console.log("🛑 Download cancelled");
|
||||
return [];
|
||||
}
|
||||
|
||||
// Para outros erros, lançar
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,462 +0,0 @@
|
||||
import {
|
||||
PulseMCPSearchParams,
|
||||
PulseMCPResponse,
|
||||
MCPServerMetadata,
|
||||
MCPProvider,
|
||||
} from "@/lib/types/mcp";
|
||||
import { MCPCache } from "@/lib/cache/mcp-cache";
|
||||
|
||||
const PULSEMCP_API_BASE = "https://api.pulsemcp.com/v0beta";
|
||||
|
||||
export class PulseMCPService {
|
||||
// Mapear categorias do PulseMCP para categorias internas
|
||||
private static categoryMap: Record<string, MCPProvider["category"]> = {
|
||||
filesystem: "other",
|
||||
github: "code",
|
||||
google: "search",
|
||||
brave: "search",
|
||||
postgres: "database",
|
||||
sqlite: "database",
|
||||
mongodb: "database",
|
||||
redis: "database",
|
||||
aws: "other",
|
||||
gcp: "other",
|
||||
azure: "other",
|
||||
slack: "other",
|
||||
discord: "other",
|
||||
notion: "office",
|
||||
confluence: "office",
|
||||
jira: "office",
|
||||
figma: "other",
|
||||
linear: "office",
|
||||
trello: "office",
|
||||
calendar: "office",
|
||||
email: "office",
|
||||
maps: "map",
|
||||
weather: "other",
|
||||
news: "other",
|
||||
finance: "finance",
|
||||
crypto: "finance",
|
||||
stocks: "finance",
|
||||
payment: "payment",
|
||||
stripe: "payment",
|
||||
paypal: "payment",
|
||||
chart: "chart",
|
||||
analytics: "chart",
|
||||
monitoring: "other",
|
||||
logging: "other",
|
||||
security: "other",
|
||||
auth: "other",
|
||||
api: "other",
|
||||
webhook: "other",
|
||||
scraping: "other",
|
||||
automation: "other",
|
||||
ai: "other",
|
||||
ml: "other",
|
||||
data: "other",
|
||||
etl: "other",
|
||||
visualization: "chart",
|
||||
reporting: "chart",
|
||||
};
|
||||
|
||||
/**
|
||||
* Buscar todos os servidores MCP na API PulseMCP (sem parâmetros)
|
||||
* Com retry automático e backoff exponencial para rate limiting
|
||||
*/
|
||||
static async getAllServers(retryCount = 0): Promise<PulseMCPResponse> {
|
||||
const maxRetries = 3;
|
||||
const baseDelay = 2000; // 2 segundos base
|
||||
|
||||
try {
|
||||
if (retryCount > 0) {
|
||||
console.log(
|
||||
`📡 Retry ${retryCount}/${maxRetries} - Fetching from PulseMCP API...`
|
||||
);
|
||||
} else {
|
||||
console.log("📡 Fetching all servers from PulseMCP API...");
|
||||
}
|
||||
|
||||
const response = await fetch(`${PULSEMCP_API_BASE}/servers`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"User-Agent": "Ollahub-MCP-Client/1.0 (https://ollahub.com)",
|
||||
},
|
||||
// Timeout de 30 segundos para download completo
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Se for 429 (Too Many Requests), tentar novamente com backoff
|
||||
if (response.status === 429 && retryCount < maxRetries) {
|
||||
const delay = baseDelay * Math.pow(2, retryCount); // Backoff exponencial
|
||||
console.warn(
|
||||
`⚠️ Rate limit hit (429). Waiting ${delay}ms before retry ${
|
||||
retryCount + 1
|
||||
}/${maxRetries}...`
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
return this.getAllServers(retryCount + 1);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`PulseMCP API error: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log(
|
||||
`✅ PulseMCP API returned ${data.servers.length} servers (total: ${data.total_count})`
|
||||
);
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
// Se for erro de rate limit e ainda temos retries, tentar novamente
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message.includes("429") &&
|
||||
retryCount < maxRetries
|
||||
) {
|
||||
const delay = baseDelay * Math.pow(2, retryCount);
|
||||
console.warn(
|
||||
`⚠️ Rate limit error. Waiting ${delay}ms before retry ${
|
||||
retryCount + 1
|
||||
}/${maxRetries}...`
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
return this.getAllServers(retryCount + 1);
|
||||
}
|
||||
|
||||
console.error("❌ Error calling PulseMCP API:", error);
|
||||
throw new Error(
|
||||
`Failed to fetch MCPs after ${retryCount + 1} attempts: ${
|
||||
error instanceof Error ? error.message : "Unknown error"
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transformar resposta PulseMCP para formato interno MCPProvider
|
||||
*/
|
||||
static transformToMCPProvider(pulseMCPItem: MCPServerMetadata): MCPProvider {
|
||||
const meta = pulseMCPItem._meta?.["com.pulsemcp"];
|
||||
const category = this.mapCategory(
|
||||
pulseMCPItem.name,
|
||||
pulseMCPItem.short_description
|
||||
);
|
||||
|
||||
// Usar name como ID se package_name não estiver disponível
|
||||
const id =
|
||||
pulseMCPItem.package_name ||
|
||||
pulseMCPItem.name.toLowerCase().replace(/[^a-z0-9]/g, "-");
|
||||
|
||||
return {
|
||||
id: id,
|
||||
name: meta?.standardized_name || pulseMCPItem.name,
|
||||
author: meta?.standardized_provider_name || "Unknown",
|
||||
description:
|
||||
meta?.standardized_description || pulseMCPItem.short_description,
|
||||
version: "1.0.0", // PulseMCP não fornece versão específica
|
||||
category,
|
||||
tags: this.extractTags(pulseMCPItem.name, pulseMCPItem.short_description),
|
||||
rating: this.calculateRating(pulseMCPItem),
|
||||
totalRatings: pulseMCPItem.github_stars || 0,
|
||||
repository: pulseMCPItem.source_code_url,
|
||||
homepage: pulseMCPItem.external_url || pulseMCPItem.source_code_url,
|
||||
installed: false, // Será verificado localmente
|
||||
subfield: category.toUpperCase(),
|
||||
field: "MCP SERVER",
|
||||
config: this.generateConfigTemplate(pulseMCPItem),
|
||||
tools: [], // PulseMCP não fornece schemas de ferramentas
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapear categoria baseada no nome e descrição
|
||||
*/
|
||||
private static mapCategory(
|
||||
name: string,
|
||||
description: string
|
||||
): MCPProvider["category"] {
|
||||
const text = `${name} ${description}`.toLowerCase();
|
||||
|
||||
// Buscar por palavras-chave específicas
|
||||
for (const [keyword, category] of Object.entries(this.categoryMap)) {
|
||||
if (text.includes(keyword)) {
|
||||
return category;
|
||||
}
|
||||
}
|
||||
|
||||
return "other";
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrair tags baseadas no nome e descrição
|
||||
*/
|
||||
private static extractTags(name: string, description: string): string[] {
|
||||
const text = `${name} ${description}`.toLowerCase();
|
||||
const tags: string[] = [];
|
||||
|
||||
// Tags comuns
|
||||
const commonTags = [
|
||||
"api",
|
||||
"database",
|
||||
"filesystem",
|
||||
"github",
|
||||
"google",
|
||||
"aws",
|
||||
"gcp",
|
||||
"azure",
|
||||
"slack",
|
||||
"discord",
|
||||
"notion",
|
||||
"calendar",
|
||||
"email",
|
||||
"maps",
|
||||
"weather",
|
||||
"finance",
|
||||
"crypto",
|
||||
"payment",
|
||||
"chart",
|
||||
"analytics",
|
||||
"monitoring",
|
||||
"security",
|
||||
"auth",
|
||||
"webhook",
|
||||
"scraping",
|
||||
"automation",
|
||||
"ai",
|
||||
"ml",
|
||||
];
|
||||
|
||||
for (const tag of commonTags) {
|
||||
if (text.includes(tag)) {
|
||||
tags.push(tag);
|
||||
}
|
||||
}
|
||||
|
||||
return tags.slice(0, 5); // Limitar a 5 tags
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcular rating baseado em métricas disponíveis
|
||||
*/
|
||||
private static calculateRating(item: MCPServerMetadata): number {
|
||||
const meta = item._meta?.["com.pulsemcp"];
|
||||
let rating = 3.0; // Rating base
|
||||
|
||||
// Ajustar baseado em downloads
|
||||
if (meta?.estimated_downloads_all_time) {
|
||||
const downloads = meta.estimated_downloads_all_time;
|
||||
if (downloads > 1000000) rating += 1.0;
|
||||
else if (downloads > 100000) rating += 0.5;
|
||||
else if (downloads > 10000) rating += 0.2;
|
||||
}
|
||||
|
||||
// Ajustar baseado em GitHub stars
|
||||
if (item.github_stars) {
|
||||
if (item.github_stars > 1000) rating += 0.5;
|
||||
else if (item.github_stars > 100) rating += 0.2;
|
||||
}
|
||||
|
||||
// Ajustar se é oficial
|
||||
if (meta?.is_official) {
|
||||
rating += 0.5;
|
||||
}
|
||||
|
||||
return Math.min(5.0, Math.max(1.0, rating));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrair capacidades baseadas no nome e descrição
|
||||
*/
|
||||
private static extractCapabilities(item: MCPServerMetadata): string[] {
|
||||
const text = `${item.name} ${item.short_description}`.toLowerCase();
|
||||
const capabilities: string[] = [];
|
||||
|
||||
// Capacidades comuns
|
||||
const capabilityMap = {
|
||||
read: ["read", "get", "fetch", "retrieve"],
|
||||
write: ["write", "create", "update", "modify", "edit"],
|
||||
delete: ["delete", "remove", "destroy"],
|
||||
search: ["search", "find", "query", "filter"],
|
||||
sync: ["sync", "synchronize", "backup"],
|
||||
monitor: ["monitor", "watch", "track", "observe"],
|
||||
authenticate: ["auth", "login", "authenticate"],
|
||||
notify: ["notify", "alert", "message", "send"],
|
||||
};
|
||||
|
||||
for (const [capability, keywords] of Object.entries(capabilityMap)) {
|
||||
if (keywords.some((keyword) => text.includes(keyword))) {
|
||||
capabilities.push(capability);
|
||||
}
|
||||
}
|
||||
|
||||
return capabilities.slice(0, 5); // Limitar a 5 capacidades
|
||||
}
|
||||
|
||||
/**
|
||||
* Gerar template de configuração para o servidor
|
||||
*/
|
||||
private static generateConfigTemplate(item: MCPServerMetadata): any[] {
|
||||
const config: any[] = [];
|
||||
|
||||
// Configuração básica
|
||||
config.push({
|
||||
type: "server",
|
||||
name: (item.package_name || item.name).replace(/^@[\w-]+\//, ""),
|
||||
command: this.getCommand(item.package_registry),
|
||||
args: this.getArgs(item),
|
||||
});
|
||||
|
||||
// Adicionar variáveis de ambiente se necessário
|
||||
const envVars = this.getEnvTemplate(item);
|
||||
if (Object.keys(envVars).length > 0) {
|
||||
config.push({
|
||||
type: "env",
|
||||
variables: envVars,
|
||||
});
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter comando baseado no registry
|
||||
*/
|
||||
private static getCommand(registry: string | null): string {
|
||||
switch (registry) {
|
||||
case "npm":
|
||||
return "npx";
|
||||
case "pypi":
|
||||
return "python";
|
||||
case "cargo":
|
||||
return "cargo";
|
||||
default:
|
||||
return "node";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter argumentos baseados no registry
|
||||
*/
|
||||
private static getArgs(item: MCPServerMetadata): string[] {
|
||||
switch (item.package_registry) {
|
||||
case "npm":
|
||||
return ["-y", item.package_name || item.name];
|
||||
case "pypi":
|
||||
return ["-m", item.package_name || item.name];
|
||||
case "cargo":
|
||||
return ["run", "--bin", item.package_name || item.name];
|
||||
default:
|
||||
return [item.package_name || item.name];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter template de variáveis de ambiente
|
||||
*/
|
||||
private static getEnvTemplate(
|
||||
item: MCPServerMetadata
|
||||
): Record<string, string> {
|
||||
const template: Record<string, string> = {};
|
||||
const text = `${item.name} ${item.short_description}`.toLowerCase();
|
||||
|
||||
// Templates comuns baseados em palavras-chave
|
||||
if (text.includes("google")) {
|
||||
template.GOOGLE_API_KEY = "your-google-api-key";
|
||||
}
|
||||
if (text.includes("github")) {
|
||||
template.GITHUB_TOKEN = "your-github-token";
|
||||
}
|
||||
if (text.includes("openai")) {
|
||||
template.OPENAI_API_KEY = "your-openai-api-key";
|
||||
}
|
||||
if (text.includes("aws")) {
|
||||
template.AWS_ACCESS_KEY_ID = "your-aws-access-key";
|
||||
template.AWS_SECRET_ACCESS_KEY = "your-aws-secret-key";
|
||||
}
|
||||
if (text.includes("slack")) {
|
||||
template.SLACK_BOT_TOKEN = "your-slack-bot-token";
|
||||
}
|
||||
if (text.includes("discord")) {
|
||||
template.DISCORD_TOKEN = "your-discord-token";
|
||||
}
|
||||
|
||||
return template;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verificar se a API está disponível
|
||||
*/
|
||||
static async checkHealth(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${PULSEMCP_API_BASE}/servers?count_per_page=1`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"User-Agent": "Ollahub-MCP-Client/1.0 (https://ollahub.com)",
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error("PulseMCP API health check failed:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter servidores populares
|
||||
*/
|
||||
static async getPopularServers(
|
||||
limit: number = 10
|
||||
): Promise<MCPServerMetadata[]> {
|
||||
try {
|
||||
const response = await this.getAllServers();
|
||||
|
||||
return response.servers
|
||||
.sort((a: MCPServerMetadata, b: MCPServerMetadata) => {
|
||||
const aDownloads =
|
||||
a._meta?.["com.pulsemcp"]?.estimated_downloads_all_time || 0;
|
||||
const bDownloads =
|
||||
b._meta?.["com.pulsemcp"]?.estimated_downloads_all_time || 0;
|
||||
return bDownloads - aDownloads;
|
||||
})
|
||||
.slice(0, limit);
|
||||
} catch (error) {
|
||||
console.error("Error getting popular servers:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Buscar servidores por integração específica
|
||||
*/
|
||||
static async getServersByIntegration(
|
||||
integration: string
|
||||
): Promise<MCPServerMetadata[]> {
|
||||
try {
|
||||
const response = await this.getAllServers();
|
||||
// Filtrar servidores que contenham a integração no nome ou descrição
|
||||
return response.servers.filter(
|
||||
(server) =>
|
||||
server.name.toLowerCase().includes(integration.toLowerCase()) ||
|
||||
server.short_description
|
||||
.toLowerCase()
|
||||
.includes(integration.toLowerCase())
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error getting servers for integration ${integration}:`,
|
||||
error
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-70
@@ -54,51 +54,7 @@ export interface MCPTool {
|
||||
};
|
||||
}
|
||||
|
||||
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" | "name" | "recent" | "total_ratings" | "updated_at";
|
||||
order?: "asc" | "desc";
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
// PulseMCP API Types
|
||||
export interface PulseMCPSearchParams {
|
||||
query?: string;
|
||||
integrations?: string[];
|
||||
count_per_page?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface PulseMCPResponse {
|
||||
servers: MCPServerMetadata[];
|
||||
next?: string;
|
||||
total_count: number;
|
||||
}
|
||||
// Tipos relacionados a busca/marketplace foram removidos
|
||||
|
||||
export interface MCPTestResult {
|
||||
success: boolean;
|
||||
@@ -113,28 +69,4 @@ export interface MCPTestRequest {
|
||||
mcpId: string;
|
||||
}
|
||||
|
||||
export interface MCPServerMetadata {
|
||||
name: string;
|
||||
url: string;
|
||||
external_url?: string;
|
||||
short_description: string;
|
||||
source_code_url: string;
|
||||
github_stars?: number;
|
||||
package_registry: "npm" | "pypi" | "cargo";
|
||||
package_name: string;
|
||||
package_download_count?: number;
|
||||
EXPERIMENTAL_ai_generated_description?: string;
|
||||
_meta: {
|
||||
"com.pulsemcp": {
|
||||
estimated_downloads_all_time: number;
|
||||
estimated_downloads_last_30_days: number;
|
||||
estimated_downloads_last_7_days: number;
|
||||
standardized_name: string;
|
||||
standardized_description: string;
|
||||
standardized_provider_name: string;
|
||||
standardized_provider_url: string;
|
||||
estimated_released_on: string;
|
||||
is_official: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
// Metadados específicos de PulseMCP removidos
|
||||
Reference in new issue
Block a user