feat: Add MCP settings export and copy functionality (#367)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
Co-authored-by: samanhappy <samanhappy@gmail.com>
This commit is contained in:
Copilot
2025-10-13 19:39:01 +08:00
committed by GitHub
parent f53c4a0e3b
commit 4d736c543d
11 changed files with 650 additions and 168 deletions

View File

@@ -1,7 +1,7 @@
{ {
"semi": true, "semi": false,
"trailingComma": "all", "trailingComma": "all",
"singleQuote": true, "singleQuote": true,
"printWidth": 100, "printWidth": 100,
"tabWidth": 2 "tabWidth": 2
} }

View File

@@ -7,6 +7,7 @@ import ToolCard from '@/components/ui/ToolCard'
import PromptCard from '@/components/ui/PromptCard' import PromptCard from '@/components/ui/PromptCard'
import DeleteDialog from '@/components/ui/DeleteDialog' import DeleteDialog from '@/components/ui/DeleteDialog'
import { useToast } from '@/contexts/ToastContext' import { useToast } from '@/contexts/ToastContext'
import { useSettingsData } from '@/hooks/useSettingsData'
interface ServerCardProps { interface ServerCardProps {
server: Server server: Server
@@ -39,6 +40,8 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
} }
}, []) }, [])
const { exportMCPSettings } = useSettingsData()
const handleRemove = (e: React.MouseEvent) => { const handleRemove = (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
setShowDeleteDialog(true) setShowDeleteDialog(true)
@@ -99,6 +102,39 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
} }
} }
const handleCopyServerConfig = async (e: React.MouseEvent) => {
e.stopPropagation()
try {
const result = await exportMCPSettings(server.name)
const configJson = JSON.stringify(result.data, null, 2)
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(configJson)
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
} else {
// Fallback for HTTP or unsupported clipboard API
const textArea = document.createElement('textarea')
textArea.value = configJson
textArea.style.position = 'fixed'
textArea.style.left = '-9999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
try {
document.execCommand('copy')
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
} catch (err) {
showToast(t('common.copyFailed') || 'Copy failed', 'error')
console.error('Copy to clipboard failed:', err)
}
document.body.removeChild(textArea)
}
} catch (error) {
console.error('Error copying server configuration:', error)
showToast(t('common.copyFailed') || 'Copy failed', 'error')
}
}
const handleConfirmDelete = () => { const handleConfirmDelete = () => {
onRemove(server.name) onRemove(server.name)
setShowDeleteDialog(false) setShowDeleteDialog(false)
@@ -111,7 +147,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
if (result.success) { if (result.success) {
showToast( showToast(
t(enabled ? 'tool.enableSuccess' : 'tool.disableSuccess', { name: toolName }), t(enabled ? 'tool.enableSuccess' : 'tool.disableSuccess', { name: toolName }),
'success' 'success',
) )
// Trigger refresh to update the tool's state in the UI // Trigger refresh to update the tool's state in the UI
if (onRefresh) { if (onRefresh) {
@@ -133,7 +169,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
if (result.success) { if (result.success) {
showToast( showToast(
t(enabled ? 'tool.enableSuccess' : 'tool.disableSuccess', { name: promptName }), t(enabled ? 'tool.enableSuccess' : 'tool.disableSuccess', { name: promptName }),
'success' 'success',
) )
// Trigger refresh to update the prompt's state in the UI // Trigger refresh to update the prompt's state in the UI
if (onRefresh) { if (onRefresh) {
@@ -150,21 +186,33 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
return ( return (
<> <>
<div className={`bg-white shadow rounded-lg p-6 mb-6 page-card transition-all duration-200 ${server.enabled === false ? 'opacity-60' : ''}`}> <div
className={`bg-white shadow rounded-lg p-6 mb-6 page-card transition-all duration-200 ${server.enabled === false ? 'opacity-60' : ''}`}
>
<div <div
className="flex justify-between items-center cursor-pointer" className="flex justify-between items-center cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)} onClick={() => setIsExpanded(!isExpanded)}
> >
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<h2 className={`text-xl font-semibold ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'}`}>{server.name}</h2> <h2
className={`text-xl font-semibold ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'}`}
>
{server.name}
</h2>
<StatusBadge status={server.status} /> <StatusBadge status={server.status} />
{/* Tool count display */} {/* Tool count display */}
<div className="flex items-center px-2 py-1 bg-blue-50 text-blue-700 rounded-full text-sm btn-primary"> <div className="flex items-center px-2 py-1 bg-blue-50 text-blue-700 rounded-full text-sm btn-primary">
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" /> <path
fillRule="evenodd"
d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z"
clipRule="evenodd"
/>
</svg> </svg>
<span>{server.tools?.length || 0} {t('server.tools')}</span> <span>
{server.tools?.length || 0} {t('server.tools')}
</span>
</div> </div>
{/* Prompt count display */} {/* Prompt count display */}
@@ -173,7 +221,9 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
<path d="M2 5a2 2 0 012-2h7a2 2 0 012 2v4a2 2 0 01-2 2H9l-3 3v-3H4a2 2 0 01-2-2V5z" /> <path d="M2 5a2 2 0 012-2h7a2 2 0 012 2v4a2 2 0 01-2 2H9l-3 3v-3H4a2 2 0 01-2-2V5z" />
<path d="M15 7v2a4 4 0 01-4 4H9.828l-1.766 1.767c.28.149.599.233.938.233h2l3 3v-3h2a2 2 0 002-2V9a2 2 0 00-2-2h-1z" /> <path d="M15 7v2a4 4 0 01-4 4H9.828l-1.766 1.767c.28.149.599.233.938.233h2l3 3v-3h2a2 2 0 002-2V9a2 2 0 00-2-2h-1z" />
</svg> </svg>
<span>{server.prompts?.length || 0} {t('server.prompts')}</span> <span>
{server.prompts?.length || 0} {t('server.prompts')}
</span>
</div> </div>
{server.error && ( {server.error && (
@@ -196,19 +246,25 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
maxHeight: '300px', maxHeight: '300px',
overflowY: 'auto', overflowY: 'auto',
width: '480px', width: '480px',
transform: 'translateX(50%)' transform: 'translateX(50%)',
}} }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="flex justify-between items-center sticky top-0 bg-white py-2 px-4 border-b border-gray-200 z-20 shadow-sm"> <div className="flex justify-between items-center sticky top-0 bg-white py-2 px-4 border-b border-gray-200 z-20 shadow-sm">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<h4 className="text-sm font-medium text-red-600">{t('server.errorDetails')}</h4> <h4 className="text-sm font-medium text-red-600">
{t('server.errorDetails')}
</h4>
<button <button
onClick={copyToClipboard} onClick={copyToClipboard}
className="p-1 text-gray-400 hover:text-gray-600 transition-colors btn-secondary" className="p-1 text-gray-400 hover:text-gray-600 transition-colors btn-secondary"
title={t('common.copy')} title={t('common.copy')}
> >
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />} {copied ? (
<Check size={14} className="text-green-500" />
) : (
<Copy size={14} />
)}
</button> </button>
</div> </div>
<button <button
@@ -222,7 +278,9 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
</button> </button>
</div> </div>
<div className="p-4 pt-2"> <div className="p-4 pt-2">
<pre className="text-sm text-gray-700 break-words whitespace-pre-wrap">{server.error}</pre> <pre className="text-sm text-gray-700 break-words whitespace-pre-wrap">
{server.error}
</pre>
</div> </div>
</div> </div>
)} )}
@@ -230,6 +288,9 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
)} )}
</div> </div>
<div className="flex space-x-2"> <div className="flex space-x-2">
<button onClick={handleCopyServerConfig} className={`px-3 py-1 btn-secondary`}>
{t('server.copy')}
</button>
<button <button
onClick={handleEdit} onClick={handleEdit}
className="px-3 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary" className="px-3 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary"
@@ -239,20 +300,20 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
<div className="flex items-center"> <div className="flex items-center">
<button <button
onClick={handleToggle} onClick={handleToggle}
className={`px-3 py-1 text-sm rounded transition-colors ${isToggling className={`px-3 py-1 text-sm rounded transition-colors ${
? 'bg-gray-200 text-gray-500' isToggling
: server.enabled !== false ? 'bg-gray-200 text-gray-500'
? 'bg-green-100 text-green-800 hover:bg-green-200 btn-secondary' : server.enabled !== false
: 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-primary' ? 'bg-green-100 text-green-800 hover:bg-green-200 btn-secondary'
}`} : 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-primary'
}`}
disabled={isToggling} disabled={isToggling}
> >
{isToggling {isToggling
? t('common.processing') ? t('common.processing')
: server.enabled !== false : server.enabled !== false
? t('server.disable') ? t('server.disable')
: t('server.enable') : t('server.enable')}
}
</button> </button>
</div> </div>
<button <button
@@ -271,10 +332,19 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
<> <>
{server.tools && ( {server.tools && (
<div className="mt-6"> <div className="mt-6">
<h6 className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}>{t('server.tools')}</h6> <h6
className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}
>
{t('server.tools')}
</h6>
<div className="space-y-4"> <div className="space-y-4">
{server.tools.map((tool, index) => ( {server.tools.map((tool, index) => (
<ToolCard key={index} server={server.name} tool={tool} onToggle={handleToolToggle} /> <ToolCard
key={index}
server={server.name}
tool={tool}
onToggle={handleToolToggle}
/>
))} ))}
</div> </div>
</div> </div>
@@ -282,14 +352,18 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
{server.prompts && ( {server.prompts && (
<div className="mt-6"> <div className="mt-6">
<h6 className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}>{t('server.prompts')}</h6> <h6
className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}
>
{t('server.prompts')}
</h6>
<div className="space-y-4"> <div className="space-y-4">
{server.prompts.map((prompt, index) => ( {server.prompts.map((prompt, index) => (
<PromptCard <PromptCard
key={index} key={index}
server={server.name} server={server.name}
prompt={prompt} prompt={prompt}
onToggle={handlePromptToggle} onToggle={handlePromptToggle}
/> />
))} ))}
</div> </div>
@@ -309,4 +383,4 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
) )
} }
export default ServerCard export default ServerCard

View File

@@ -4,6 +4,7 @@ export const PERMISSIONS = {
SETTINGS_SMART_ROUTING: 'settings:smart_routing', SETTINGS_SMART_ROUTING: 'settings:smart_routing',
SETTINGS_SKIP_AUTH: 'settings:skip_auth', SETTINGS_SKIP_AUTH: 'settings:skip_auth',
SETTINGS_INSTALL_CONFIG: 'settings:install_config', SETTINGS_INSTALL_CONFIG: 'settings:install_config',
SETTINGS_EXPORT_CONFIG: 'settings:export_config',
} as const; } as const;
export default PERMISSIONS; export default PERMISSIONS;

View File

@@ -420,6 +420,21 @@ export const useSettingsData = () => {
} }
}; };
const exportMCPSettings = async (serverName?: string) => {
setLoading(true);
setError(null);
try {
return await apiGet(`/mcp-settings/export?serverName=${serverName ? serverName : ''}`);
} catch (error) {
console.error('Failed to export MCP settings:', error);
const errorMessage = error instanceof Error ? error.message : 'Failed to export MCP settings';
setError(errorMessage);
showToast(errorMessage);
} finally {
setLoading(false);
}
};
// Fetch settings when the component mounts or refreshKey changes // Fetch settings when the component mounts or refreshKey changes
useEffect(() => { useEffect(() => {
fetchSettings(); fetchSettings();
@@ -454,5 +469,6 @@ export const useSettingsData = () => {
updateMCPRouterConfig, updateMCPRouterConfig,
updateMCPRouterConfigBatch, updateMCPRouterConfigBatch,
updateNameSeparator, updateNameSeparator,
exportMCPSettings,
}; };
}; };

View File

@@ -1,54 +1,55 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom'
import ChangePasswordForm from '@/components/ChangePasswordForm'; import ChangePasswordForm from '@/components/ChangePasswordForm'
import { Switch } from '@/components/ui/ToggleGroup'; import { Switch } from '@/components/ui/ToggleGroup'
import { useSettingsData } from '@/hooks/useSettingsData'; import { useSettingsData } from '@/hooks/useSettingsData'
import { useToast } from '@/contexts/ToastContext'; import { useToast } from '@/contexts/ToastContext'
import { generateRandomKey } from '@/utils/key'; import { generateRandomKey } from '@/utils/key'
import { PermissionChecker } from '@/components/PermissionChecker'; import { PermissionChecker } from '@/components/PermissionChecker'
import { PERMISSIONS } from '@/constants/permissions'; import { PERMISSIONS } from '@/constants/permissions'
import { Copy, Check, Download } from 'lucide-react'
const SettingsPage: React.FC = () => { const SettingsPage: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation()
const navigate = useNavigate(); const navigate = useNavigate()
const { showToast } = useToast(); const { showToast } = useToast()
const [installConfig, setInstallConfig] = useState<{ const [installConfig, setInstallConfig] = useState<{
pythonIndexUrl: string; pythonIndexUrl: string
npmRegistry: string; npmRegistry: string
baseUrl: string; baseUrl: string
}>({ }>({
pythonIndexUrl: '', pythonIndexUrl: '',
npmRegistry: '', npmRegistry: '',
baseUrl: 'http://localhost:3000', baseUrl: 'http://localhost:3000',
}); })
const [tempSmartRoutingConfig, setTempSmartRoutingConfig] = useState<{ const [tempSmartRoutingConfig, setTempSmartRoutingConfig] = useState<{
dbUrl: string; dbUrl: string
openaiApiBaseUrl: string; openaiApiBaseUrl: string
openaiApiKey: string; openaiApiKey: string
openaiApiEmbeddingModel: string; openaiApiEmbeddingModel: string
}>({ }>({
dbUrl: '', dbUrl: '',
openaiApiBaseUrl: '', openaiApiBaseUrl: '',
openaiApiKey: '', openaiApiKey: '',
openaiApiEmbeddingModel: '', openaiApiEmbeddingModel: '',
}); })
const [tempMCPRouterConfig, setTempMCPRouterConfig] = useState<{ const [tempMCPRouterConfig, setTempMCPRouterConfig] = useState<{
apiKey: string; apiKey: string
referer: string; referer: string
title: string; title: string
baseUrl: string; baseUrl: string
}>({ }>({
apiKey: '', apiKey: '',
referer: 'https://www.mcphubx.com', referer: 'https://www.mcphubx.com',
title: 'MCPHub', title: 'MCPHub',
baseUrl: 'https://api.mcprouter.to/v1', baseUrl: 'https://api.mcprouter.to/v1',
}); })
const [tempNameSeparator, setTempNameSeparator] = useState<string>('-'); const [tempNameSeparator, setTempNameSeparator] = useState<string>('-')
const { const {
routingConfig, routingConfig,
@@ -66,14 +67,15 @@ const SettingsPage: React.FC = () => {
updateSmartRoutingConfigBatch, updateSmartRoutingConfigBatch,
updateMCPRouterConfig, updateMCPRouterConfig,
updateNameSeparator, updateNameSeparator,
} = useSettingsData(); exportMCPSettings,
} = useSettingsData()
// Update local installConfig when savedInstallConfig changes // Update local installConfig when savedInstallConfig changes
useEffect(() => { useEffect(() => {
if (savedInstallConfig) { if (savedInstallConfig) {
setInstallConfig(savedInstallConfig); setInstallConfig(savedInstallConfig)
} }
}, [savedInstallConfig]); }, [savedInstallConfig])
// Update local tempSmartRoutingConfig when smartRoutingConfig changes // Update local tempSmartRoutingConfig when smartRoutingConfig changes
useEffect(() => { useEffect(() => {
@@ -83,9 +85,9 @@ const SettingsPage: React.FC = () => {
openaiApiBaseUrl: smartRoutingConfig.openaiApiBaseUrl || '', openaiApiBaseUrl: smartRoutingConfig.openaiApiBaseUrl || '',
openaiApiKey: smartRoutingConfig.openaiApiKey || '', openaiApiKey: smartRoutingConfig.openaiApiKey || '',
openaiApiEmbeddingModel: smartRoutingConfig.openaiApiEmbeddingModel || '', openaiApiEmbeddingModel: smartRoutingConfig.openaiApiEmbeddingModel || '',
}); })
} }
}, [smartRoutingConfig]); }, [smartRoutingConfig])
// Update local tempMCPRouterConfig when mcpRouterConfig changes // Update local tempMCPRouterConfig when mcpRouterConfig changes
useEffect(() => { useEffect(() => {
@@ -95,14 +97,14 @@ const SettingsPage: React.FC = () => {
referer: mcpRouterConfig.referer || 'https://www.mcphubx.com', referer: mcpRouterConfig.referer || 'https://www.mcphubx.com',
title: mcpRouterConfig.title || 'MCPHub', title: mcpRouterConfig.title || 'MCPHub',
baseUrl: mcpRouterConfig.baseUrl || 'https://api.mcprouter.to/v1', baseUrl: mcpRouterConfig.baseUrl || 'https://api.mcprouter.to/v1',
}); })
} }
}, [mcpRouterConfig]); }, [mcpRouterConfig])
// Update local tempNameSeparator when nameSeparator changes // Update local tempNameSeparator when nameSeparator changes
useEffect(() => { useEffect(() => {
setTempNameSeparator(nameSeparator); setTempNameSeparator(nameSeparator)
}, [nameSeparator]); }, [nameSeparator])
const [sectionsVisible, setSectionsVisible] = useState({ const [sectionsVisible, setSectionsVisible] = useState({
routingConfig: false, routingConfig: false,
@@ -110,138 +112,244 @@ const SettingsPage: React.FC = () => {
smartRoutingConfig: false, smartRoutingConfig: false,
mcpRouterConfig: false, mcpRouterConfig: false,
nameSeparator: false, nameSeparator: false,
password: false password: false,
}); exportConfig: false,
})
const toggleSection = (section: 'routingConfig' | 'installConfig' | 'smartRoutingConfig' | 'mcpRouterConfig' | 'nameSeparator' | 'password') => { const toggleSection = (
setSectionsVisible(prev => ({ section:
| 'routingConfig'
| 'installConfig'
| 'smartRoutingConfig'
| 'mcpRouterConfig'
| 'nameSeparator'
| 'password'
| 'exportConfig',
) => {
setSectionsVisible((prev) => ({
...prev, ...prev,
[section]: !prev[section] [section]: !prev[section],
})); }))
}; }
const handleRoutingConfigChange = async (key: 'enableGlobalRoute' | 'enableGroupNameRoute' | 'enableBearerAuth' | 'bearerAuthKey' | 'skipAuth', value: boolean | string) => { const handleRoutingConfigChange = async (
key:
| 'enableGlobalRoute'
| 'enableGroupNameRoute'
| 'enableBearerAuth'
| 'bearerAuthKey'
| 'skipAuth',
value: boolean | string,
) => {
// If enableBearerAuth is turned on and there's no key, generate one first // If enableBearerAuth is turned on and there's no key, generate one first
if (key === 'enableBearerAuth' && value === true) { if (key === 'enableBearerAuth' && value === true) {
if (!tempRoutingConfig.bearerAuthKey && !routingConfig.bearerAuthKey) { if (!tempRoutingConfig.bearerAuthKey && !routingConfig.bearerAuthKey) {
const newKey = generateRandomKey(); const newKey = generateRandomKey()
handleBearerAuthKeyChange(newKey); handleBearerAuthKeyChange(newKey)
// Update both enableBearerAuth and bearerAuthKey in a single call // Update both enableBearerAuth and bearerAuthKey in a single call
const success = await updateRoutingConfigBatch({ const success = await updateRoutingConfigBatch({
enableBearerAuth: true, enableBearerAuth: true,
bearerAuthKey: newKey bearerAuthKey: newKey,
}); })
if (success) { if (success) {
// Update tempRoutingConfig to reflect the saved values // Update tempRoutingConfig to reflect the saved values
setTempRoutingConfig(prev => ({ setTempRoutingConfig((prev) => ({
...prev, ...prev,
bearerAuthKey: newKey bearerAuthKey: newKey,
})); }))
} }
return; return
} }
} }
await updateRoutingConfig(key, value); await updateRoutingConfig(key, value)
}; }
const handleBearerAuthKeyChange = (value: string) => { const handleBearerAuthKeyChange = (value: string) => {
setTempRoutingConfig(prev => ({ setTempRoutingConfig((prev) => ({
...prev, ...prev,
bearerAuthKey: value bearerAuthKey: value,
})); }))
}; }
const saveBearerAuthKey = async () => { const saveBearerAuthKey = async () => {
await updateRoutingConfig('bearerAuthKey', tempRoutingConfig.bearerAuthKey); await updateRoutingConfig('bearerAuthKey', tempRoutingConfig.bearerAuthKey)
}; }
const handleInstallConfigChange = (key: 'pythonIndexUrl' | 'npmRegistry' | 'baseUrl', value: string) => { const handleInstallConfigChange = (
key: 'pythonIndexUrl' | 'npmRegistry' | 'baseUrl',
value: string,
) => {
setInstallConfig({ setInstallConfig({
...installConfig, ...installConfig,
[key]: value [key]: value,
}); })
}; }
const saveInstallConfig = async (key: 'pythonIndexUrl' | 'npmRegistry' | 'baseUrl') => { const saveInstallConfig = async (key: 'pythonIndexUrl' | 'npmRegistry' | 'baseUrl') => {
await updateInstallConfig(key, installConfig[key]); await updateInstallConfig(key, installConfig[key])
}; }
const handleSmartRoutingConfigChange = (key: 'dbUrl' | 'openaiApiBaseUrl' | 'openaiApiKey' | 'openaiApiEmbeddingModel', value: string) => { const handleSmartRoutingConfigChange = (
key: 'dbUrl' | 'openaiApiBaseUrl' | 'openaiApiKey' | 'openaiApiEmbeddingModel',
value: string,
) => {
setTempSmartRoutingConfig({ setTempSmartRoutingConfig({
...tempSmartRoutingConfig, ...tempSmartRoutingConfig,
[key]: value [key]: value,
}); })
}; }
const saveSmartRoutingConfig = async (key: 'dbUrl' | 'openaiApiBaseUrl' | 'openaiApiKey' | 'openaiApiEmbeddingModel') => { const saveSmartRoutingConfig = async (
await updateSmartRoutingConfig(key, tempSmartRoutingConfig[key]); key: 'dbUrl' | 'openaiApiBaseUrl' | 'openaiApiKey' | 'openaiApiEmbeddingModel',
}; ) => {
await updateSmartRoutingConfig(key, tempSmartRoutingConfig[key])
}
const handleMCPRouterConfigChange = (key: 'apiKey' | 'referer' | 'title' | 'baseUrl', value: string) => { const handleMCPRouterConfigChange = (
key: 'apiKey' | 'referer' | 'title' | 'baseUrl',
value: string,
) => {
setTempMCPRouterConfig({ setTempMCPRouterConfig({
...tempMCPRouterConfig, ...tempMCPRouterConfig,
[key]: value [key]: value,
}); })
}; }
const saveMCPRouterConfig = async (key: 'apiKey' | 'referer' | 'title' | 'baseUrl') => { const saveMCPRouterConfig = async (key: 'apiKey' | 'referer' | 'title' | 'baseUrl') => {
await updateMCPRouterConfig(key, tempMCPRouterConfig[key]); await updateMCPRouterConfig(key, tempMCPRouterConfig[key])
}; }
const saveNameSeparator = async () => { const saveNameSeparator = async () => {
await updateNameSeparator(tempNameSeparator); await updateNameSeparator(tempNameSeparator)
}; }
const handleSmartRoutingEnabledChange = async (value: boolean) => { const handleSmartRoutingEnabledChange = async (value: boolean) => {
// If enabling Smart Routing, validate required fields and save any unsaved changes // If enabling Smart Routing, validate required fields and save any unsaved changes
if (value) { if (value) {
const currentDbUrl = tempSmartRoutingConfig.dbUrl || smartRoutingConfig.dbUrl; const currentDbUrl = tempSmartRoutingConfig.dbUrl || smartRoutingConfig.dbUrl
const currentOpenaiApiKey = tempSmartRoutingConfig.openaiApiKey || smartRoutingConfig.openaiApiKey; const currentOpenaiApiKey =
tempSmartRoutingConfig.openaiApiKey || smartRoutingConfig.openaiApiKey
if (!currentDbUrl || !currentOpenaiApiKey) { if (!currentDbUrl || !currentOpenaiApiKey) {
const missingFields = []; const missingFields = []
if (!currentDbUrl) missingFields.push(t('settings.dbUrl')); if (!currentDbUrl) missingFields.push(t('settings.dbUrl'))
if (!currentOpenaiApiKey) missingFields.push(t('settings.openaiApiKey')); if (!currentOpenaiApiKey) missingFields.push(t('settings.openaiApiKey'))
showToast(t('settings.smartRoutingValidationError', { showToast(
fields: missingFields.join(', ') t('settings.smartRoutingValidationError', {
})); fields: missingFields.join(', '),
return; }),
)
return
} }
// Prepare updates object with unsaved changes and enabled status // Prepare updates object with unsaved changes and enabled status
const updates: any = { enabled: value }; const updates: any = { enabled: value }
// Check for unsaved changes and include them in the batch update // Check for unsaved changes and include them in the batch update
if (tempSmartRoutingConfig.dbUrl !== smartRoutingConfig.dbUrl) { if (tempSmartRoutingConfig.dbUrl !== smartRoutingConfig.dbUrl) {
updates.dbUrl = tempSmartRoutingConfig.dbUrl; updates.dbUrl = tempSmartRoutingConfig.dbUrl
} }
if (tempSmartRoutingConfig.openaiApiBaseUrl !== smartRoutingConfig.openaiApiBaseUrl) { if (tempSmartRoutingConfig.openaiApiBaseUrl !== smartRoutingConfig.openaiApiBaseUrl) {
updates.openaiApiBaseUrl = tempSmartRoutingConfig.openaiApiBaseUrl; updates.openaiApiBaseUrl = tempSmartRoutingConfig.openaiApiBaseUrl
} }
if (tempSmartRoutingConfig.openaiApiKey !== smartRoutingConfig.openaiApiKey) { if (tempSmartRoutingConfig.openaiApiKey !== smartRoutingConfig.openaiApiKey) {
updates.openaiApiKey = tempSmartRoutingConfig.openaiApiKey; updates.openaiApiKey = tempSmartRoutingConfig.openaiApiKey
} }
if (tempSmartRoutingConfig.openaiApiEmbeddingModel !== smartRoutingConfig.openaiApiEmbeddingModel) { if (
updates.openaiApiEmbeddingModel = tempSmartRoutingConfig.openaiApiEmbeddingModel; tempSmartRoutingConfig.openaiApiEmbeddingModel !==
smartRoutingConfig.openaiApiEmbeddingModel
) {
updates.openaiApiEmbeddingModel = tempSmartRoutingConfig.openaiApiEmbeddingModel
} }
// Save all changes in a single batch update // Save all changes in a single batch update
await updateSmartRoutingConfigBatch(updates); await updateSmartRoutingConfigBatch(updates)
} else { } else {
// If disabling, just update the enabled status // If disabling, just update the enabled status
await updateSmartRoutingConfig('enabled', value); await updateSmartRoutingConfig('enabled', value)
} }
}; }
const handlePasswordChangeSuccess = () => { const handlePasswordChangeSuccess = () => {
setTimeout(() => { setTimeout(() => {
navigate('/'); navigate('/')
}, 2000); }, 2000)
}; }
const [copiedConfig, setCopiedConfig] = useState(false)
const [mcpSettingsJson, setMcpSettingsJson] = useState<string>('')
const fetchMcpSettings = async () => {
try {
const result = await exportMCPSettings()
console.log('Fetched MCP settings:', result)
const configJson = JSON.stringify(result, null, 2)
setMcpSettingsJson(configJson)
} catch (error) {
console.error('Error fetching MCP settings:', error)
showToast(t('settings.exportError') || 'Failed to fetch settings', 'error')
}
}
useEffect(() => {
if (sectionsVisible.exportConfig && !mcpSettingsJson) {
fetchMcpSettings()
}
}, [sectionsVisible.exportConfig])
const handleCopyConfig = async () => {
if (!mcpSettingsJson) return
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(mcpSettingsJson)
setCopiedConfig(true)
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
setTimeout(() => setCopiedConfig(false), 2000)
} else {
// Fallback for HTTP or unsupported clipboard API
const textArea = document.createElement('textarea')
textArea.value = mcpSettingsJson
textArea.style.position = 'fixed'
textArea.style.left = '-9999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
try {
document.execCommand('copy')
setCopiedConfig(true)
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
setTimeout(() => setCopiedConfig(false), 2000)
} catch (err) {
showToast(t('common.copyFailed') || 'Copy failed', 'error')
console.error('Copy to clipboard failed:', err)
}
document.body.removeChild(textArea)
}
} catch (error) {
console.error('Error copying configuration:', error)
showToast(t('common.copyFailed') || 'Copy failed', 'error')
}
}
const handleDownloadConfig = () => {
if (!mcpSettingsJson) return
const blob = new Blob([mcpSettingsJson], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = 'mcp_settings.json'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
showToast(t('settings.exportSuccess') || 'Settings exported successfully', 'success')
}
return ( return (
<div className="container mx-auto"> <div className="container mx-auto">
@@ -265,7 +373,9 @@ const SettingsPage: React.FC = () => {
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md"> <div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
<div> <div>
<h3 className="font-medium text-gray-700">{t('settings.enableSmartRouting')}</h3> <h3 className="font-medium text-gray-700">{t('settings.enableSmartRouting')}</h3>
<p className="text-sm text-gray-500">{t('settings.enableSmartRoutingDescription')}</p> <p className="text-sm text-gray-500">
{t('settings.enableSmartRoutingDescription')}
</p>
</div> </div>
<Switch <Switch
disabled={loading} disabled={loading}
@@ -277,7 +387,8 @@ const SettingsPage: React.FC = () => {
<div className="p-3 bg-gray-50 rounded-md"> <div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2"> <div className="mb-2">
<h3 className="font-medium text-gray-700"> <h3 className="font-medium text-gray-700">
<span className="text-red-500 px-1">*</span>{t('settings.dbUrl')} <span className="text-red-500 px-1">*</span>
{t('settings.dbUrl')}
</h3> </h3>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -302,7 +413,8 @@ const SettingsPage: React.FC = () => {
<div className="p-3 bg-gray-50 rounded-md"> <div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2"> <div className="mb-2">
<h3 className="font-medium text-gray-700"> <h3 className="font-medium text-gray-700">
<span className="text-red-500 px-1">*</span>{t('settings.openaiApiKey')} <span className="text-red-500 px-1">*</span>
{t('settings.openaiApiKey')}
</h3> </h3>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -332,7 +444,9 @@ const SettingsPage: React.FC = () => {
<input <input
type="text" type="text"
value={tempSmartRoutingConfig.openaiApiBaseUrl} value={tempSmartRoutingConfig.openaiApiBaseUrl}
onChange={(e) => handleSmartRoutingConfigChange('openaiApiBaseUrl', e.target.value)} onChange={(e) =>
handleSmartRoutingConfigChange('openaiApiBaseUrl', e.target.value)
}
placeholder={t('settings.openaiApiBaseUrlPlaceholder')} placeholder={t('settings.openaiApiBaseUrlPlaceholder')}
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input" className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
disabled={loading} disabled={loading}
@@ -349,13 +463,17 @@ const SettingsPage: React.FC = () => {
<div className="p-3 bg-gray-50 rounded-md"> <div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2"> <div className="mb-2">
<h3 className="font-medium text-gray-700">{t('settings.openaiApiEmbeddingModel')}</h3> <h3 className="font-medium text-gray-700">
{t('settings.openaiApiEmbeddingModel')}
</h3>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<input <input
type="text" type="text"
value={tempSmartRoutingConfig.openaiApiEmbeddingModel} value={tempSmartRoutingConfig.openaiApiEmbeddingModel}
onChange={(e) => handleSmartRoutingConfigChange('openaiApiEmbeddingModel', e.target.value)} onChange={(e) =>
handleSmartRoutingConfigChange('openaiApiEmbeddingModel', e.target.value)
}
placeholder={t('settings.openaiApiEmbeddingModelPlaceholder')} placeholder={t('settings.openaiApiEmbeddingModelPlaceholder')}
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input" className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
disabled={loading} disabled={loading}
@@ -392,7 +510,9 @@ const SettingsPage: React.FC = () => {
<div className="p-3 bg-gray-50 rounded-md"> <div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2"> <div className="mb-2">
<h3 className="font-medium text-gray-700">{t('settings.mcpRouterApiKey')}</h3> <h3 className="font-medium text-gray-700">{t('settings.mcpRouterApiKey')}</h3>
<p className="text-sm text-gray-500">{t('settings.mcpRouterApiKeyDescription')}</p> <p className="text-sm text-gray-500">
{t('settings.mcpRouterApiKeyDescription')}
</p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<input <input
@@ -416,7 +536,9 @@ const SettingsPage: React.FC = () => {
<div className="p-3 bg-gray-50 rounded-md"> <div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2"> <div className="mb-2">
<h3 className="font-medium text-gray-700">{t('settings.mcpRouterBaseUrl')}</h3> <h3 className="font-medium text-gray-700">{t('settings.mcpRouterBaseUrl')}</h3>
<p className="text-sm text-gray-500">{t('settings.mcpRouterBaseUrlDescription')}</p> <p className="text-sm text-gray-500">
{t('settings.mcpRouterBaseUrlDescription')}
</p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<input <input
@@ -448,9 +570,7 @@ const SettingsPage: React.FC = () => {
onClick={() => toggleSection('nameSeparator')} onClick={() => toggleSection('nameSeparator')}
> >
<h2 className="font-semibold text-gray-800">{t('settings.systemSettings')}</h2> <h2 className="font-semibold text-gray-800">{t('settings.systemSettings')}</h2>
<span className="text-gray-500"> <span className="text-gray-500">{sectionsVisible.nameSeparator ? '▼' : '►'}</span>
{sectionsVisible.nameSeparator ? '▼' : '►'}
</span>
</div> </div>
{sectionsVisible.nameSeparator && ( {sectionsVisible.nameSeparator && (
@@ -490,9 +610,7 @@ const SettingsPage: React.FC = () => {
onClick={() => toggleSection('routingConfig')} onClick={() => toggleSection('routingConfig')}
> >
<h2 className="font-semibold text-gray-800">{t('pages.settings.routeConfig')}</h2> <h2 className="font-semibold text-gray-800">{t('pages.settings.routeConfig')}</h2>
<span className="text-gray-500"> <span className="text-gray-500">{sectionsVisible.routingConfig ? '▼' : '►'}</span>
{sectionsVisible.routingConfig ? '▼' : '►'}
</span>
</div> </div>
{sectionsVisible.routingConfig && ( {sectionsVisible.routingConfig && (
@@ -505,7 +623,9 @@ const SettingsPage: React.FC = () => {
<Switch <Switch
disabled={loading} disabled={loading}
checked={routingConfig.enableBearerAuth} checked={routingConfig.enableBearerAuth}
onCheckedChange={(checked) => handleRoutingConfigChange('enableBearerAuth', checked)} onCheckedChange={(checked) =>
handleRoutingConfigChange('enableBearerAuth', checked)
}
/> />
</div> </div>
@@ -538,24 +658,32 @@ const SettingsPage: React.FC = () => {
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md"> <div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
<div> <div>
<h3 className="font-medium text-gray-700">{t('settings.enableGlobalRoute')}</h3> <h3 className="font-medium text-gray-700">{t('settings.enableGlobalRoute')}</h3>
<p className="text-sm text-gray-500">{t('settings.enableGlobalRouteDescription')}</p> <p className="text-sm text-gray-500">
{t('settings.enableGlobalRouteDescription')}
</p>
</div> </div>
<Switch <Switch
disabled={loading} disabled={loading}
checked={routingConfig.enableGlobalRoute} checked={routingConfig.enableGlobalRoute}
onCheckedChange={(checked) => handleRoutingConfigChange('enableGlobalRoute', checked)} onCheckedChange={(checked) =>
handleRoutingConfigChange('enableGlobalRoute', checked)
}
/> />
</div> </div>
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md"> <div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
<div> <div>
<h3 className="font-medium text-gray-700">{t('settings.enableGroupNameRoute')}</h3> <h3 className="font-medium text-gray-700">{t('settings.enableGroupNameRoute')}</h3>
<p className="text-sm text-gray-500">{t('settings.enableGroupNameRouteDescription')}</p> <p className="text-sm text-gray-500">
{t('settings.enableGroupNameRouteDescription')}
</p>
</div> </div>
<Switch <Switch
disabled={loading} disabled={loading}
checked={routingConfig.enableGroupNameRoute} checked={routingConfig.enableGroupNameRoute}
onCheckedChange={(checked) => handleRoutingConfigChange('enableGroupNameRoute', checked)} onCheckedChange={(checked) =>
handleRoutingConfigChange('enableGroupNameRoute', checked)
}
/> />
</div> </div>
@@ -572,7 +700,6 @@ const SettingsPage: React.FC = () => {
/> />
</div> </div>
</PermissionChecker> </PermissionChecker>
</div> </div>
)} )}
</div> </div>
@@ -585,9 +712,7 @@ const SettingsPage: React.FC = () => {
onClick={() => toggleSection('installConfig')} onClick={() => toggleSection('installConfig')}
> >
<h2 className="font-semibold text-gray-800">{t('settings.installConfig')}</h2> <h2 className="font-semibold text-gray-800">{t('settings.installConfig')}</h2>
<span className="text-gray-500"> <span className="text-gray-500">{sectionsVisible.installConfig ? '▼' : '►'}</span>
{sectionsVisible.installConfig ? '▼' : '►'}
</span>
</div> </div>
{sectionsVisible.installConfig && ( {sectionsVisible.installConfig && (
@@ -675,9 +800,7 @@ const SettingsPage: React.FC = () => {
onClick={() => toggleSection('password')} onClick={() => toggleSection('password')}
> >
<h2 className="font-semibold text-gray-800">{t('auth.changePassword')}</h2> <h2 className="font-semibold text-gray-800">{t('auth.changePassword')}</h2>
<span className="text-gray-500"> <span className="text-gray-500">{sectionsVisible.password ? '▼' : '►'}</span>
{sectionsVisible.password ? '▼' : '►'}
</span>
</div> </div>
{sectionsVisible.password && ( {sectionsVisible.password && (
@@ -686,8 +809,61 @@ const SettingsPage: React.FC = () => {
</div> </div>
)} )}
</div> </div>
</div >
);
};
export default SettingsPage; {/* Export MCP Settings */}
<PermissionChecker permissions={PERMISSIONS.SETTINGS_EXPORT_CONFIG}>
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card">
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => toggleSection('exportConfig')}
>
<h2 className="font-semibold text-gray-800">{t('settings.exportMcpSettings')}</h2>
<span className="text-gray-500">{sectionsVisible.exportConfig ? '▼' : '►'}</span>
</div>
{sectionsVisible.exportConfig && (
<div className="space-y-4 mt-4">
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-4">
<h3 className="font-medium text-gray-700">{t('settings.mcpSettingsJson')}</h3>
<p className="text-sm text-gray-500">
{t('settings.mcpSettingsJsonDescription')}
</p>
</div>
<div className="space-y-3">
<div className="flex items-center gap-3">
<button
onClick={handleCopyConfig}
disabled={!mcpSettingsJson}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
>
{copiedConfig ? <Check size={16} /> : <Copy size={16} />}
{copiedConfig ? t('common.copied') : t('settings.copyToClipboard')}
</button>
<button
onClick={handleDownloadConfig}
disabled={!mcpSettingsJson}
className="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
>
<Download size={16} />
{t('settings.downloadJson')}
</button>
</div>
{mcpSettingsJson && (
<div className="mt-3">
<pre className="bg-gray-900 text-gray-100 p-4 rounded-md overflow-x-auto text-xs max-h-96">
{mcpSettingsJson}
</pre>
</div>
)}
</div>
</div>
</div>
)}
</div>
</PermissionChecker>
</div>
)
}
export default SettingsPage

View File

@@ -75,6 +75,7 @@
"addServer": "Add Server", "addServer": "Add Server",
"add": "Add", "add": "Add",
"edit": "Edit", "edit": "Edit",
"copy": "Copy",
"delete": "Delete", "delete": "Delete",
"confirmDelete": "Are you sure you want to delete this server?", "confirmDelete": "Are you sure you want to delete this server?",
"deleteWarning": "Deleting server '{{name}}' will remove it and all its data. This action cannot be undone.", "deleteWarning": "Deleting server '{{name}}' will remove it and all its data. This action cannot be undone.",
@@ -124,6 +125,7 @@
"argumentsPlaceholder": "Enter arguments", "argumentsPlaceholder": "Enter arguments",
"errorDetails": "Error Details", "errorDetails": "Error Details",
"viewErrorDetails": "View error details", "viewErrorDetails": "View error details",
"copyConfig": "Copy Configuration",
"confirmVariables": "Confirm Variable Configuration", "confirmVariables": "Confirm Variable Configuration",
"variablesDetected": "Variables detected in configuration. Please confirm these variables are properly configured:", "variablesDetected": "Variables detected in configuration. Please confirm these variables are properly configured:",
"detectedVariables": "Detected Variables", "detectedVariables": "Detected Variables",
@@ -200,6 +202,7 @@
"copyJson": "Copy JSON", "copyJson": "Copy JSON",
"copySuccess": "Copied to clipboard", "copySuccess": "Copied to clipboard",
"copyFailed": "Copy failed", "copyFailed": "Copy failed",
"copied": "Copied",
"close": "Close", "close": "Close",
"confirm": "Confirm", "confirm": "Confirm",
"language": "Language", "language": "Language",
@@ -502,7 +505,14 @@
"systemSettings": "System Settings", "systemSettings": "System Settings",
"nameSeparatorLabel": "Name Separator", "nameSeparatorLabel": "Name Separator",
"nameSeparatorDescription": "Character used to separate server name and tool/prompt name (default: -)", "nameSeparatorDescription": "Character used to separate server name and tool/prompt name (default: -)",
"restartRequired": "Configuration saved. It is recommended to restart the application to ensure all services load the new settings correctly." "restartRequired": "Configuration saved. It is recommended to restart the application to ensure all services load the new settings correctly.",
"exportMcpSettings": "Export Settings",
"mcpSettingsJson": "MCP Settings JSON",
"mcpSettingsJsonDescription": "View, copy, or download your current mcp_settings.json configuration for backup or migration to other tools",
"copyToClipboard": "Copy to Clipboard",
"downloadJson": "Download JSON",
"exportSuccess": "Settings exported successfully",
"exportError": "Failed to fetch settings"
}, },
"dxt": { "dxt": {
"upload": "Upload", "upload": "Upload",

View File

@@ -75,6 +75,7 @@
"addServer": "Ajouter un serveur", "addServer": "Ajouter un serveur",
"add": "Ajouter", "add": "Ajouter",
"edit": "Modifier", "edit": "Modifier",
"copy": "Copier",
"delete": "Supprimer", "delete": "Supprimer",
"confirmDelete": "Êtes-vous sûr de vouloir supprimer ce serveur ?", "confirmDelete": "Êtes-vous sûr de vouloir supprimer ce serveur ?",
"deleteWarning": "La suppression du serveur '{{name}}' le supprimera ainsi que toutes ses données. Cette action est irréversible.", "deleteWarning": "La suppression du serveur '{{name}}' le supprimera ainsi que toutes ses données. Cette action est irréversible.",
@@ -124,6 +125,7 @@
"argumentsPlaceholder": "Entrez les arguments", "argumentsPlaceholder": "Entrez les arguments",
"errorDetails": "Détails de l'erreur", "errorDetails": "Détails de l'erreur",
"viewErrorDetails": "Voir les détails de l'erreur", "viewErrorDetails": "Voir les détails de l'erreur",
"copyConfig": "Copier la configuration",
"confirmVariables": "Confirmer la configuration des variables", "confirmVariables": "Confirmer la configuration des variables",
"variablesDetected": "Variables détectées dans la configuration. Veuillez confirmer que ces variables sont correctement configurées :", "variablesDetected": "Variables détectées dans la configuration. Veuillez confirmer que ces variables sont correctement configurées :",
"detectedVariables": "Variables détectées", "detectedVariables": "Variables détectées",
@@ -200,6 +202,7 @@
"copyJson": "Copier le JSON", "copyJson": "Copier le JSON",
"copySuccess": "Copié dans le presse-papiers", "copySuccess": "Copié dans le presse-papiers",
"copyFailed": "Échec de la copie", "copyFailed": "Échec de la copie",
"copied": "Copié",
"close": "Fermer", "close": "Fermer",
"confirm": "Confirmer", "confirm": "Confirmer",
"language": "Langue", "language": "Langue",
@@ -502,7 +505,14 @@
"systemSettings": "Paramètres système", "systemSettings": "Paramètres système",
"nameSeparatorLabel": "Séparateur de noms", "nameSeparatorLabel": "Séparateur de noms",
"nameSeparatorDescription": "Caractère utilisé pour séparer le nom du serveur et le nom de l'outil/prompt (par défaut : -)", "nameSeparatorDescription": "Caractère utilisé pour séparer le nom du serveur et le nom de l'outil/prompt (par défaut : -)",
"restartRequired": "Configuration enregistrée. Il est recommandé de redémarrer l'application pour s'assurer que tous les services chargent correctement les nouveaux paramètres." "restartRequired": "Configuration enregistrée. Il est recommandé de redémarrer l'application pour s'assurer que tous les services chargent correctement les nouveaux paramètres.",
"exportMcpSettings": "Exporter les paramètres",
"mcpSettingsJson": "JSON des paramètres MCP",
"mcpSettingsJsonDescription": "Afficher, copier ou télécharger votre configuration mcp_settings.json actuelle pour la sauvegarde ou la migration vers d'autres outils",
"copyToClipboard": "Copier dans le presse-papiers",
"downloadJson": "Télécharger JSON",
"exportSuccess": "Paramètres exportés avec succès",
"exportError": "Échec de la récupération des paramètres"
}, },
"dxt": { "dxt": {
"upload": "Télécharger", "upload": "Télécharger",

View File

@@ -75,6 +75,7 @@
"addServer": "添加服务器", "addServer": "添加服务器",
"add": "添加", "add": "添加",
"edit": "编辑", "edit": "编辑",
"copy": "复制",
"delete": "删除", "delete": "删除",
"confirmDelete": "您确定要删除此服务器吗?", "confirmDelete": "您确定要删除此服务器吗?",
"deleteWarning": "删除服务器 '{{name}}' 将会移除该服务器及其所有数据。此操作无法撤销。", "deleteWarning": "删除服务器 '{{name}}' 将会移除该服务器及其所有数据。此操作无法撤销。",
@@ -124,6 +125,7 @@
"argumentsPlaceholder": "请输入参数", "argumentsPlaceholder": "请输入参数",
"errorDetails": "错误详情", "errorDetails": "错误详情",
"viewErrorDetails": "查看错误详情", "viewErrorDetails": "查看错误详情",
"copyConfig": "复制配置",
"confirmVariables": "确认变量配置", "confirmVariables": "确认变量配置",
"variablesDetected": "检测到配置中包含变量,请确认这些变量是否已正确配置:", "variablesDetected": "检测到配置中包含变量,请确认这些变量是否已正确配置:",
"detectedVariables": "检测到的变量", "detectedVariables": "检测到的变量",
@@ -201,6 +203,7 @@
"copyJson": "复制JSON", "copyJson": "复制JSON",
"copySuccess": "已复制到剪贴板", "copySuccess": "已复制到剪贴板",
"copyFailed": "复制失败", "copyFailed": "复制失败",
"copied": "已复制",
"close": "关闭", "close": "关闭",
"confirm": "确认", "confirm": "确认",
"language": "语言", "language": "语言",
@@ -504,7 +507,14 @@
"systemSettings": "系统设置", "systemSettings": "系统设置",
"nameSeparatorLabel": "名称分隔符", "nameSeparatorLabel": "名称分隔符",
"nameSeparatorDescription": "用于分隔服务器名称和工具/提示名称(默认:-", "nameSeparatorDescription": "用于分隔服务器名称和工具/提示名称(默认:-",
"restartRequired": "配置已保存。为确保所有服务正确加载新设置,建议重启应用。" "restartRequired": "配置已保存。为确保所有服务正确加载新设置,建议重启应用。",
"exportMcpSettings": "导出配置",
"mcpSettingsJson": "MCP 配置 JSON",
"mcpSettingsJsonDescription": "查看、复制或下载当前的 mcp_settings.json 配置,可用于备份或迁移到其他工具",
"copyToClipboard": "复制到剪贴板",
"downloadJson": "下载 JSON",
"exportSuccess": "配置导出成功",
"exportError": "获取配置失败"
}, },
"dxt": { "dxt": {
"upload": "上传", "upload": "上传",

View File

@@ -1,6 +1,6 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import config from '../config/index.js'; import config from '../config/index.js';
import { loadSettings } from '../config/index.js'; import { loadSettings, loadOriginalSettings } from '../config/index.js';
import { getDataService } from '../services/services.js'; import { getDataService } from '../services/services.js';
import { DataService } from '../services/dataService.js'; import { DataService } from '../services/dataService.js';
import { IUser } from '../types/index.js'; import { IUser } from '../types/index.js';
@@ -72,3 +72,46 @@ export const getPublicConfig = (req: Request, res: Response): void => {
}); });
} }
}; };
/**
* Get MCP settings in JSON format for export/copy
* Supports both full settings and individual server configuration
*/
export const getMcpSettingsJson = (req: Request, res: Response): void => {
try {
const { serverName } = req.query;
const settings = loadOriginalSettings();
if (serverName && typeof serverName === 'string') {
// Return individual server configuration
const serverConfig = settings.mcpServers[serverName];
if (!serverConfig) {
res.status(404).json({
success: false,
message: `Server '${serverName}' not found`,
});
return;
}
res.json({
success: true,
data: {
mcpServers: {
[serverName]: serverConfig,
},
},
});
} else {
// Return full settings
res.json({
success: true,
data: settings,
});
}
} catch (error) {
console.error('Error getting MCP settings JSON:', error);
res.status(500).json({
success: false,
message: 'Failed to get MCP settings',
});
}
};

View File

@@ -58,7 +58,7 @@ import {
} from '../controllers/cloudController.js'; } from '../controllers/cloudController.js';
import { login, register, getCurrentUser, changePassword } from '../controllers/authController.js'; import { login, register, getCurrentUser, changePassword } from '../controllers/authController.js';
import { getAllLogs, clearLogs, streamLogs } from '../controllers/logController.js'; import { getAllLogs, clearLogs, streamLogs } from '../controllers/logController.js';
import { getRuntimeConfig, getPublicConfig } from '../controllers/configController.js'; import { getRuntimeConfig, getPublicConfig, getMcpSettingsJson } from '../controllers/configController.js';
import { callTool } from '../controllers/toolController.js'; import { callTool } from '../controllers/toolController.js';
import { getPrompt } from '../controllers/promptController.js'; import { getPrompt } from '../controllers/promptController.js';
import { uploadDxtFile, uploadMiddleware } from '../controllers/dxtController.js'; import { uploadDxtFile, uploadMiddleware } from '../controllers/dxtController.js';
@@ -149,6 +149,9 @@ export const initRoutes = (app: express.Application): void => {
router.delete('/logs', clearLogs); router.delete('/logs', clearLogs);
router.get('/logs/stream', streamLogs); router.get('/logs/stream', streamLogs);
// MCP settings export route
router.get('/mcp-settings/export', getMcpSettingsJson);
// Auth routes - move to router instead of app directly // Auth routes - move to router instead of app directly
router.post( router.post(
'/auth/login', '/auth/login',

View File

@@ -0,0 +1,139 @@
import { getMcpSettingsJson } from '../../src/controllers/configController.js'
import * as config from '../../src/config/index.js'
import { Request, Response } from 'express'
// Mock the config module
jest.mock('../../src/config/index.js')
describe('ConfigController - getMcpSettingsJson', () => {
let mockRequest: Partial<Request>
let mockResponse: Partial<Response>
let mockJson: jest.Mock
let mockStatus: jest.Mock
beforeEach(() => {
mockJson = jest.fn()
mockStatus = jest.fn().mockReturnThis()
mockRequest = {
query: {},
}
mockResponse = {
json: mockJson,
status: mockStatus,
}
// Reset mocks
jest.clearAllMocks()
})
describe('Full Settings Export', () => {
it('should handle settings without users array', () => {
const mockSettings = {
mcpServers: {
'test-server': {
command: 'test',
args: ['--test'],
},
},
}
;(config.loadOriginalSettings as jest.Mock).mockReturnValue(mockSettings)
getMcpSettingsJson(mockRequest as Request, mockResponse as Response)
expect(mockJson).toHaveBeenCalledWith({
success: true,
data: {
mcpServers: mockSettings.mcpServers,
users: undefined,
},
})
})
})
describe('Individual Server Export', () => {
it('should return individual server configuration when serverName is specified', () => {
const mockSettings = {
mcpServers: {
'test-server': {
command: 'test',
args: ['--test'],
env: {
TEST_VAR: 'test-value',
},
},
'another-server': {
command: 'another',
args: ['--another'],
},
},
users: [
{
username: 'admin',
password: '$2b$10$hashedpassword',
isAdmin: true,
},
],
}
mockRequest.query = { serverName: 'test-server' }
;(config.loadOriginalSettings as jest.Mock).mockReturnValue(mockSettings)
getMcpSettingsJson(mockRequest as Request, mockResponse as Response)
expect(mockJson).toHaveBeenCalledWith({
success: true,
data: {
mcpServers: {
'test-server': {
command: 'test',
args: ['--test'],
env: {
TEST_VAR: 'test-value',
},
},
},
},
})
})
it('should return 404 when server does not exist', () => {
const mockSettings = {
mcpServers: {
'test-server': {
command: 'test',
args: ['--test'],
},
},
}
mockRequest.query = { serverName: 'non-existent-server' }
;(config.loadOriginalSettings as jest.Mock).mockReturnValue(mockSettings)
getMcpSettingsJson(mockRequest as Request, mockResponse as Response)
expect(mockStatus).toHaveBeenCalledWith(404)
expect(mockJson).toHaveBeenCalledWith({
success: false,
message: "Server 'non-existent-server' not found",
})
})
})
describe('Error Handling', () => {
it('should handle errors gracefully and return 500', () => {
const errorMessage = 'Failed to load settings'
;(config.loadOriginalSettings as jest.Mock).mockImplementation(() => {
throw new Error(errorMessage)
})
getMcpSettingsJson(mockRequest as Request, mockResponse as Response)
expect(mockStatus).toHaveBeenCalledWith(500)
expect(mockJson).toHaveBeenCalledWith({
success: false,
message: 'Failed to get MCP settings',
})
})
})
})