Add one-click installation dialog for servers and groups

Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2025-10-31 15:25:01 +00:00
parent a8852f7807
commit ee301a893f
7 changed files with 342 additions and 4 deletions

View File

@@ -1,10 +1,11 @@
import { useState, useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Group, Server, IGroupServerConfig } from '@/types'
import { Edit, Trash, Copy, Check, Link, FileCode, DropdownIcon, Wrench } from '@/components/icons/LucideIcons'
import { Edit, Trash, Copy, Check, Link, FileCode, DropdownIcon, Wrench, Download } from '@/components/icons/LucideIcons'
import DeleteDialog from '@/components/ui/DeleteDialog'
import { useToast } from '@/contexts/ToastContext'
import { useSettingsData } from '@/hooks/useSettingsData'
import InstallToClientDialog from '@/components/InstallToClientDialog'
interface GroupCardProps {
group: Group
@@ -26,6 +27,7 @@ const GroupCard = ({
const [copied, setCopied] = useState(false)
const [showCopyDropdown, setShowCopyDropdown] = useState(false)
const [expandedServer, setExpandedServer] = useState<string | null>(null)
const [showInstallDialog, setShowInstallDialog] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
// Close dropdown when clicking outside
@@ -50,6 +52,10 @@ const GroupCard = ({
setShowDeleteDialog(true)
}
const handleInstall = () => {
setShowInstallDialog(true)
}
const handleConfirmDelete = () => {
onDelete(group.id)
setShowDeleteDialog(false)
@@ -183,6 +189,13 @@ const GroupCard = ({
<div className="bg-blue-50 text-blue-700 px-3 py-1 rounded-full text-sm btn-secondary">
{t('groups.serverCount', { count: group.servers.length })}
</div>
<button
onClick={handleInstall}
className="text-purple-500 hover:text-purple-700"
title={t('install.installButton')}
>
<Download size={18} />
</button>
<button
onClick={handleEdit}
className="text-gray-500 hover:text-gray-700"
@@ -277,6 +290,20 @@ const GroupCard = ({
serverName={group.name}
isGroup={true}
/>
{showInstallDialog && installConfig && (
<InstallToClientDialog
groupId={group.id}
groupName={group.name}
config={{
type: 'streamable-http',
url: `${installConfig.protocol}://${installConfig.baseUrl}${installConfig.basePath}/mcp/${group.id}`,
headers: {
Authorization: `Bearer ${installConfig.token}`
}
}}
onClose={() => setShowInstallDialog(false)}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,219 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Copy, Check } from 'lucide-react';
interface InstallToClientDialogProps {
serverName?: string;
groupId?: string;
groupName?: string;
config: any;
onClose: () => void;
}
const InstallToClientDialog: React.FC<InstallToClientDialogProps> = ({
serverName,
groupId,
groupName,
config,
onClose,
}) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<'cursor' | 'claude-code' | 'claude-desktop'>('cursor');
const [copied, setCopied] = useState(false);
// Generate configuration based on the active tab
const generateConfig = () => {
if (groupId) {
// For groups, generate group-based configuration
return {
mcpServers: {
[`mcphub-${groupId}`]: config,
},
};
} else {
// For individual servers
return {
mcpServers: {
[serverName || 'mcp-server']: config,
},
};
}
};
const configJson = JSON.stringify(generateConfig(), null, 2);
const handleCopyConfig = () => {
navigator.clipboard.writeText(configJson).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
// Generate deep link for Cursor (if supported in the future)
const handleInstallToCursor = () => {
// For now, just copy the config since deep linking may not be widely supported
handleCopyConfig();
// In the future, this could be:
// const deepLink = `cursor://install-mcp?config=${encodeURIComponent(configJson)}`;
// window.open(deepLink, '_blank');
};
const getStepsList = () => {
const displayName = groupName || serverName || 'MCP server';
switch (activeTab) {
case 'cursor':
return [
t('install.step1Cursor'),
t('install.step2Cursor'),
t('install.step3Cursor'),
t('install.step4Cursor', { name: displayName }),
];
case 'claude-code':
return [
t('install.step1ClaudeCode'),
t('install.step2ClaudeCode'),
t('install.step3ClaudeCode'),
t('install.step4ClaudeCode', { name: displayName }),
];
case 'claude-desktop':
return [
t('install.step1ClaudeDesktop'),
t('install.step2ClaudeDesktop'),
t('install.step3ClaudeDesktop'),
t('install.step4ClaudeDesktop', { name: displayName }),
];
default:
return [];
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden">
<div className="flex justify-between items-center p-6 border-b">
<h2 className="text-2xl font-bold text-gray-900">
{groupId
? t('install.installGroupTitle', { name: groupName })
: t('install.installServerTitle', { name: serverName })}
</h2>
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700 transition-colors duration-200"
aria-label={t('common.close')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div className="overflow-y-auto max-h-[calc(90vh-140px)]">
{/* Tab Navigation */}
<div className="border-b border-gray-200 px-6 pt-4">
<nav className="-mb-px flex space-x-4">
<button
onClick={() => setActiveTab('cursor')}
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors duration-200 ${
activeTab === 'cursor'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Cursor
</button>
<button
onClick={() => setActiveTab('claude-code')}
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors duration-200 ${
activeTab === 'claude-code'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Claude Code
</button>
<button
onClick={() => setActiveTab('claude-desktop')}
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors duration-200 ${
activeTab === 'claude-desktop'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Claude Desktop
</button>
</nav>
</div>
{/* Configuration Display */}
<div className="p-6 space-y-6">
<div className="bg-gray-50 rounded-lg p-4">
<div className="flex justify-between items-center mb-2">
<h3 className="text-sm font-medium text-gray-700">{t('install.configCode')}</h3>
<button
onClick={handleCopyConfig}
className="flex items-center space-x-2 px-3 py-1.5 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 transition-colors duration-200 text-sm"
>
{copied ? <Check size={16} /> : <Copy size={16} />}
<span>{copied ? t('common.copied') : t('install.copyConfig')}</span>
</button>
</div>
<pre className="bg-white border border-gray-200 rounded p-4 text-xs overflow-x-auto">
<code>{configJson}</code>
</pre>
</div>
{/* Installation Steps */}
<div className="bg-blue-50 rounded-lg p-4">
<h3 className="text-sm font-semibold text-blue-900 mb-3">{t('install.steps')}</h3>
<ol className="space-y-3">
{getStepsList().map((step, index) => (
<li key={index} className="flex items-start space-x-3">
<span className="flex-shrink-0 w-6 h-6 bg-blue-500 text-white rounded-full flex items-center justify-center text-xs font-medium">
{index + 1}
</span>
<span className="text-sm text-blue-900 pt-0.5">{step}</span>
</li>
))}
</ol>
</div>
</div>
</div>
{/* Footer */}
<div className="flex justify-between items-center p-6 border-t bg-gray-50">
<button
onClick={onClose}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded hover:bg-gray-100 transition-colors duration-200"
>
{t('common.close')}
</button>
<button
onClick={handleInstallToCursor}
className="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors duration-200 flex items-center space-x-2"
>
<Copy size={16} />
<span>
{activeTab === 'cursor' && t('install.installToCursor', { name: groupName || serverName })}
{activeTab === 'claude-code' && t('install.installToClaudeCode', { name: groupName || serverName })}
{activeTab === 'claude-desktop' && t('install.installToClaudeDesktop', { name: groupName || serverName })}
</span>
</button>
</div>
</div>
</div>
);
};
export default InstallToClientDialog;

View File

@@ -1,13 +1,14 @@
import { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Server } from '@/types';
import { ChevronDown, ChevronRight, AlertCircle, Copy, Check } from 'lucide-react';
import { ChevronDown, ChevronRight, AlertCircle, Copy, Check, Download } from 'lucide-react';
import { StatusBadge } from '@/components/ui/Badge';
import ToolCard from '@/components/ui/ToolCard';
import PromptCard from '@/components/ui/PromptCard';
import DeleteDialog from '@/components/ui/DeleteDialog';
import { useToast } from '@/contexts/ToastContext';
import { useSettingsData } from '@/hooks/useSettingsData';
import InstallToClientDialog from '@/components/InstallToClientDialog';
interface ServerCardProps {
server: Server;
@@ -25,6 +26,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
const [isToggling, setIsToggling] = useState(false);
const [showErrorPopover, setShowErrorPopover] = useState(false);
const [copied, setCopied] = useState(false);
const [showInstallDialog, setShowInstallDialog] = useState(false);
const errorPopoverRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -52,6 +54,11 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
onEdit(server);
};
const handleInstall = (e: React.MouseEvent) => {
e.stopPropagation();
setShowInstallDialog(true);
};
const handleToggle = async (e: React.MouseEvent) => {
e.stopPropagation();
if (isToggling || !onToggle) return;
@@ -310,6 +317,13 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
<button onClick={handleCopyServerConfig} className={`px-3 py-1 btn-secondary`}>
{t('server.copy')}
</button>
<button
onClick={handleInstall}
className="px-3 py-1 bg-purple-100 text-purple-800 rounded hover:bg-purple-200 text-sm btn-primary flex items-center space-x-1"
>
<Download size={14} />
<span>{t('install.installButton')}</span>
</button>
<button
onClick={handleEdit}
className="px-3 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary"
@@ -398,6 +412,13 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
onConfirm={handleConfirmDelete}
serverName={server.name}
/>
{showInstallDialog && server.config && (
<InstallToClientDialog
serverName={server.name}
config={server.config}
onClose={() => setShowInstallDialog(false)}
/>
)}
</>
);
};

View File

@@ -17,7 +17,8 @@ import {
Link,
FileCode,
ChevronDown as DropdownIcon,
Wrench
Wrench,
Download
} from 'lucide-react'
export {
@@ -39,7 +40,8 @@ export {
Link,
FileCode,
DropdownIcon,
Wrench
Wrench,
Download
}
const LucideIcons = {

View File

@@ -751,5 +751,28 @@
"internalError": "Internal Error",
"internalErrorMessage": "An unexpected error occurred while processing the OAuth callback.",
"closeWindow": "Close Window"
},
"install": {
"installServerTitle": "Install Server to {{name}}",
"installGroupTitle": "Install Group {{name}}",
"configCode": "Configuration Code",
"copyConfig": "Copy Configuration",
"steps": "Installation Steps",
"step1Cursor": "Copy the configuration code above",
"step2Cursor": "Open Cursor, go to Settings > Features > MCP",
"step3Cursor": "Click 'Add New MCP Server' to add a new server",
"step4Cursor": "Paste the configuration in the appropriate location and restart Cursor",
"step1ClaudeCode": "Copy the configuration code above",
"step2ClaudeCode": "Open Claude Code, go to Settings > Features > MCP",
"step3ClaudeCode": "Click 'Add New MCP Server' to add a new server",
"step4ClaudeCode": "Paste the configuration in the appropriate location and restart Claude Code",
"step1ClaudeDesktop": "Copy the configuration code above",
"step2ClaudeDesktop": "Open Claude Desktop, go to Settings > Developer",
"step3ClaudeDesktop": "Click 'Edit Config' to edit the configuration file",
"step4ClaudeDesktop": "Paste the configuration in the mcpServers section and restart Claude Desktop",
"installToCursor": "Add {{name}} MCP server to Cursor",
"installToClaudeCode": "Add {{name}} MCP server to Claude Code",
"installToClaudeDesktop": "Add {{name}} MCP server to Claude Desktop",
"installButton": "Install"
}
}

View File

@@ -751,5 +751,28 @@
"internalError": "Erreur interne",
"internalErrorMessage": "Une erreur inattendue s'est produite lors du traitement du callback OAuth.",
"closeWindow": "Fermer la fenêtre"
},
"install": {
"installServerTitle": "Installer le serveur sur {{name}}",
"installGroupTitle": "Installer le groupe {{name}}",
"configCode": "Code de configuration",
"copyConfig": "Copier la configuration",
"steps": "Étapes d'installation",
"step1Cursor": "Copiez le code de configuration ci-dessus",
"step2Cursor": "Ouvrez Cursor, allez dans Paramètres > Features > MCP",
"step3Cursor": "Cliquez sur 'Add New MCP Server' pour ajouter un nouveau serveur",
"step4Cursor": "Collez la configuration à l'emplacement approprié et redémarrez Cursor",
"step1ClaudeCode": "Copiez le code de configuration ci-dessus",
"step2ClaudeCode": "Ouvrez Claude Code, allez dans Paramètres > Features > MCP",
"step3ClaudeCode": "Cliquez sur 'Add New MCP Server' pour ajouter un nouveau serveur",
"step4ClaudeCode": "Collez la configuration à l'emplacement approprié et redémarrez Claude Code",
"step1ClaudeDesktop": "Copiez le code de configuration ci-dessus",
"step2ClaudeDesktop": "Ouvrez Claude Desktop, allez dans Paramètres > Développeur",
"step3ClaudeDesktop": "Cliquez sur 'Edit Config' pour modifier le fichier de configuration",
"step4ClaudeDesktop": "Collez la configuration dans la section mcpServers et redémarrez Claude Desktop",
"installToCursor": "Ajouter le serveur MCP {{name}} à Cursor",
"installToClaudeCode": "Ajouter le serveur MCP {{name}} à Claude Code",
"installToClaudeDesktop": "Ajouter le serveur MCP {{name}} à Claude Desktop",
"installButton": "Installer"
}
}

View File

@@ -753,5 +753,28 @@
"internalError": "内部错误",
"internalErrorMessage": "处理 OAuth 回调时发生意外错误。",
"closeWindow": "关闭窗口"
},
"install": {
"installServerTitle": "安装服务器到 {{name}}",
"installGroupTitle": "安装分组 {{name}}",
"configCode": "配置代码",
"copyConfig": "复制配置",
"steps": "安装步骤",
"step1Cursor": "复制上面的配置代码",
"step2Cursor": "打开 Cursor进入设置 > Features > MCP",
"step3Cursor": "点击 'Add New MCP Server' 添加新服务器",
"step4Cursor": "将配置粘贴到相应位置并重启 Cursor",
"step1ClaudeCode": "复制上面的配置代码",
"step2ClaudeCode": "打开 Claude Code进入设置 > Features > MCP",
"step3ClaudeCode": "点击 'Add New MCP Server' 添加新服务器",
"step4ClaudeCode": "将配置粘贴到相应位置并重启 Claude Code",
"step1ClaudeDesktop": "复制上面的配置代码",
"step2ClaudeDesktop": "打开 Claude Desktop进入设置 > Developer",
"step3ClaudeDesktop": "点击 'Edit Config' 编辑配置文件",
"step4ClaudeDesktop": "将配置粘贴到 mcpServers 部分并重启 Claude Desktop",
"installToCursor": "添加 {{name}} MCP 服务器到 Cursor",
"installToClaudeCode": "添加 {{name}} MCP 服务器到 Claude Code",
"installToClaudeDesktop": "添加 {{name}} MCP 服务器到 Claude Desktop",
"installButton": "安装"
}
}