- Add catalog metadata lookup for better error messages with hints (e.g., for missing manifests or tags) - Persist downloaded model references in saved remote models for future reuse - Replace searchRemoteModels import with getSavedRemoteModels in search route - Enhance pull progress UI to display download bytes, digest, and fallback message when data is unavailable - Improve error payload with model details, URL, and tags count to guide users toward valid tags or models
430 lines
13 KiB
TypeScript
430 lines
13 KiB
TypeScript
/**
|
|
* 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 npmBin = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
const result = await spawnWithProgress(
|
|
npmBin,
|
|
["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
|
|
// Para NPM, executar npx do diretório onde foi instalado (não usa --prefix que não funciona)
|
|
const npxBin = process.platform === "win32" ? "npx.cmd" : "npx";
|
|
const environment: MCPEnvironment = {
|
|
type: "npm",
|
|
path: npmDir,
|
|
executable: npxBin,
|
|
args: ["-y", packageName],
|
|
};
|
|
|
|
return environment;
|
|
} catch (error: any) {
|
|
const msg = String(error?.message || "");
|
|
if (msg.includes("spawn EINVAL") || msg.includes("Failed to spawn process") || msg.includes("ENOENT")) {
|
|
const npxBin = process.platform === "win32" ? "npx.cmd" : "npx";
|
|
onProgress({
|
|
status: "installing" as MCPInstallationStatus,
|
|
message: "Falling back to npx runtime execution (no preinstall)",
|
|
percentage: 60,
|
|
logs: config.enableLogs ? [msg] : undefined,
|
|
});
|
|
|
|
const environment: MCPEnvironment = {
|
|
type: "npm",
|
|
path: npmDir,
|
|
executable: npxBin,
|
|
args: ["-y", packageName],
|
|
};
|
|
return environment;
|
|
}
|
|
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}`);
|
|
}
|
|
}
|
|
}
|