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:
1 parent
f19bf10ef3
commit
745746afa9
8 files changed
+227
-60
No files matched your search
@@ -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
@@ -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>
|
||||
|
||||
@@ -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.
@@ -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;
|
||||
|
||||
@@ -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
@@ -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, "");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user