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

+241 -154
View File
@@ -1,5 +1,6 @@
"use client";
import { memo } from "react";
import {
Package,
Star,
@@ -7,6 +8,9 @@ import {
ExternalLink,
CheckCircle,
Zap,
Settings,
PlayCircle,
Loader2,
} from "lucide-react";
import { MCPProvider } from "@/lib/types/mcp";
@@ -15,171 +19,254 @@ interface MCPCardProps {
onInstall: () => void;
onUninstall: () => void;
onViewDetails: () => void;
mode?: "marketplace" | "installed";
onEditConfig?: () => void;
onTestServer?: () => void;
isTesting?: boolean;
installingStatus?: {
status: string;
message: string;
percentage: number;
};
}
export function MCPCard({
mcp,
onInstall,
onUninstall,
onViewDetails,
}: MCPCardProps) {
const getCategoryColor = (category: string) => {
switch (category) {
case "map":
return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200";
case "browser":
return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200";
case "office":
return "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200";
case "search":
return "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200";
case "database":
return "bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200";
case "finance":
return "bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200";
case "code":
return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200";
case "chart":
return "bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-200";
case "payment":
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200";
default:
return "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200";
}
};
export const MCPCard = memo(
function MCPCard({
mcp,
onInstall,
onUninstall,
onViewDetails,
mode = "marketplace",
onEditConfig,
onTestServer,
isTesting = false,
installingStatus,
}: MCPCardProps) {
const getCategoryColor = (category: string) => {
switch (category) {
case "map":
return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200";
case "browser":
return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200";
case "office":
return "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200";
case "search":
return "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200";
case "database":
return "bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200";
case "finance":
return "bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200";
case "code":
return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200";
case "chart":
return "bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-200";
case "payment":
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200";
default:
return "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200";
}
};
const formatDownloads = (downloads: number) => {
if (downloads >= 1000000) return `${(downloads / 1000000).toFixed(1)}M`;
if (downloads >= 1000) return `${(downloads / 1000).toFixed(1)}K`;
return downloads.toString();
};
const formatDownloads = (downloads: number) => {
if (downloads >= 1000000) return `${(downloads / 1000000).toFixed(1)}M`;
if (downloads >= 1000) return `${(downloads / 1000).toFixed(1)}K`;
return downloads.toString();
};
const isOfficial = mcp.tags?.includes("official") || false;
const toolCount = mcp.tools?.length || 0;
const isOfficial = mcp.tags?.includes("official") || false;
const toolCount = mcp.tools?.length || 0;
return (
<div className="bg-[var(--surface)] border border-[var(--border)] rounded-lg p-4 hover:shadow-md transition-all duration-200 hover:border-[var(--primary)]/50 mcp-card">
{/* Header */}
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<div className="p-2 bg-[var(--primary)]/10 rounded-md">
<Package className="h-4 w-4 text-[var(--primary)]" />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-sm">{mcp.name}</h3>
{isOfficial && <CheckCircle className="h-3 w-3 text-green-500" />}
return (
<div className="bg-[var(--surface)] border border-[var(--border)] rounded-lg p-4 hover:shadow-md transition-all duration-200 hover:border-[var(--primary)]/50 mcp-card">
{/* Header */}
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<div className="p-2 bg-[var(--primary)]/10 rounded-md">
<Package className="h-4 w-4 text-[var(--primary)]" />
</div>
<p className="text-xs text-[var(--muted-foreground)]">
by {mcp.author}
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-sm">{mcp.name}</h3>
{isOfficial && (
<CheckCircle className="h-3 w-3 text-green-500" />
)}
</div>
<p className="text-xs text-[var(--muted-foreground)]">
by {mcp.author}
</p>
</div>
</div>
<span
className={`px-2 py-1 rounded-full text-xs font-medium mcp-category-badge ${getCategoryColor(
mcp.category
)}`}
>
{mcp.category}
</span>
</div>
{/* Description */}
<p className="text-sm text-[var(--muted-foreground)] mb-3 line-clamp-2">
{mcp.description}
</p>
{/* Stats */}
<div className="flex items-center gap-4 mb-3 text-xs text-[var(--muted-foreground)]">
<div className="flex items-center gap-1">
<Star className="h-3 w-3 fill-yellow-400 text-yellow-400" />
<span>{mcp.rating}</span>
<span>({mcp.totalRatings})</span>
</div>
{toolCount > 0 && (
<div className="flex items-center gap-1">
<Zap className="h-3 w-3" />
<span>{toolCount} tools</span>
</div>
)}
<span className="text-xs bg-[var(--surface)] px-2 py-1 rounded">
v{mcp.version}
</span>
</div>
{/* Tags */}
<div className="flex flex-wrap gap-1 mb-4">
{mcp.tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="px-2 py-1 bg-[var(--background)] text-xs rounded-md border border-[var(--border)] mcp-tag"
>
{tag}
</span>
))}
{mcp.tags.length > 3 && (
<span className="px-2 py-1 bg-[var(--background)] text-xs rounded-md border border-[var(--border)] mcp-tag">
+{mcp.tags.length - 3}
</span>
)}
</div>
{/* Installation Progress */}
{installingStatus && (
<div className="mb-3 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md">
<div className="flex items-center gap-2 mb-2">
<Loader2 className="h-4 w-4 animate-spin text-blue-600 dark:text-blue-400" />
<span className="text-sm font-medium text-blue-900 dark:text-blue-100">
Installing...
</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 mb-2">
<div
className="bg-blue-600 dark:bg-blue-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${installingStatus.percentage}%` }}
/>
</div>
<p className="text-xs text-blue-700 dark:text-blue-300">
{installingStatus.message}
</p>
</div>
</div>
<span
className={`px-2 py-1 rounded-full text-xs font-medium mcp-category-badge ${getCategoryColor(
mcp.category
)}`}
>
{mcp.category}
</span>
</div>
)}
{/* Description */}
<p className="text-sm text-[var(--muted-foreground)] mb-3 line-clamp-2">
{mcp.description}
</p>
{/* Actions */}
<div className="flex gap-2">
<button
onClick={onViewDetails}
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-md hover:bg-[var(--surface)] transition-colors text-sm"
>
<Eye className="h-4 w-4" />
Details
</button>
{/* Stats */}
<div className="flex items-center gap-4 mb-3 text-xs text-[var(--muted-foreground)]">
<div className="flex items-center gap-1">
<Star className="h-3 w-3 fill-yellow-400 text-yellow-400" />
<span>{mcp.rating}</span>
<span>({mcp.totalRatings})</span>
{mode === "installed" && onEditConfig ? (
<>
<button
onClick={onEditConfig}
className="px-3 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors text-sm mcp-install-button"
title="Editar configuração"
>
<Settings className="h-4 w-4" />
</button>
{onTestServer && (
<button
onClick={onTestServer}
disabled={isTesting}
className="px-3 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 transition-colors text-sm mcp-install-button disabled:opacity-50"
title="Testar servidor MCP"
>
{isTesting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<PlayCircle className="h-4 w-4" />
)}
</button>
)}
<button
onClick={onUninstall}
className="px-3 py-2 bg-red-500 text-white rounded-md hover:bg-red-600 transition-colors text-sm mcp-install-button"
title="Desinstalar MCP"
>
Uninstall
</button>
</>
) : mcp.installed ? (
<button
onClick={onUninstall}
className="px-3 py-2 bg-red-500 text-white rounded-md hover:bg-red-600 transition-colors text-sm mcp-install-button"
>
Uninstall
</button>
) : (
<button
onClick={onInstall}
className="px-3 py-2 bg-[var(--primary)] text-[var(--primary-foreground)] rounded-md hover:bg-[var(--primary)]/90 transition-colors text-sm mcp-install-button"
>
Install
</button>
)}
</div>
{toolCount > 0 && (
<div className="flex items-center gap-1">
<Zap className="h-3 w-3" />
<span>{toolCount} tools</span>
{/* Links */}
{(mcp.repository || mcp.homepage) && (
<div className="mt-3 pt-3 border-t border-[var(--border)]">
<div className="flex gap-2">
{mcp.repository && (
<a
href={mcp.repository}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
>
<ExternalLink className="h-3 w-3" />
Repository
</a>
)}
{mcp.homepage && (
<a
href={mcp.homepage}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
>
<ExternalLink className="h-3 w-3" />
Homepage
</a>
)}
</div>
</div>
)}
<span className="text-xs bg-[var(--surface)] px-2 py-1 rounded">
v{mcp.version}
</span>
</div>
{/* Tags */}
<div className="flex flex-wrap gap-1 mb-4">
{mcp.tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="px-2 py-1 bg-[var(--background)] text-xs rounded-md border border-[var(--border)] mcp-tag"
>
{tag}
</span>
))}
{mcp.tags.length > 3 && (
<span className="px-2 py-1 bg-[var(--background)] text-xs rounded-md border border-[var(--border)] mcp-tag">
+{mcp.tags.length - 3}
</span>
)}
</div>
{/* Actions */}
<div className="flex gap-2">
<button
onClick={onViewDetails}
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-md hover:bg-[var(--surface)] transition-colors text-sm"
>
<Eye className="h-4 w-4" />
Details
</button>
{mcp.installed ? (
<button
onClick={onUninstall}
className="px-3 py-2 bg-red-500 text-white rounded-md hover:bg-red-600 transition-colors text-sm mcp-install-button"
>
Uninstall
</button>
) : (
<button
onClick={onInstall}
className="px-3 py-2 bg-[var(--primary)] text-[var(--primary-foreground)] rounded-md hover:bg-[var(--primary)]/90 transition-colors text-sm mcp-install-button"
>
Install
</button>
)}
</div>
{/* Links */}
{(mcp.repository || mcp.homepage) && (
<div className="mt-3 pt-3 border-t border-[var(--border)]">
<div className="flex gap-2">
{mcp.repository && (
<a
href={mcp.repository}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
>
<ExternalLink className="h-3 w-3" />
Repository
</a>
)}
{mcp.homepage && (
<a
href={mcp.homepage}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
>
<ExternalLink className="h-3 w-3" />
Homepage
</a>
)}
</div>
</div>
)}
</div>
);
}
);
},
(prevProps, nextProps) => {
// Custom comparison para evitar re-renders desnecessários
return (
prevProps.mcp.id === nextProps.mcp.id &&
prevProps.mcp.installed === nextProps.mcp.installed &&
prevProps.mcp.rating === nextProps.mcp.rating &&
prevProps.mcp.totalRatings === nextProps.mcp.totalRatings &&
prevProps.mcp.name === nextProps.mcp.name &&
prevProps.mcp.description === nextProps.mcp.description &&
prevProps.mode === nextProps.mode &&
prevProps.isTesting === nextProps.isTesting
);
}
);
+272
View File
@@ -0,0 +1,272 @@
"use client";
import { useState, useEffect } from "react";
import {
X,
Package,
AlertCircle,
CheckCircle,
Info,
ExternalLink,
} from "lucide-react";
import { MCPProvider } from "@/lib/types/mcp";
interface MCPInstallConfigModalProps {
mcp: MCPProvider;
config: any; // Configuração inicial do servidor
isOpen: boolean;
onClose: () => void;
onConfirm: (editedConfig: any) => void;
isEditMode?: boolean; // Novo prop para indicar modo de edição
}
export function MCPInstallConfigModal({
mcp,
config,
isOpen,
onClose,
onConfirm,
isEditMode = false,
}: MCPInstallConfigModalProps) {
const [jsonString, setJsonString] = useState("");
const [isValidJson, setIsValidJson] = useState(true);
const [jsonError, setJsonError] = useState("");
const [hasEnvVars, setHasEnvVars] = useState(false);
// Inicializar JSON quando config mudar
useEffect(() => {
if (config) {
try {
const formattedJson = JSON.stringify(config, null, 2);
setJsonString(formattedJson);
setIsValidJson(true);
setJsonError("");
// Verificar se tem variáveis de ambiente
const hasEnv =
JSON.stringify(config).includes('"env"') ||
JSON.stringify(config).includes("env:");
setHasEnvVars(hasEnv);
} catch (error) {
console.error("Error formatting config:", error);
setJsonString("{}");
setIsValidJson(false);
setJsonError("Erro ao formatar configuração");
}
} else {
// Config vazio - criar template baseado no MCP
// Usar npm ou nome do repositório se disponível
const serverName = mcp.id || mcp.name.toLowerCase().replace(/\s+/g, "-");
const command = mcp.repository?.includes("npm")
? "npx"
: mcp.repository
? "node"
: "npm";
const template = {
mcpServers: {
[serverName]: {
command: command,
args: command === "npx" ? ["-y", serverName] : [],
env: {},
},
},
};
setJsonString(JSON.stringify(template, null, 2));
setIsValidJson(true);
setJsonError("");
setHasEnvVars(false);
}
}, [config]);
// Validar JSON em tempo real
const validateJson = (value: string) => {
try {
JSON.parse(value);
setIsValidJson(true);
setJsonError("");
return true;
} catch (error) {
setIsValidJson(false);
setJsonError(error instanceof Error ? error.message : "JSON inválido");
return false;
}
};
const handleJsonChange = (value: string) => {
setJsonString(value);
validateJson(value);
};
const handleConfirm = () => {
if (!isValidJson) {
return;
}
try {
const parsedConfig = JSON.parse(jsonString);
onConfirm(parsedConfig);
} catch (error) {
console.error("Error parsing config:", error);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative w-full max-w-4xl h-[90vh] bg-[var(--background)] border border-[var(--border)] rounded-lg shadow-xl flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-[var(--border)]">
<div className="flex items-center gap-4">
<div className="p-3 bg-[var(--primary)]/10 rounded-lg">
<Package className="h-8 w-8 text-[var(--primary)]" />
</div>
<div>
<h2 className="text-2xl font-bold">
{isEditMode ? "Editar Configuração" : "Configurar Instalação"}
</h2>
<div className="flex items-center gap-2">
<p className="text-[var(--muted-foreground)]">
{mcp.name} por {mcp.author}
</p>
{(mcp.repository || mcp.homepage) && (
<a
href={mcp.repository || mcp.homepage}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-blue-600 hover:text-blue-700 transition-colors"
title="Ver no GitHub"
>
<ExternalLink className="h-4 w-4" />
<span className="text-sm">GitHub</span>
</a>
)}
</div>
</div>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-[var(--surface)] rounded-md transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Instructions */}
<div className="p-6 border-b border-[var(--border)] bg-blue-50 dark:bg-blue-900/20">
<div className="flex items-start gap-3">
<Info className="h-5 w-5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
<div className="text-sm">
<p className="font-medium text-blue-800 dark:text-blue-200 mb-2">
Instruções de Configuração
</p>
<ul className="text-blue-700 dark:text-blue-300 space-y-1">
<li>
• <strong>Edite apenas os campos necessários</strong>,
especialmente variáveis de ambiente (env)
</li>
<li>
• Adicione suas <strong>API keys</strong>,{" "}
<strong>tokens</strong> e <strong>credenciais</strong> nos
campos env
</li>
<li>
• Mantenha a estrutura JSON válida - use aspas duplas e
vírgulas corretamente
</li>
<li>
• Campos como "command" geralmente não precisam ser alterados
</li>
</ul>
</div>
</div>
</div>
{/* JSON Editor */}
<div className="flex-1 p-6 overflow-hidden flex flex-col min-h-0">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">Configuração do Servidor</h3>
<div className="flex items-center gap-2">
{isValidJson ? (
<div className="flex items-center gap-1 text-green-600">
<CheckCircle className="h-4 w-4" />
<span className="text-sm">JSON válido</span>
</div>
) : (
<div className="flex items-center gap-1 text-red-600">
<AlertCircle className="h-4 w-4" />
<span className="text-sm">JSON inválido</span>
</div>
)}
</div>
</div>
{/* Error Message */}
{!isValidJson && jsonError && (
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
<div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-red-600 dark:text-red-400 mt-0.5 flex-shrink-0" />
<div className="text-sm text-red-700 dark:text-red-300">
<p className="font-medium">Erro no JSON:</p>
<p className="font-mono text-xs mt-1">{jsonError}</p>
</div>
</div>
</div>
)}
{/* JSON Textarea - Área maior */}
<div className="flex-1 min-h-0 mb-4">
<textarea
value={jsonString}
onChange={(e) => handleJsonChange(e.target.value)}
className="w-full h-full p-4 bg-[var(--surface)] border border-[var(--border)] rounded-md font-mono text-sm resize-none focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
placeholder="Digite a configuração JSON aqui..."
spellCheck={false}
/>
</div>
{/* Env Variables Warning */}
{hasEnvVars && (
<div className="mb-4 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md">
<div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-yellow-600 dark:text-yellow-400 mt-0.5 flex-shrink-0" />
<div className="text-sm text-yellow-700 dark:text-yellow-300">
<p className="font-medium">
Variáveis de Ambiente Detectadas
</p>
<p>
Certifique-se de preencher os valores necessários nos campos
"env" antes de instalar.
</p>
</div>
</div>
</div>
)}
</div>
{/* Footer */}
<div className="p-6 border-t border-[var(--border)] flex-shrink-0">
<div className="flex justify-end gap-3">
<button
onClick={onClose}
className="px-4 py-2 bg-[var(--surface)] border border-[var(--border)] rounded-md hover:bg-[var(--background)] transition-colors"
>
Cancelar
</button>
<button
onClick={handleConfirm}
disabled={!isValidJson}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isEditMode ? "Salvar Alterações" : "Confirmar Instalação"}
</button>
</div>
</div>
</div>
</div>
);
}
+320
View File
@@ -0,0 +1,320 @@
"use client";
import { useState } from "react";
import { X, Upload, CheckCircle, AlertCircle, Loader2 } from "lucide-react";
interface MCPManualInstallModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess?: (mcpId: string) => void;
}
export function MCPManualInstallModal({
isOpen,
onClose,
onSuccess,
}: MCPManualInstallModalProps) {
const [configJson, setConfigJson] = useState("");
const [mcpId, setMcpId] = useState("");
const [isInstalling, setIsInstalling] = useState(false);
const [error, setError] = useState<string | null>(null);
const [validationError, setValidationError] = useState<string | null>(null);
const [parsedConfig, setParsedConfig] = useState<any>(null);
// Validar JSON enquanto digita
const handleConfigChange = (value: string) => {
setConfigJson(value);
setValidationError(null);
setParsedConfig(null);
if (!value.trim()) {
return;
}
try {
const parsed = JSON.parse(value);
setParsedConfig(parsed);
// Validar estrutura básica
if (parsed.mcpServers) {
const serverNames = Object.keys(parsed.mcpServers);
if (serverNames.length === 0) {
setValidationError("No MCP server found in configuration");
return;
}
const firstServer = parsed.mcpServers[serverNames[0]];
if (!firstServer.command) {
setValidationError("Missing required field: command");
return;
}
// Auto-preencher ID se estiver vazio
if (!mcpId) {
setMcpId(serverNames[0]);
}
} else {
// Formato direto
if (!parsed.command) {
setValidationError("Missing required field: command");
return;
}
}
} catch (err: any) {
setValidationError(`Invalid JSON: ${err.message}`);
}
};
const handleInstall = async () => {
if (!configJson.trim()) {
setError("Configuration is required");
return;
}
if (validationError) {
setError("Please fix validation errors before installing");
return;
}
setIsInstalling(true);
setError(null);
try {
const response = await fetch("/api/mcp/install-manual", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
config: parsedConfig,
mcpId: mcpId || undefined,
enableLogs: true,
}),
});
const data = await response.json();
if (data.success) {
console.log(`✅ Manual installation started for ${data.mcpId}`);
onSuccess?.(data.mcpId);
onClose();
// Reset form
setConfigJson("");
setMcpId("");
setParsedConfig(null);
} else {
setError(data.error || "Failed to start installation");
}
} catch (err: any) {
console.error("Error installing MCP:", err);
setError(err.message || "Failed to start installation");
} finally {
setIsInstalling(false);
}
};
const handlePasteExample = () => {
const example = `{
"mcpServers": {
"time-server": {
"command": "npx",
"args": ["@mcpcentral/mcp-time"]
}
}
}`;
setConfigJson(example);
handleConfigChange(example);
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="sticky top-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<Upload className="h-5 w-5 text-blue-600 dark:text-blue-400" />
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
Install MCP Manually
</h2>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-6">
{/* Description */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h3 className="font-medium text-blue-900 dark:text-blue-100 mb-2">
How to use
</h3>
<p className="text-sm text-blue-700 dark:text-blue-300 mb-3">
Paste your MCP server configuration in Claude Desktop format
(claude_desktop_config.json). You can install custom MCPs or MCPs
not available in the marketplace.
</p>
<button
onClick={handlePasteExample}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
Click here to load an example
</button>
</div>
{/* MCP ID (optional) */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
MCP ID (optional)
</label>
<input
type="text"
value={mcpId}
onChange={(e) => setMcpId(e.target.value)}
placeholder="my-custom-mcp (auto-detected if empty)"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
{/* Configuration JSON */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Configuration JSON *
</label>
<textarea
value={configJson}
onChange={(e) => handleConfigChange(e.target.value)}
placeholder={`Paste your configuration here, e.g.:
{
"mcpServers": {
"time-server": {
"command": "npx",
"args": ["@mcpcentral/mcp-time"]
}
}
}`}
rows={15}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
/>
{/* Validation Status */}
{configJson.trim() && (
<div className="mt-2 flex items-center gap-2">
{validationError ? (
<>
<AlertCircle className="h-4 w-4 text-red-500" />
<span className="text-sm text-red-600 dark:text-red-400">
{validationError}
</span>
</>
) : parsedConfig ? (
<>
<CheckCircle className="h-4 w-4 text-green-500" />
<span className="text-sm text-green-600 dark:text-green-400">
Valid configuration
</span>
</>
) : null}
</div>
)}
</div>
{/* Preview */}
{parsedConfig && !validationError && (
<div className="bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h3 className="font-medium text-gray-900 dark:text-white mb-3">
Preview
</h3>
<div className="space-y-2 text-sm">
{parsedConfig.mcpServers ? (
Object.entries(parsedConfig.mcpServers).map(
([name, config]: [string, any]) => (
<div key={name} className="space-y-1">
<div className="text-gray-700 dark:text-gray-300">
<span className="font-medium">Server:</span> {name}
</div>
<div className="text-gray-600 dark:text-gray-400">
<span className="font-medium">Command:</span>{" "}
{config.command} {(config.args || []).join(" ")}
</div>
{config.env && (
<div className="text-gray-600 dark:text-gray-400">
<span className="font-medium">Environment:</span>{" "}
{Object.keys(config.env).length} variables
</div>
)}
</div>
)
)
) : (
<div className="space-y-1">
<div className="text-gray-600 dark:text-gray-400">
<span className="font-medium">Command:</span>{" "}
{parsedConfig.command}{" "}
{(parsedConfig.args || []).join(" ")}
</div>
{parsedConfig.env && (
<div className="text-gray-600 dark:text-gray-400">
<span className="font-medium">Environment:</span>{" "}
{Object.keys(parsedConfig.env).length} variables
</div>
)}
</div>
)}
</div>
</div>
)}
{/* Error Message */}
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div className="flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h4 className="font-medium text-red-900 dark:text-red-100">
Installation Error
</h4>
<p className="text-sm text-red-700 dark:text-red-300 mt-1">
{error}
</p>
</div>
</div>
</div>
)}
</div>
{/* Footer */}
<div className="sticky bottom-0 bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 p-4 flex gap-3 justify-end">
<button
onClick={onClose}
disabled={isInstalling}
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
onClick={handleInstall}
disabled={isInstalling || !parsedConfig || !!validationError}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{isInstalling ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Installing...
</>
) : (
<>
<Upload className="h-4 w-4" />
Install MCP
</>
)}
</button>
</div>
</div>
</div>
);
}
+652 -93
View File
@@ -1,11 +1,24 @@
"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
import { X, Search, Filter, Package, Star, Eye } from "lucide-react";
import { MCPProvider } from "@/lib/types/mcp";
import {
X,
Search,
Filter,
Package,
Star,
Eye,
CheckCircle,
XCircle,
Upload,
} from "lucide-react";
import { MCPProvider, MCPTestResult } from "@/lib/types/mcp";
import { MCPCard } from "./MCPCard";
import { MCPCategoryFilter } from "./MCPCategoryFilter";
import { MCPSearchBar } from "./MCPSearchBar";
import { MCPInstallConfigModal } from "./MCPInstallConfigModal";
import { MCPManualInstallModal } from "./MCPManualInstallModal";
// import { VirtualizedMCPGrid } from "./VirtualizedMCPGrid";
interface MCPMarketplaceModalProps {
isOpen: boolean;
@@ -26,6 +39,11 @@ export function MCPMarketplaceModal({
>("rating");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
const [selectedMCP, setSelectedMCP] = useState<MCPProvider | null>(null);
const [configModalOpen, setConfigModalOpen] = useState(false);
const [selectedConfigMCP, setSelectedConfigMCP] =
useState<MCPProvider | null>(null);
const [mcpConfig, setMcpConfig] = useState<any>(null);
const [loadingConfig, setLoadingConfig] = useState(false);
const [syncStatus, setSyncStatus] = useState<{
isSyncing: boolean;
lastSync: number | null;
@@ -37,6 +55,66 @@ export function MCPMarketplaceModal({
others: Array<{ category: string; count: number }>;
}>({ primary: [], others: [] });
// Estados para controle de abas
const [activeTab, setActiveTab] = useState<"marketplace" | "installed">(
"marketplace"
);
const [installedMcps, setInstalledMcps] = useState<any[]>([]);
const [loadingInstalled, setLoadingInstalled] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
// Estados para teste de servidor
const [testingMCP, setTestingMCP] = useState<string | null>(null);
const [testResult, setTestResult] = useState<MCPTestResult | null>(null);
// Estados para tracking de instalação em progresso
const [installingMcps, setInstallingMcps] = useState<
Map<
string,
{
status: string;
message: string;
percentage: number;
}
>
>(new Map());
// Estado para modal de instalação manual
const [manualInstallModalOpen, setManualInstallModalOpen] = useState(false);
// Função helper para calcular porcentagem baseada no status
const calculatePercentage = (status: string): number => {
const statusMap: Record<string, number> = {
pending: 5,
checking_dependencies: 10,
downloading: 30,
installing: 60,
testing: 85,
ready: 100,
failed: 0,
};
return statusMap[status] || 0;
};
// Memoizar categorias processadas para evitar re-processamento desnecessário
const processedCategories = useMemo(
() => ({
primary: categories.primary.map((cat) => ({
id: cat.category.toLowerCase(),
name: cat.category,
icon: cat.category,
count: cat.count,
})),
others: categories.others.map((cat) => ({
id: cat.category.toLowerCase(),
name: cat.category,
icon: cat.category,
count: cat.count,
})),
}),
[categories]
);
const fetchMCPs = useCallback(async () => {
console.log("Fetching MCPs with params:", {
selectedCategory,
@@ -83,25 +161,261 @@ export function MCPMarketplaceModal({
}
}, []);
const fetchInstalledMCPs = useCallback(async () => {
setLoadingInstalled(true);
try {
const response = await fetch("/api/mcp/installed");
const data = await response.json();
if (data.success) {
setInstalledMcps(data.mcps);
console.log("📦 Installed MCPs loaded:", data.mcps.length);
} else {
console.error("Failed to fetch installed MCPs:", data.error);
}
} catch (error) {
console.error("Error fetching installed MCPs:", error);
} finally {
setLoadingInstalled(false);
}
}, []);
const handleInstall = async (mcpId: string) => {
try {
const response = await fetch("/api/mcp/install", {
// Limpar ID de possíveis barras no início/fim
const cleanId = mcpId.replace(/^\/+|\/+$/g, "");
console.log(
`[handleInstall] Received mcpId: "${mcpId}" -> cleaned: "${cleanId}"`
);
// Buscar dados completos do MCP no cache
const mcpFromCache = mcps.find((m) => m.id === cleanId || m.id === mcpId);
if (!mcpFromCache) {
console.error(
"MCP not found in cache. Looking for:",
cleanId,
"Available IDs:",
mcps.slice(0, 5).map((m) => m.id)
);
return;
}
console.log("[handleInstall] Found MCP:", {
id: mcpFromCache.id,
owner: mcpFromCache.owner,
repo: mcpFromCache.repo,
name: mcpFromCache.name,
});
// Construir ID no formato owner/repo
let finalId = cleanId;
if (mcpFromCache.owner && mcpFromCache.owner.trim() !== "") {
finalId = `${mcpFromCache.owner}/${mcpFromCache.repo}`;
} else if (mcpFromCache.repo) {
finalId = mcpFromCache.repo;
}
console.log(`[handleInstall] Using final ID: "${finalId}"`);
setLoadingConfig(true);
// Abrir modal de configuração sempre
// Se tivermos owner/repo, buscar config; senão, abrir com config vazio
if (finalId.includes("/")) {
const configResponse = await fetch(
`/api/mcp/server-config?mcpId=${encodeURIComponent(finalId)}`
);
const configData = await configResponse.json();
if (configData.success) {
console.log(
"Opening config modal with:",
configData.config ? "provided config" : "empty config"
);
setSelectedConfigMCP(mcpFromCache);
setMcpConfig(configData.config); // Pode ser null
setConfigModalOpen(true);
} else {
console.error("Error fetching server config:", configData.error);
// Abrir modal mesmo com erro (usuário pode adicionar config)
setSelectedConfigMCP(mcpFromCache);
setMcpConfig(null);
setConfigModalOpen(true);
}
} else {
console.log("No owner/repo format, opening modal with empty config");
// Abrir modal com config vazio
setSelectedConfigMCP(mcpFromCache);
setMcpConfig(null);
setConfigModalOpen(true);
}
} catch (error) {
console.error("Error preparing MCP installation:", error);
} finally {
setLoadingConfig(false);
}
};
const handleEditConfig = async (mcp: any) => {
try {
setLoadingConfig(true);
setIsEditMode(true);
// Converter dados do MCP instalado para formato MCPProvider
const mcpProvider: MCPProvider = {
id: mcp.id,
name: mcp.name,
author: mcp.author || mcp.owner || "Unknown",
description: mcp.description || `MCP instalado: ${mcp.name}`,
version: "1.0.0",
category: mcp.category || "other",
tags: mcp.tags || [],
rating: mcp.rating || 0,
totalRatings: mcp.totalRatings || 0,
installed: true,
repository: mcp.repository || "",
homepage: mcp.homepage || "",
config: mcp.config,
tools: mcp.tools,
};
setSelectedConfigMCP(mcpProvider);
setMcpConfig(mcp.config);
setConfigModalOpen(true);
} catch (error) {
console.error("Error preparing MCP config edit:", error);
} finally {
setLoadingConfig(false);
}
};
const handleTestServer = async (mcpId: string) => {
try {
setTestingMCP(mcpId);
setTestResult(null);
console.log(`🧪 Testando servidor MCP: ${mcpId}`);
const response = await fetch("/api/mcp/test-server", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mcpId }),
});
const data = await response.json();
if (data.success) {
// Atualizar estado local
setMcps((prev) =>
prev.map((mcp) =>
mcp.id === mcpId ? { ...mcp, installed: true } : mcp
)
);
setTestResult(data.result);
console.log("✅ Teste concluído:", data.result);
} else {
setTestResult({
success: false,
message: data.error || "Erro desconhecido",
error: data.error,
});
console.error("❌ Erro no teste:", data.error);
}
} catch (error: any) {
console.error("Erro ao testar servidor:", error);
setTestResult({
success: false,
message: "Erro de conexão",
error: error.message,
});
} finally {
setTestingMCP(null);
// Limpar resultado após 5 segundos
setTimeout(() => {
setTestResult(null);
}, 5000);
}
};
const handleConfirmInstall = async (editedConfig: any) => {
if (!selectedConfigMCP) return;
try {
if (isEditMode) {
// Modo de edição - atualizar configuração existente
const response = await fetch("/api/mcp/update-config", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
mcpId: selectedConfigMCP.id,
newConfig: editedConfig,
}),
});
const data = await response.json();
if (data.success) {
// Recarregar lista de MCPs instalados
await fetchInstalledMCPs();
// Fechar modal
setConfigModalOpen(false);
setSelectedConfigMCP(null);
setMcpConfig(null);
setIsEditMode(false);
} else {
console.error("Error updating MCP config:", data.error);
}
} else {
// Modo de instalação - comportamento original
let finalId = selectedConfigMCP.id;
if (!finalId.includes("/")) {
if (
selectedConfigMCP.owner &&
selectedConfigMCP.owner.trim() !== ""
) {
finalId = `${selectedConfigMCP.owner}/${selectedConfigMCP.repo}`;
} else if (selectedConfigMCP.repo) {
finalId = selectedConfigMCP.repo;
}
}
const response = await fetch("/api/mcp/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
mcpId: finalId,
customConfig: editedConfig,
}),
});
const data = await response.json();
if (data.success) {
// Adicionar ao tracking de instalação
setInstallingMcps((prev) => {
const next = new Map(prev);
next.set(finalId, {
status: data.status || "pending",
message: "Installation started...",
percentage: 5,
});
return next;
});
// Atualizar estado local
setMcps((prev) =>
prev.map((mcp) =>
mcp.id === selectedConfigMCP.id
? { ...mcp, installed: true }
: mcp
)
);
// Fechar modal
setConfigModalOpen(false);
setSelectedConfigMCP(null);
setMcpConfig(null);
console.log(`✅ Installation started for ${finalId}`);
} else {
console.error("Error installing MCP:", data.error);
}
}
} catch (error) {
console.error("Error installing MCP:", error);
console.error("Error processing MCP:", error);
}
};
@@ -139,6 +453,13 @@ export function MCPMarketplaceModal({
}
}, [isOpen]); // Removido fetchMCPs das dependências
// Carregar MCPs instalados quando aba "installed" for ativada
useEffect(() => {
if (isOpen && activeTab === "installed") {
fetchInstalledMCPs();
}
}, [isOpen, activeTab]);
// Debounce para searchTerm
useEffect(() => {
const timer = setTimeout(() => {
@@ -165,6 +486,57 @@ export function MCPMarketplaceModal({
}
}, [isOpen]);
// Poll status de instalações em progresso
useEffect(() => {
if (installingMcps.size === 0) return;
const interval = setInterval(async () => {
for (const mcpId of installingMcps.keys()) {
try {
const response = await fetch(
`/api/mcp/install-status?mcpId=${mcpId}`
);
const data = await response.json();
if (data.success) {
const status = data.status;
const isComplete = status === "ready" || status === "failed";
if (isComplete) {
// Remover do tracking
setInstallingMcps((prev) => {
const next = new Map(prev);
next.delete(mcpId);
return next;
});
// Atualizar lista de MCPs
await fetchMCPs();
if (activeTab === "installed") {
await fetchInstalledMCPs();
}
} else {
// Atualizar progresso
setInstallingMcps((prev) => {
const next = new Map(prev);
next.set(mcpId, {
status: data.status,
message: data.message || "",
percentage: calculatePercentage(data.status),
});
return next;
});
}
}
} catch (error) {
console.error(`Error polling status for ${mcpId}:`, error);
}
}
}, 2000); // Poll a cada 2 segundos
return () => clearInterval(interval);
}, [installingMcps, activeTab]);
// Recarregar MCPs quando filtros mudarem (só se modal estiver aberto)
useEffect(() => {
if (isOpen) {
@@ -185,14 +557,56 @@ export function MCPMarketplaceModal({
<Package className="h-6 w-6 text-[var(--primary)]" />
<h2 className="text-xl font-semibold">MCP Marketplace</h2>
<span className="text-sm text-[var(--muted-foreground)]">
{mcps.length} MCPs available
{activeTab === "marketplace"
? `${mcps.length} MCPs available`
: `${installedMcps.length} MCPs installed`}
</span>
{loadingConfig && (
<div className="flex items-center gap-2 text-sm text-blue-600">
<div className="animate-spin h-4 w-4 border-2 border-blue-600 border-t-transparent rounded-full"></div>
<span>Carregando configuração...</span>
</div>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setManualInstallModalOpen(true)}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium"
title="Install MCP manually from configuration"
>
<Upload className="h-4 w-4" />
Install Manual
</button>
<button
onClick={onClose}
className="p-2 hover:bg-[var(--surface)] rounded-md transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
</div>
{/* Tabs */}
<div className="flex gap-2 border-b border-[var(--border)] px-6 flex-shrink-0">
<button
onClick={onClose}
className="p-2 hover:bg-[var(--surface)] rounded-md transition-colors"
onClick={() => setActiveTab("marketplace")}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === "marketplace"
? "border-[var(--primary)] text-[var(--primary)]"
: "border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
}`}
>
<X className="h-5 w-5" />
Marketplace
</button>
<button
onClick={() => setActiveTab("installed")}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === "installed"
? "border-[var(--primary)] text-[var(--primary)]"
: "border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
}`}
>
Instalados ({installedMcps.length})
</button>
</div>
@@ -244,101 +658,246 @@ export function MCPMarketplaceModal({
{/* Content */}
<div className="flex flex-1 min-h-0">
{/* Sidebar */}
<div className="w-64 border-r border-[var(--border)] p-4 flex-shrink-0 overflow-y-auto">
<MCPCategoryFilter
primaryCategories={categories.primary.map((cat) => ({
id: cat.category.toLowerCase(),
name: cat.category,
icon: cat.category,
count: cat.count,
}))}
otherCategories={categories.others.map((cat) => ({
id: cat.category.toLowerCase(),
name: cat.category,
icon: cat.category,
count: cat.count,
}))}
selectedCategory={selectedCategory}
onSelectCategory={setSelectedCategory}
/>
</div>
{/* Sidebar - apenas para marketplace */}
{activeTab === "marketplace" && (
<div className="w-64 border-r border-[var(--border)] p-4 flex-shrink-0 overflow-y-auto">
<MCPCategoryFilter
primaryCategories={processedCategories.primary}
otherCategories={processedCategories.others}
selectedCategory={selectedCategory}
onSelectCategory={setSelectedCategory}
/>
</div>
)}
{/* Main Content */}
<div className="flex-1 flex flex-col min-h-0">
{/* Search and Filters */}
<div className="p-4 border-b border-[var(--border)] flex-shrink-0">
<div className="flex gap-4 items-center">
<MCPSearchBar
value={searchTerm}
onChange={setSearchTerm}
onSearch={() => {
// O useEffect já cuida de recarregar quando searchTerm muda
}}
/>
<div className="flex gap-2 items-center">
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
className="px-3 py-2 bg-[var(--surface)] border border-[var(--border)] rounded-md text-sm"
>
<option value="rating">Por Avaliação</option>
<option value="name">Por Nome</option>
<option value="recent">Mais Recentes</option>
<option value="total_ratings">Por Nº de Reviews</option>
<option value="updated_at">Atualizados Recentemente</option>
</select>
{/* Search and Filters - apenas para marketplace */}
{activeTab === "marketplace" && (
<div className="p-4 border-b border-[var(--border)] flex-shrink-0">
<div className="flex gap-4 items-center">
<MCPSearchBar
value={searchTerm}
onChange={setSearchTerm}
onSearch={() => {
// O useEffect já cuida de recarregar quando searchTerm muda
}}
/>
<div className="flex gap-2 items-center">
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
className="px-3 py-2 bg-[var(--surface)] border border-[var(--border)] rounded-md text-sm"
>
<option value="rating">Por Avaliação</option>
<option value="name">Por Nome</option>
<option value="recent">Mais Recentes</option>
<option value="total_ratings">Por Nº de Reviews</option>
<option value="updated_at">
Atualizados Recentemente
</option>
</select>
<button
onClick={() =>
setSortOrder((prev) => (prev === "asc" ? "desc" : "asc"))
}
className="px-3 py-2 border border-[var(--border)] rounded-md hover:bg-[var(--surface)] transition-colors"
title={
sortOrder === "asc"
? "Ordem Crescente"
: "Ordem Decrescente"
}
>
{sortOrder === "asc" ? "↑" : "↓"}
</button>
<button
onClick={() =>
setSortOrder((prev) =>
prev === "asc" ? "desc" : "asc"
)
}
className="px-3 py-2 border border-[var(--border)] rounded-md hover:bg-[var(--surface)] transition-colors"
title={
sortOrder === "asc"
? "Ordem Crescente"
: "Ordem Decrescente"
}
>
{sortOrder === "asc" ? "↑" : "↓"}
</button>
</div>
</div>
</div>
</div>
)}
{/* MCP Grid */}
<div className="flex-1 p-4 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]"></div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{mcps.map((mcp) => (
<MCPCard
key={mcp.id}
mcp={mcp}
onInstall={() => handleInstall(mcp.id)}
onUninstall={() => handleUninstall(mcp.id)}
onViewDetails={() => handleViewDetails(mcp)}
/>
))}
</div>
{activeTab === "marketplace" && (
<>
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]"></div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{mcps.map((mcp) => (
<MCPCard
key={mcp.id}
mcp={mcp}
onInstall={() => handleInstall(mcp.id)}
onUninstall={() => handleUninstall(mcp.id)}
onViewDetails={() => handleViewDetails(mcp)}
installingStatus={installingMcps.get(mcp.id)}
/>
))}
</div>
)}
{!loading && mcps.length === 0 && (
<div className="flex flex-col items-center justify-center h-64 text-[var(--muted-foreground)]">
<Package className="h-12 w-12 mb-4 opacity-50" />
<p>No MCPs found</p>
<p className="text-sm">
Try adjusting your search or filters
</p>
</div>
)}
</>
)}
{!loading && mcps.length === 0 && (
<div className="flex flex-col items-center justify-center h-64 text-[var(--muted-foreground)]">
<Package className="h-12 w-12 mb-4 opacity-50" />
<p>No MCPs found</p>
<p className="text-sm">
Try adjusting your search or filters
</p>
</div>
{activeTab === "installed" && (
<>
{loadingInstalled ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]"></div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{installedMcps.map((mcp) => (
<MCPCard
key={mcp.id}
mcp={{
id: mcp.id,
name: mcp.name,
author: mcp.author || mcp.owner || "Unknown",
description:
mcp.description || `MCP instalado: ${mcp.name}`,
version: "1.0.0",
category: mcp.category || "other",
tags: mcp.tags || [],
rating: mcp.rating || 0,
totalRatings: mcp.totalRatings || 0,
installed: true,
repository: mcp.repository || "",
homepage: mcp.homepage || "",
config: mcp.config,
tools: mcp.tools,
}}
mode="installed"
onInstall={() => {}} // Não usado no modo installed
onEditConfig={() => handleEditConfig(mcp)}
onTestServer={() => handleTestServer(mcp.id)}
isTesting={testingMCP === mcp.id}
onUninstall={() => handleUninstall(mcp.id)}
onViewDetails={() =>
handleViewDetails({
id: mcp.id,
name: mcp.name,
author: mcp.author || mcp.owner || "Unknown",
description:
mcp.description || `MCP instalado: ${mcp.name}`,
version: "1.0.0",
category: mcp.category || "other",
tags: mcp.tags || [],
rating: mcp.rating || 0,
totalRatings: mcp.totalRatings || 0,
installed: true,
repository: mcp.repository || "",
homepage: mcp.homepage || "",
config: mcp.config,
tools: mcp.tools,
})
}
installingStatus={installingMcps.get(mcp.id)}
/>
))}
</div>
)}
{!loadingInstalled && installedMcps.length === 0 && (
<div className="flex flex-col items-center justify-center h-64 text-[var(--muted-foreground)]">
<Package className="h-12 w-12 mb-4 opacity-50" />
<p>Nenhum MCP instalado</p>
<p className="text-sm">Instale MCPs na aba Marketplace</p>
</div>
)}
</>
)}
</div>
</div>
</div>
</div>
{/* Manual Install Modal */}
<MCPManualInstallModal
isOpen={manualInstallModalOpen}
onClose={() => setManualInstallModalOpen(false)}
onSuccess={async (mcpId) => {
console.log(`✅ Manual installation started for ${mcpId}`);
// Adicionar ao tracking de instalação
setInstallingMcps((prev) => {
const next = new Map(prev);
next.set(mcpId, {
status: "pending",
message: "Installation started...",
percentage: 5,
});
return next;
});
// Atualizar listas
await fetchMCPs();
if (activeTab === "installed") {
await fetchInstalledMCPs();
}
}}
/>
{/* Config Modal */}
{selectedConfigMCP && (
<MCPInstallConfigModal
mcp={selectedConfigMCP}
config={mcpConfig}
isOpen={configModalOpen}
onClose={() => {
setConfigModalOpen(false);
setSelectedConfigMCP(null);
setMcpConfig(null);
setIsEditMode(false);
}}
onConfirm={handleConfirmInstall}
isEditMode={isEditMode}
/>
)}
{/* Toast de resultado do teste */}
{testResult && (
<div
className={`fixed bottom-4 right-4 p-4 rounded-lg shadow-lg z-50 max-w-sm ${
testResult.success ? "bg-green-500" : "bg-red-500"
} text-white`}
>
<div className="flex items-center gap-2">
{testResult.success ? (
<CheckCircle className="h-5 w-5 flex-shrink-0" />
) : (
<XCircle className="h-5 w-5 flex-shrink-0" />
)}
<div className="min-w-0">
<p className="font-semibold text-sm">{testResult.message}</p>
{testResult.responseTime && (
<p className="text-xs opacity-90">
Tempo de resposta: {testResult.responseTime}ms
</p>
)}
{testResult.error && (
<p className="text-xs opacity-90 font-mono">
{testResult.error}
</p>
)}
</div>
</div>
</div>
)}
</div>
);
}
+1
View File
@@ -0,0 +1 @@