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:
1 parent
1042f2136b
commit
f19bf10ef3
35 files changed
+4743
-429
No files matched your search
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}`
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user