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.
This commit is contained in:
Evandro Fonseca Junior committed 2025-10-28 21:59:00 -03:00
1 parent 1042f2136b
commit f19bf10ef3
35 files changed
+4743 -429

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
/**
* Configurações para instalação e gerenciamento de servidores MCP
*/
import path from "path";
export const MCP_CONFIG = {
// Diretório base onde os servidores MCP serão instalados
serversDir:
process.env.MCP_SERVERS_DIR || path.join(process.cwd(), "mcp-servers"),
// Timeout para instalação de pacotes (5 minutos)
installTimeout: parseInt(process.env.MCP_INSTALL_TIMEOUT || "300000", 10),
// Timeout para testes de servidor (10 segundos)
testTimeout: parseInt(process.env.MCP_TEST_TIMEOUT || "10000", 10),
// Habilitar logs detalhados (para debug/testes)
enableLogs: process.env.MCP_ENABLE_LOGS === "true",
// Diretórios por tipo de pacote
get npmDir() {
return path.join(this.serversDir, "npm");
},
get pythonDir() {
return path.join(this.serversDir, "python");
},
get rustDir() {
return path.join(this.serversDir, "rust");
},
};
+214
View File
@@ -0,0 +1,214 @@
/**
* Serviço de gerenciamento de ambientes isolados para servidores MCP
*/
import { mkdir, rm, access } from "fs/promises";
import path from "path";
import { MCP_CONFIG } from "@/lib/config/mcp";
import { commandExists, spawnWithProgress } from "@/lib/utils/system";
import type {
MCPEnvironment,
SystemDependencies,
} from "@/lib/types/mcp-installer";
export class MCPEnvironmentService {
/**
* Verifica dependências do sistema
*/
static async checkSystemDependencies(): Promise<SystemDependencies> {
const [node, npm, python, pip, cargo, git] = await Promise.all([
commandExists("node"),
commandExists("npm"),
commandExists("python3"),
commandExists("pip3"),
commandExists("cargo"),
commandExists("git"),
]);
return { node, npm, python, pip, cargo, git };
}
/**
* Garante que o diretório de ambiente existe
*/
static async ensureEnvironmentDirectory(
type: "npm" | "python" | "rust"
): Promise<string> {
let dir: string;
switch (type) {
case "npm":
dir = MCP_CONFIG.npmDir;
break;
case "python":
dir = MCP_CONFIG.pythonDir;
break;
case "rust":
dir = MCP_CONFIG.rustDir;
break;
}
try {
await mkdir(dir, { recursive: true });
console.log(`✓ Environment directory ensured: ${dir}`);
return dir;
} catch (error: any) {
throw new Error(
`Failed to create environment directory: ${error.message}`
);
}
}
/**
* Cria ambiente virtual Python para um MCP específico
*/
static async createPythonVenv(
mcpId: string,
onProgress?: (message: string) => void
): Promise<string> {
const venvPath = path.join(MCP_CONFIG.pythonDir, mcpId);
// Verificar se já existe
try {
await access(venvPath);
onProgress?.(`Virtual environment already exists at ${venvPath}`);
return venvPath;
} catch {
// Não existe, criar novo
}
onProgress?.("Creating Python virtual environment...");
try {
const result = await spawnWithProgress(
"python3",
["-m", "venv", venvPath],
{
onOutput: (data) => onProgress?.(data.trim()),
timeout: 60000, // 1 minuto para criar venv
}
);
if (result.exitCode !== 0) {
throw new Error(
`Failed to create venv: ${result.stderr || result.stdout}`
);
}
onProgress?.(`✓ Virtual environment created at ${venvPath}`);
return venvPath;
} catch (error: any) {
throw new Error(`Failed to create Python venv: ${error.message}`);
}
}
/**
* Obtém caminho do executável para um ambiente
*/
static getExecutablePath(
type: "npm" | "python" | "rust",
mcpId: string,
packageName?: string
): { executable: string; args: string[] } {
switch (type) {
case "npm":
// Para npm, usar npx para executar pacotes instalados localmente
return {
executable: "npx",
args: packageName ? ["--prefix", MCP_CONFIG.npmDir, packageName] : [],
};
case "python": {
const venvPath = path.join(MCP_CONFIG.pythonDir, mcpId);
const pythonBin =
process.platform === "win32"
? path.join(venvPath, "Scripts", "python.exe")
: path.join(venvPath, "bin", "python");
return {
executable: pythonBin,
args: packageName ? ["-m", packageName] : [],
};
}
case "rust": {
// Para Rust, o binário estará em target/release/
const binaryPath = path.join(
MCP_CONFIG.rustDir,
mcpId,
"target",
"release",
packageName || mcpId
);
return {
executable: binaryPath,
args: [],
};
}
}
}
/**
* Limpa ambiente de um MCP (desinstalação)
*/
static async cleanupEnvironment(environment: MCPEnvironment): Promise<void> {
try {
console.log(`Cleaning up environment at ${environment.path}`);
await rm(environment.path, { recursive: true, force: true });
console.log(`✓ Environment cleaned: ${environment.path}`);
} catch (error: any) {
console.error(`Warning: Failed to cleanup environment: ${error.message}`);
// Não lançar erro - cleanup é best-effort
}
}
/**
* Verifica se um ambiente está válido
*/
static async validateEnvironment(
environment: MCPEnvironment
): Promise<boolean> {
try {
// Verificar se o diretório existe
await access(environment.path);
// Verificar se o executável existe
await access(environment.executable);
return true;
} catch {
return false;
}
}
/**
* Obtém informações sobre o ambiente de um MCP
*/
static async getEnvironmentInfo(
mcpId: string,
type: "npm" | "python" | "rust"
): Promise<{
exists: boolean;
path: string;
size?: number;
}> {
const envPath = path.join(
type === "npm"
? MCP_CONFIG.npmDir
: type === "python"
? path.join(MCP_CONFIG.pythonDir, mcpId)
: path.join(MCP_CONFIG.rustDir, mcpId)
);
try {
await access(envPath);
// TODO: Calcular tamanho do diretório se necessário
return { exists: true, path: envPath };
} catch {
return { exists: false, path: envPath };
}
}
}
+408
View File
@@ -0,0 +1,408 @@
/**
* Serviço de instalação de servidores MCP
*/
import path from "path";
import { MCP_CONFIG } from "@/lib/config/mcp";
import { MCPEnvironmentService } from "./mcp-environment";
import { sanitizePackageName, spawnWithProgress } from "@/lib/utils/system";
import type {
MCPEnvironment,
InstallationProgress,
MCPInstallationStatus,
InstallationConfig,
} from "@/lib/types/mcp-installer";
export class MCPInstallerService {
/**
* Instala um servidor MCP baseado na configuração
*/
static async install(
config: InstallationConfig,
onProgress: (progress: InstallationProgress) => void
): Promise<MCPEnvironment> {
// Verificar dependências do sistema
onProgress({
status: "checking_dependencies" as MCPInstallationStatus,
message: "Checking system dependencies...",
percentage: 5,
});
const dependencies = await MCPEnvironmentService.checkSystemDependencies();
// Determinar tipo de instalação
const installType = this.determineInstallType(config, dependencies);
onProgress({
status: "downloading" as MCPInstallationStatus,
message: `Preparing ${installType} installation...`,
percentage: 10,
});
// Delegar para instalador específico
switch (installType) {
case "npm":
return await this.installNPM(config, onProgress, dependencies);
case "python":
return await this.installPython(config, onProgress, dependencies);
case "rust":
return await this.installRust(config, onProgress, dependencies);
default:
throw new Error(`Unsupported installation type: ${installType}`);
}
}
/**
* Determina o tipo de instalação baseado na configuração
*/
private static determineInstallType(
config: InstallationConfig,
dependencies: any
): "npm" | "python" | "rust" {
// Preferir package_registry se disponível
if (config.packageRegistry === "npm" && dependencies.npm) {
return "npm";
}
if (config.packageRegistry === "pypi" && dependencies.python) {
return "python";
}
if (config.packageRegistry === "cargo" && dependencies.cargo) {
return "rust";
}
// Tentar detectar pelo nome do pacote
if (config.packageName) {
if (
config.packageName.startsWith("@") ||
config.packageName.includes("/")
) {
if (dependencies.npm) return "npm";
}
if (
config.packageName.includes("mcp-server") ||
config.packageName.includes("mcp_server")
) {
if (dependencies.python) return "python";
}
}
// Fallback para npm se disponível
if (dependencies.npm) return "npm";
if (dependencies.python) return "python";
if (dependencies.cargo) return "rust";
throw new Error("No suitable package manager found");
}
/**
* Instala pacote NPM
*/
private static async installNPM(
config: InstallationConfig,
onProgress: (progress: InstallationProgress) => void,
dependencies: any
): Promise<MCPEnvironment> {
if (!dependencies.npm) {
throw new Error("npm is not installed on this system");
}
// Sanitizar nome do pacote
const packageName = sanitizePackageName(config.packageName);
onProgress({
status: "installing" as MCPInstallationStatus,
message: `Installing npm package: ${packageName}`,
percentage: 20,
logs: config.enableLogs
? [`Installing ${packageName} via npm...`]
: undefined,
});
// Garantir diretório npm existe
const npmDir = await MCPEnvironmentService.ensureEnvironmentDirectory(
"npm"
);
// Instalar pacote localmente no diretório npm
const logs: string[] = [];
try {
const result = await spawnWithProgress(
"npm",
["install", packageName, "--prefix", npmDir],
{
cwd: npmDir,
timeout: MCP_CONFIG.installTimeout,
onOutput: (data, isError) => {
if (config.enableLogs) {
logs.push(data);
}
// Atualizar progresso baseado na saída
let percentage = 40;
if (data.includes("fetchMetadata")) percentage = 30;
if (data.includes("extract")) percentage = 50;
if (data.includes("finalize")) percentage = 70;
onProgress({
status: "installing" as MCPInstallationStatus,
message: isError
? `npm: ${data.substring(0, 100)}`
: `Installing: ${data.substring(0, 100)}`,
percentage,
logs: config.enableLogs ? [data] : undefined,
});
},
}
);
if (result.exitCode !== 0) {
throw new Error(
`npm install failed: ${result.stderr || result.stdout}`
);
}
onProgress({
status: "installing" as MCPInstallationStatus,
message: "npm package installed successfully",
percentage: 80,
logs: config.enableLogs ? ["✓ Installation completed"] : undefined,
});
// Construir ambiente
const environment: MCPEnvironment = {
type: "npm",
path: npmDir,
executable: "npx",
args: ["--prefix", npmDir, packageName],
};
return environment;
} catch (error: any) {
throw new Error(`NPM installation failed: ${error.message}`);
}
}
/**
* Instala pacote Python
*/
private static async installPython(
config: InstallationConfig,
onProgress: (progress: InstallationProgress) => void,
dependencies: any
): Promise<MCPEnvironment> {
if (!dependencies.python || !dependencies.pip) {
throw new Error("Python3 and pip3 are not installed on this system");
}
const packageName = sanitizePackageName(config.packageName);
onProgress({
status: "installing" as MCPInstallationStatus,
message: "Creating Python virtual environment...",
percentage: 20,
logs: config.enableLogs ? ["Creating venv..."] : undefined,
});
// Garantir diretório python existe
await MCPEnvironmentService.ensureEnvironmentDirectory("python");
// Criar venv para este MCP
const venvPath = await MCPEnvironmentService.createPythonVenv(
config.mcpId,
(msg) => {
if (config.enableLogs) {
onProgress({
status: "installing" as MCPInstallationStatus,
message: msg,
percentage: 30,
logs: [msg],
});
}
}
);
onProgress({
status: "installing" as MCPInstallationStatus,
message: `Installing Python package: ${packageName}`,
percentage: 40,
logs: config.enableLogs
? [`Installing ${packageName} via pip...`]
: undefined,
});
// Caminho para pip no venv
const pipBin =
process.platform === "win32"
? path.join(venvPath, "Scripts", "pip.exe")
: path.join(venvPath, "bin", "pip");
const pythonBin =
process.platform === "win32"
? path.join(venvPath, "Scripts", "python.exe")
: path.join(venvPath, "bin", "python");
try {
const result = await spawnWithProgress(pipBin, ["install", packageName], {
cwd: venvPath,
timeout: MCP_CONFIG.installTimeout,
onOutput: (data, isError) => {
if (config.enableLogs) {
onProgress({
status: "installing" as MCPInstallationStatus,
message: `pip: ${data.substring(0, 100)}`,
percentage: 60,
logs: [data],
});
}
},
});
if (result.exitCode !== 0) {
throw new Error(
`pip install failed: ${result.stderr || result.stdout}`
);
}
onProgress({
status: "installing" as MCPInstallationStatus,
message: "Python package installed successfully",
percentage: 80,
logs: config.enableLogs ? ["✓ Installation completed"] : undefined,
});
// Extrair módulo Python do nome do pacote (ex: mcp-server-git -> mcp_server_git)
const moduleName = packageName.replace(/-/g, "_");
const environment: MCPEnvironment = {
type: "python",
path: venvPath,
executable: pythonBin,
args: ["-m", moduleName],
};
return environment;
} catch (error: any) {
throw new Error(`Python installation failed: ${error.message}`);
}
}
/**
* Instala servidor Rust (via git clone + cargo build)
*/
private static async installRust(
config: InstallationConfig,
onProgress: (progress: InstallationProgress) => void,
dependencies: any
): Promise<MCPEnvironment> {
if (!dependencies.cargo || !dependencies.git) {
throw new Error("Cargo and Git are not installed on this system");
}
if (!config.repositoryUrl) {
throw new Error("Repository URL is required for Rust installation");
}
onProgress({
status: "downloading" as MCPInstallationStatus,
message: "Cloning Rust repository...",
percentage: 20,
logs: config.enableLogs
? [`Cloning ${config.repositoryUrl}...`]
: undefined,
});
// Garantir diretório rust existe
await MCPEnvironmentService.ensureEnvironmentDirectory("rust");
const projectPath = path.join(MCP_CONFIG.rustDir, config.mcpId);
try {
// Clonar repositório
const cloneResult = await spawnWithProgress(
"git",
["clone", config.repositoryUrl, projectPath],
{
cwd: MCP_CONFIG.rustDir,
timeout: MCP_CONFIG.installTimeout,
onOutput: (data) => {
if (config.enableLogs) {
onProgress({
status: "downloading" as MCPInstallationStatus,
message: `git: ${data.substring(0, 100)}`,
percentage: 30,
logs: [data],
});
}
},
}
);
if (cloneResult.exitCode !== 0) {
throw new Error(`git clone failed: ${cloneResult.stderr}`);
}
onProgress({
status: "installing" as MCPInstallationStatus,
message: "Building Rust project...",
percentage: 40,
logs: config.enableLogs ? ["Building with cargo..."] : undefined,
});
// Compilar com cargo
const buildResult = await spawnWithProgress(
"cargo",
["build", "--release"],
{
cwd: projectPath,
timeout: MCP_CONFIG.installTimeout,
onOutput: (data, isError) => {
if (config.enableLogs) {
let percentage = 50;
if (data.includes("Compiling")) percentage = 60;
if (data.includes("Finished")) percentage = 80;
onProgress({
status: "installing" as MCPInstallationStatus,
message: `cargo: ${data.substring(0, 100)}`,
percentage,
logs: [data],
});
}
},
}
);
if (buildResult.exitCode !== 0) {
throw new Error(`cargo build failed: ${buildResult.stderr}`);
}
onProgress({
status: "installing" as MCPInstallationStatus,
message: "Rust project built successfully",
percentage: 80,
logs: config.enableLogs ? ["✓ Build completed"] : undefined,
});
// Determinar nome do binário
const binaryName = config.packageName || config.mcpId;
const binaryPath = path.join(
projectPath,
"target",
"release",
process.platform === "win32" ? `${binaryName}.exe` : binaryName
);
const environment: MCPEnvironment = {
type: "rust",
path: projectPath,
executable: binaryPath,
args: [],
};
return environment;
} catch (error: any) {
throw new Error(`Rust installation failed: ${error.message}`);
}
}
}
+64 -51
View File
@@ -17,6 +17,7 @@ export interface SyncResult {
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;
@@ -26,63 +27,75 @@ export class MCPSyncService {
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;
try {
if (this.isSyncingFlag) {
throw new Error("Sync already in progress");
// 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
}
})();
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;
}
return this.syncPromise;
}
/**
+285
View File
@@ -0,0 +1,285 @@
/**
* Serviço de validação de protocolo MCP (JSON-RPC)
*/
import { spawn } from "child_process";
import type {
MCPEnvironment,
ValidationResult,
} from "@/lib/types/mcp-installer";
import { MCP_CONFIG } from "@/lib/config/mcp";
export class MCPValidatorService {
/**
* Valida se um servidor MCP está funcionando corretamente
* Testa o protocolo JSON-RPC do MCP
*/
static async validateMCPServer(
environment: MCPEnvironment,
config: any,
timeoutMs: number = MCP_CONFIG.testTimeout
): Promise<ValidationResult> {
return new Promise((resolve) => {
const startTime = Date.now();
let hasResolved = false;
// Preparar comando e argumentos
const command = environment.executable;
const args = environment.args || [];
const env = {
...process.env,
...environment.env,
...(config.env || {}),
};
console.log(`🧪 Testing MCP server: ${command} ${args.join(" ")}`);
try {
// Spawnar processo do servidor MCP
const serverProcess = spawn(command, args, {
env,
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
const responses: any[] = [];
let currentBuffer = "";
// Timeout
const timeoutId = setTimeout(() => {
if (!hasResolved) {
hasResolved = true;
serverProcess.kill();
resolve({
success: false,
protocol: "unknown",
tools: [],
error: `Timeout after ${timeoutMs}ms. Server did not respond.`,
});
}
}, timeoutMs);
// Capturar stdout (respostas JSON-RPC)
serverProcess.stdout?.on("data", (data: Buffer) => {
const text = data.toString();
stdout += text;
currentBuffer += text;
// Processar respostas JSON-RPC linha por linha
const lines = currentBuffer.split("\n");
currentBuffer = lines.pop() || "";
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith("{")) {
try {
const response = JSON.parse(trimmed);
responses.push(response);
console.log(
"📨 MCP Response:",
JSON.stringify(response).substring(0, 200)
);
// Verificar se recebemos resposta válida de initialize
if (
response.id === 1 &&
response.result &&
response.result.capabilities
) {
// Protocolo MCP detectado, agora listar ferramentas
this.sendRequest(serverProcess, {
jsonrpc: "2.0",
method: "tools/list",
id: 2,
});
}
// Verificar se recebemos lista de ferramentas
if (response.id === 2 && response.result) {
clearTimeout(timeoutId);
if (!hasResolved) {
hasResolved = true;
serverProcess.kill();
const tools = Array.isArray(response.result.tools)
? response.result.tools.map((tool: any) => ({
name: tool.name || "unnamed",
description: tool.description,
inputSchema: tool.inputSchema,
}))
: [];
resolve({
success: true,
protocol: "mcp",
tools,
capabilities: responses[0]?.result?.capabilities,
});
}
}
} catch (e) {
// Não é JSON válido, ignorar
}
}
}
});
// Capturar stderr
serverProcess.stderr?.on("data", (data: Buffer) => {
stderr += data.toString();
});
// Error handler
serverProcess.on("error", (error) => {
clearTimeout(timeoutId);
if (!hasResolved) {
hasResolved = true;
resolve({
success: false,
protocol: "unknown",
tools: [],
error: `Failed to spawn server: ${error.message}`,
});
}
});
// Exit handler
serverProcess.on("exit", (code) => {
clearTimeout(timeoutId);
if (!hasResolved) {
hasResolved = true;
// Se saiu muito rápido sem responder, é um erro
const elapsed = Date.now() - startTime;
if (elapsed < 1000) {
resolve({
success: false,
protocol: "unknown",
tools: [],
error: `Server exited quickly with code ${code}. stderr: ${stderr}`,
});
} else {
// Servidor pode ter processado e saído normalmente
resolve({
success: false,
protocol: "unknown",
tools: [],
error: `Server exited with code ${code} before completing validation`,
});
}
}
});
// Aguardar servidor estar pronto (pequeno delay)
setTimeout(() => {
if (!hasResolved) {
// Enviar requisição de initialize
this.sendRequest(serverProcess, {
jsonrpc: "2.0",
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: {
name: "ollahub",
version: "1.0.0",
},
},
id: 1,
});
}
}, 500);
} catch (error: any) {
resolve({
success: false,
protocol: "unknown",
tools: [],
error: `Validation error: ${error.message}`,
});
}
});
}
/**
* Envia requisição JSON-RPC para o servidor
*/
private static sendRequest(process: any, request: any): void {
try {
const requestStr = JSON.stringify(request) + "\n";
console.log("📤 Sending MCP request:", requestStr.trim());
process.stdin?.write(requestStr);
} catch (error: any) {
console.error("Failed to send request:", error.message);
}
}
/**
* Validação simples de servidor (apenas verifica se inicia)
* Usado como fallback se validação JSON-RPC falhar
*/
static async simpleValidation(
environment: MCPEnvironment,
config: any,
timeoutMs: number = 5000
): Promise<{ success: boolean; error?: string }> {
return new Promise((resolve) => {
const command = environment.executable;
const args = environment.args || [];
const env = {
...process.env,
...environment.env,
...(config.env || {}),
};
try {
const serverProcess = spawn(command, args, {
env,
stdio: ["pipe", "pipe", "pipe"],
});
let hasResolved = false;
const timeoutId = setTimeout(() => {
if (!hasResolved) {
hasResolved = true;
serverProcess.kill();
// Se chegou ao timeout sem errar, provavelmente está rodando
resolve({ success: true });
}
}, timeoutMs);
serverProcess.on("error", (error) => {
clearTimeout(timeoutId);
if (!hasResolved) {
hasResolved = true;
resolve({
success: false,
error: `Failed to start: ${error.message}`,
});
}
});
serverProcess.on("exit", (code) => {
clearTimeout(timeoutId);
if (!hasResolved) {
hasResolved = true;
if (code === 0) {
resolve({ success: true });
} else {
resolve({
success: false,
error: `Exited with code ${code}`,
});
}
}
});
} catch (error: any) {
resolve({
success: false,
error: `Validation error: ${error.message}`,
});
}
});
}
}
+43 -3
View File
@@ -61,10 +61,20 @@ export class PulseMCPService {
/**
* Buscar todos os servidores MCP na API PulseMCP (sem parâmetros)
* Com retry automático e backoff exponencial para rate limiting
*/
static async getAllServers(): Promise<PulseMCPResponse> {
static async getAllServers(retryCount = 0): Promise<PulseMCPResponse> {
const maxRetries = 3;
const baseDelay = 2000; // 2 segundos base
try {
console.log("📡 Fetching all servers from PulseMCP API...");
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",
@@ -77,6 +87,19 @@ export class PulseMCPService {
});
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}`
);
@@ -89,9 +112,26 @@ export class PulseMCPService {
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: ${
`Failed to fetch MCPs after ${retryCount + 1} attempts: ${
error instanceof Error ? error.message : "Unknown error"
}`
);
+65
View File
@@ -0,0 +1,65 @@
/**
* Tipos e interfaces para o sistema de instalação MCP
*/
export type PackageRegistry = "npm" | "pypi" | "cargo" | "other" | null;
export enum MCPInstallationStatus {
PENDING = "pending",
CHECKING_DEPENDENCIES = "checking_dependencies",
DOWNLOADING = "downloading",
INSTALLING = "installing",
TESTING = "testing",
READY = "ready",
FAILED = "failed",
}
export interface InstallationProgress {
status: MCPInstallationStatus;
message: string;
percentage: number;
logs?: string[];
}
export interface MCPEnvironment {
type: "npm" | "python" | "rust";
path: string;
executable: string;
args?: string[];
env?: Record<string, string>;
}
export interface ValidationResult {
success: boolean;
protocol: "mcp" | "unknown";
tools: Array<{
name: string;
description?: string;
inputSchema?: any;
}>;
resources?: Array<{
uri: string;
name: string;
description?: string;
}>;
error?: string;
capabilities?: any;
}
export interface SystemDependencies {
node: boolean;
npm: boolean;
python: boolean;
pip: boolean;
cargo: boolean;
git: boolean;
}
export interface InstallationConfig {
mcpId: string;
packageName: string;
packageRegistry: PackageRegistry;
repositoryUrl?: string;
config: any;
enableLogs: boolean;
}
+16
View File
@@ -1,5 +1,8 @@
export interface MCPProvider {
id: string; // formato: "owner/repo"
originalId?: string; // ID original do cache antes de transformar para owner/repo
owner?: string; // Owner do repositório
repo?: string; // Nome do repositório
name: string; // mapeado de content_name
author: string; // mapeado de publisher_id
description: string;
@@ -97,6 +100,19 @@ export interface PulseMCPResponse {
total_count: number;
}
export interface MCPTestResult {
success: boolean;
message: string;
responseTime?: number; // em ms
serverVersion?: string;
availableTools?: number;
error?: string;
}
export interface MCPTestRequest {
mcpId: string;
}
export interface MCPServerMetadata {
name: string;
url: string;
+155
View File
@@ -0,0 +1,155 @@
/**
* Utilitários para operações de sistema
*/
import { exec, spawn } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
/**
* Verifica se um comando existe no sistema
*/
export async function commandExists(command: string): Promise<boolean> {
try {
const isWindows = process.platform === "win32";
const checkCommand = isWindows ? "where" : "which";
await execAsync(`${checkCommand} ${command}`);
return true;
} catch {
return false;
}
}
/**
* Executa um comando e retorna stdout/stderr
*/
export async function execCommand(
cmd: string,
args: string[],
options?: {
cwd?: string;
env?: Record<string, string>;
timeout?: number;
}
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
try {
const fullCommand = `${cmd} ${args.join(" ")}`;
const { stdout, stderr } = await execAsync(fullCommand, {
cwd: options?.cwd,
env: options?.env ? { ...process.env, ...options.env } : process.env,
timeout: options?.timeout || 300000, // 5 minutos default
});
return { stdout, stderr, exitCode: 0 };
} catch (error: any) {
return {
stdout: error.stdout || "",
stderr: error.stderr || error.message,
exitCode: error.code || 1,
};
}
}
/**
* Spawna um processo e captura output com progresso
*/
export async function spawnWithProgress(
cmd: string,
args: string[],
options: {
cwd?: string;
env?: Record<string, string>;
onOutput?: (data: string, isError: boolean) => void;
timeout?: number;
}
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
let stdout = "";
let stderr = "";
let hasTimedOut = false;
const childProcess = spawn(cmd, args, {
cwd: options.cwd,
env: options.env ? { ...process.env, ...options.env } : process.env,
stdio: ["pipe", "pipe", "pipe"],
});
// Timeout handler
const timeout = options.timeout || 300000; // 5 minutos default
const timeoutId = setTimeout(() => {
hasTimedOut = true;
childProcess.kill("SIGTERM");
// Force kill após 5 segundos
setTimeout(() => {
if (!childProcess.killed) {
childProcess.kill("SIGKILL");
}
}, 5000);
}, timeout);
// Capturar stdout
childProcess.stdout?.on("data", (data: Buffer) => {
const text = data.toString();
stdout += text;
if (options.onOutput) {
options.onOutput(text, false);
}
});
// Capturar stderr
childProcess.stderr?.on("data", (data: Buffer) => {
const text = data.toString();
stderr += text;
if (options.onOutput) {
options.onOutput(text, true);
}
});
// Error handler
childProcess.on("error", (error) => {
clearTimeout(timeoutId);
reject(new Error(`Failed to spawn process: ${error.message}`));
});
// Exit handler
childProcess.on("close", (code) => {
clearTimeout(timeoutId);
if (hasTimedOut) {
reject(
new Error(
`Process timed out after ${timeout}ms. stdout: ${stdout}, stderr: ${stderr}`
)
);
} else {
resolve({
exitCode: code || 0,
stdout,
stderr,
});
}
});
});
}
/**
* Sanitiza nome de pacote para evitar command injection
*/
export function sanitizePackageName(packageName: string): string {
// Remove caracteres perigosos, mantém apenas alphanumeric, @, /, -, _
return packageName.replace(/[^a-zA-Z0-9@/\-_.]/g, "");
}
/**
* Valida se um caminho está dentro de um diretório base (previne path traversal)
*/
export function isPathSafe(basePath: string, targetPath: string): boolean {
const path = require("path");
const resolvedBase = path.resolve(basePath);
const resolvedTarget = path.resolve(targetPath);
return resolvedTarget.startsWith(resolvedBase);
}