feat(mcp): add GitHub repo support and tools display in MCP cards

- Support installing MCP servers from GitHub repos by converting 'github:user/repo' to 'git+https://github.com/user/repo.git' to avoid SSH issues and improve npm compatibility
- Display available tools in MCPCard with badges, showing up to 6 tools and total count for better visibility of server capabilities
- Remove unused onViewDetails prop and make uninstall button full-width for improved UI consistency
This commit is contained in:
Evandro Fonseca Junior committed 2025-10-28 22:17:19 -03:00
1 parent f19bf10ef3
commit 745746afa9
8 files changed
+227 -60

No files matched your search

+14 -3
View File
@@ -144,14 +144,25 @@ function detectPackageInfo(serverConfig: any): {
if (command === "npx" || command === "npm") {
// npx @mcpcentral/mcp-time
// npx -y @modelcontextprotocol/server-filesystem
// npx -y github:user/repo
const packageArg = args.find(
(arg: string) => arg.startsWith("@") || !arg.startsWith("-")
(arg: string) =>
arg.startsWith("@") || arg.startsWith("github:") || !arg.startsWith("-")
);
const packageName = packageArg || firstArg || "unknown-npm-package";
let packageName = packageArg || firstArg || "unknown-npm-package";
packageName = packageName.replace(/^-y\s*/, "").trim();
// Converter github:user/repo para git+https://github.com/user/repo.git
// Isso evita problemas com SSH e funciona melhor com npm install
if (packageName.startsWith("github:")) {
const repoPath = packageName.replace("github:", "");
packageName = `git+https://github.com/${repoPath}.git`;
console.log(`Converted github: format to: ${packageName}`);
}
return {
packageRegistry: "npm",
packageName: packageName.replace(/^-y\s*/, "").trim(),
packageName,
};
}
+37 -12
View File
@@ -18,7 +18,6 @@ interface MCPCardProps {
mcp: MCPProvider;
onInstall: () => void;
onUninstall: () => void;
onViewDetails: () => void;
mode?: "marketplace" | "installed";
onEditConfig?: () => void;
onTestServer?: () => void;
@@ -35,7 +34,6 @@ export const MCPCard = memo(
mcp,
onInstall,
onUninstall,
onViewDetails,
mode = "marketplace",
onEditConfig,
onTestServer,
@@ -145,6 +143,41 @@ export const MCPCard = memo(
)}
</div>
{/* Tools Badges */}
{mcp.tools && Array.isArray(mcp.tools) && mcp.tools.length > 0 && (
<div className="mb-3">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5">
<Zap className="h-4 w-4 text-purple-500 dark:text-purple-400" />
<span className="text-xs font-semibold text-purple-600 dark:text-purple-400">
{mcp.tools.length} {mcp.tools.length === 1 ? "Tool" : "Tools"}
</span>
</div>
{mcp.tools.length > 6 && (
<span className="text-[10px] text-gray-500 dark:text-gray-400">
Showing 6 of {mcp.tools.length}
</span>
)}
</div>
<div className="flex flex-wrap gap-1.5 max-h-[60px] overflow-hidden">
{mcp.tools.slice(0, 6).map((tool: any, index: number) => (
<span
key={index}
className="inline-flex items-center px-2.5 py-1 bg-gradient-to-r from-purple-50 to-purple-100 dark:from-purple-950/40 dark:to-purple-900/30 text-purple-700 dark:text-purple-300 text-[11px] font-medium rounded-md border border-purple-200/60 dark:border-purple-800/60 hover:border-purple-300 dark:hover:border-purple-700 transition-colors cursor-default shadow-sm"
title={tool.description || tool.name}
>
{tool.name}
</span>
))}
{mcp.tools.length > 6 && (
<span className="inline-flex items-center px-2.5 py-1 bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-900/40 dark:to-gray-800/30 text-gray-600 dark:text-gray-400 text-[11px] font-medium rounded-md border border-gray-200/60 dark:border-gray-700/60 shadow-sm">
+{mcp.tools.length - 6}
</span>
)}
</div>
</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">
@@ -168,14 +201,6 @@ export const MCPCard = memo(
{/* 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>
{mode === "installed" && onEditConfig ? (
<>
<button
@@ -210,14 +235,14 @@ export const MCPCard = memo(
) : 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"
className="w-full px-3 py-2 bg-red-500 text-white rounded-md hover:bg-red-600 transition-colors text-sm mcp-install-button font-medium"
>
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"
className="w-full 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 font-medium"
>
Install
</button>
+147 -42
View File
@@ -38,7 +38,6 @@ export function MCPMarketplaceModal({
"rating" | "name" | "recent" | "total_ratings" | "updated_at"
>("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);
@@ -170,6 +169,32 @@ export function MCPMarketplaceModal({
if (data.success) {
setInstalledMcps(data.mcps);
console.log("📦 Installed MCPs loaded:", data.mcps.length);
// Atualizar MCPs do marketplace para marcar como instalados
setMcps((prevMcps) =>
prevMcps.map((mcp) => {
const installedMatch = data.mcps.find(
(installed: any) =>
installed.id === mcp.id ||
installed.repo === mcp.repo ||
installed.name === mcp.name
);
if (installedMatch) {
return {
...mcp,
installed: true,
tools: installedMatch.tools || mcp.tools, // Atualizar tools também
};
}
return { ...mcp, installed: false };
})
);
console.log(
`📊 Marketplace updated: ${data.mcps.length} MCPs marked as installed`
);
} else {
console.error("Failed to fetch installed MCPs:", data.error);
}
@@ -385,6 +410,8 @@ export function MCPMarketplaceModal({
const data = await response.json();
if (data.success) {
console.log(`✅ Installation started for ${finalId}`);
// Adicionar ao tracking de instalação
setInstallingMcps((prev) => {
const next = new Map(prev);
@@ -396,20 +423,24 @@ export function MCPMarketplaceModal({
return next;
});
// Atualizar estado local
// Marcar como "instalando" no marketplace (não como instalado ainda)
// O polling vai atualizar para installed: true quando status === "ready"
setMcps((prev) =>
prev.map((mcp) =>
mcp.id === selectedConfigMCP.id
? { ...mcp, installed: true }
: mcp
)
prev.map((mcp) => {
// Verificar se é o mesmo MCP (por ID ou repo)
const isSameMcp =
mcp.id === selectedConfigMCP.id ||
mcp.id === finalId ||
mcp.repo === selectedConfigMCP.repo;
return isSameMcp ? { ...mcp, installed: false } : mcp;
})
);
// Fechar modal
setConfigModalOpen(false);
setSelectedConfigMCP(null);
setMcpConfig(null);
console.log(`✅ Installation started for ${finalId}`);
} else {
console.error("Error installing MCP:", data.error);
}
@@ -429,22 +460,36 @@ export function MCPMarketplaceModal({
const data = await response.json();
if (data.success) {
// Atualizar estado local
console.log(`✅ MCP ${mcpId} uninstalled successfully`);
// Remover do tracking de instalação (se estiver lá)
setInstallingMcps((prev) => {
const next = new Map(prev);
next.delete(mcpId);
return next;
});
// Atualizar marketplace: marcar como não instalado
setMcps((prev) =>
prev.map((mcp) =>
mcp.id === mcpId ? { ...mcp, installed: false } : mcp
mcp.id === mcpId || mcp.repo === mcpId
? { ...mcp, installed: false }
: mcp
)
);
// Recarregar lista de instalados para sincronizar
await fetchInstalledMCPs();
console.log(`📦 Lists updated after uninstalling ${mcpId}`);
} else {
console.error("Failed to uninstall MCP:", data.error);
}
} catch (error) {
console.error("Error uninstalling MCP:", error);
}
};
const handleViewDetails = (mcp: MCPProvider) => {
setSelectedMCP(mcp);
};
// Carregar MCPs e categorias quando o modal abrir
useEffect(() => {
if (isOpen) {
@@ -454,11 +499,51 @@ export function MCPMarketplaceModal({
}, [isOpen]); // Removido fetchMCPs das dependências
// Carregar MCPs instalados quando aba "installed" for ativada
// Carregar MCPs instalados sempre que o modal abrir (não apenas na aba instalados)
useEffect(() => {
if (isOpen && activeTab === "installed") {
if (isOpen) {
fetchInstalledMCPs();
// Limpar MCPs órfãos no tracking (que estão instalados mas ainda no Map)
// Isso previne polling infinito de MCPs que já completaram instalação
setTimeout(async () => {
if (installingMcps.size > 0) {
console.log(
`🔍 Checking for orphaned installations: ${installingMcps.size} tracked`
);
const installedResponse = await fetch("/api/mcp/installed");
const installedData = await installedResponse.json();
if (installedData.success) {
const installedIds = new Set(
installedData.mcps.map((m: any) => m.id)
);
setInstallingMcps((prev) => {
const next = new Map(prev);
let removed = 0;
for (const mcpId of prev.keys()) {
// Se já está instalado, remover do tracking
if (installedIds.has(mcpId)) {
next.delete(mcpId);
removed++;
console.log(`🧹 Removed orphaned tracking for ${mcpId}`);
}
}
if (removed > 0) {
console.log(`✅ Cleaned ${removed} orphaned installations`);
}
return next;
});
}
}
}, 1000); // Aguardar 1 segundo após abrir modal
}
}, [isOpen, activeTab]);
}, [isOpen, fetchInstalledMCPs]);
// Debounce para searchTerm
useEffect(() => {
@@ -503,6 +588,10 @@ export function MCPMarketplaceModal({
const isComplete = status === "ready" || status === "failed";
if (isComplete) {
console.log(
`🎉 Installation ${status} for ${mcpId}, updating lists...`
);
// Remover do tracking
setInstallingMcps((prev) => {
const next = new Map(prev);
@@ -510,11 +599,30 @@ export function MCPMarketplaceModal({
return next;
});
// Atualizar lista de MCPs
await fetchMCPs();
if (activeTab === "installed") {
await fetchInstalledMCPs();
// Se instalação foi bem-sucedida, marcar como instalado imediatamente
if (status === "ready") {
const toolsCount = data.validationResult?.tools?.length || 0;
console.log(
`✨ Marking ${mcpId} as installed with ${toolsCount} tools`
);
setMcps((prev) =>
prev.map((mcp) =>
mcp.id === mcpId || mcp.repo === mcpId || mcp.name === mcpId
? {
...mcp,
installed: true,
tools: data.validationResult?.tools || mcp.tools,
}
: mcp
)
);
}
// Recarregar lista de instalados sempre (não apenas na aba)
await fetchInstalledMCPs();
console.log(`✅ Lists synchronized after ${status} for ${mcpId}`);
} else {
// Atualizar progresso
setInstallingMcps((prev) => {
@@ -527,9 +635,26 @@ export function MCPMarketplaceModal({
return next;
});
}
} else {
// MCP não encontrado ou erro no servidor
console.warn(`⚠️ MCP ${mcpId} not found or error: ${data.error}`);
// Remover do tracking após erro (evita polling infinito)
setInstallingMcps((prev) => {
const next = new Map(prev);
next.delete(mcpId);
return next;
});
}
} catch (error) {
console.error(`Error polling status for ${mcpId}:`, error);
console.error(`❌ Error polling status for ${mcpId}:`, error);
// Remover do tracking após erro de rede (evita polling infinito)
setInstallingMcps((prev) => {
const next = new Map(prev);
next.delete(mcpId);
return next;
});
}
}
}, 2000); // Poll a cada 2 segundos
@@ -734,7 +859,6 @@ export function MCPMarketplaceModal({
mcp={mcp}
onInstall={() => handleInstall(mcp.id)}
onUninstall={() => handleUninstall(mcp.id)}
onViewDetails={() => handleViewDetails(mcp)}
installingStatus={installingMcps.get(mcp.id)}
/>
))}
@@ -787,25 +911,6 @@ export function MCPMarketplaceModal({
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)}
/>
))}
Binary file not shown.
Binary file not shown.
+2 -1
View File
@@ -170,11 +170,12 @@ export class MCPInstallerService {
});
// Construir ambiente
// Para NPM, executar npx do diretório onde foi instalado (não usa --prefix que não funciona)
const environment: MCPEnvironment = {
type: "npm",
path: npmDir,
executable: "npx",
args: ["--prefix", npmDir, packageName],
args: [packageName],
};
return environment;
+14
View File
@@ -32,11 +32,18 @@ export class MCPValidatorService {
...(config.env || {}),
};
// Para NPM, adicionar node_modules/.bin ao PATH para encontrar binários locais
if (environment.type === "npm" && environment.path) {
const nodeBinPath = `${environment.path}/node_modules/.bin`;
env.PATH = `${nodeBinPath}:${env.PATH || process.env.PATH}`;
}
console.log(`🧪 Testing MCP server: ${command} ${args.join(" ")}`);
try {
// Spawnar processo do servidor MCP
const serverProcess = spawn(command, args, {
cwd: environment.path, // Executar do diretório de instalação
env,
stdio: ["pipe", "pipe", "pipe"],
});
@@ -232,8 +239,15 @@ export class MCPValidatorService {
...(config.env || {}),
};
// Para NPM, adicionar node_modules/.bin ao PATH para encontrar binários locais
if (environment.type === "npm" && environment.path) {
const nodeBinPath = `${environment.path}/node_modules/.bin`;
env.PATH = `${nodeBinPath}:${env.PATH || process.env.PATH}`;
}
try {
const serverProcess = spawn(command, args, {
cwd: environment.path, // Executar do diretório de instalação
env,
stdio: ["pipe", "pipe", "pipe"],
});
+13 -2
View File
@@ -137,10 +137,21 @@ export async function spawnWithProgress(
/**
* Sanitiza nome de pacote para evitar command injection
* Suporta formatos: @scope/pkg, github:user/repo, git+https://..., etc.
*/
export function sanitizePackageName(packageName: string): string {
// Remove caracteres perigosos, mantém apenas alphanumeric, @, /, -, _
return packageName.replace(/[^a-zA-Z0-9@/\-_.]/g, "");
// URLs git+https:// devem ser preservadas completamente (são seguras)
if (
packageName.startsWith("git+https://") ||
packageName.startsWith("git+http://")
) {
// Apenas validar que parece uma URL válida
return packageName;
}
// Remove caracteres perigosos, mantém alphanumeric, @, :, +, /, -, _, .
// Suporta: @scope/pkg, github:user/repo
return packageName.replace(/[^a-zA-Z0-9@:+/\-_.]/g, "");
}
/**