mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-23 18:29:21 -05:00
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:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"semi": true,
|
||||
"semi": false,
|
||||
"trailingComma": "all",
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ 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'
|
||||
|
||||
interface ServerCardProps {
|
||||
server: Server
|
||||
@@ -39,6 +40,8 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
}
|
||||
}, [])
|
||||
|
||||
const { exportMCPSettings } = useSettingsData()
|
||||
|
||||
const handleRemove = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
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 = () => {
|
||||
onRemove(server.name)
|
||||
setShowDeleteDialog(false)
|
||||
@@ -111,7 +147,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
if (result.success) {
|
||||
showToast(
|
||||
t(enabled ? 'tool.enableSuccess' : 'tool.disableSuccess', { name: toolName }),
|
||||
'success'
|
||||
'success',
|
||||
)
|
||||
// Trigger refresh to update the tool's state in the UI
|
||||
if (onRefresh) {
|
||||
@@ -133,7 +169,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
if (result.success) {
|
||||
showToast(
|
||||
t(enabled ? 'tool.enableSuccess' : 'tool.disableSuccess', { name: promptName }),
|
||||
'success'
|
||||
'success',
|
||||
)
|
||||
// Trigger refresh to update the prompt's state in the UI
|
||||
if (onRefresh) {
|
||||
@@ -150,21 +186,33 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
|
||||
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
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<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} />
|
||||
|
||||
{/* Tool count display */}
|
||||
<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">
|
||||
<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>
|
||||
<span>{server.tools?.length || 0} {t('server.tools')}</span>
|
||||
<span>
|
||||
{server.tools?.length || 0} {t('server.tools')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 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="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>
|
||||
<span>{server.prompts?.length || 0} {t('server.prompts')}</span>
|
||||
<span>
|
||||
{server.prompts?.length || 0} {t('server.prompts')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{server.error && (
|
||||
@@ -196,19 +246,25 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto',
|
||||
width: '480px',
|
||||
transform: 'translateX(50%)'
|
||||
transform: 'translateX(50%)',
|
||||
}}
|
||||
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 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
|
||||
onClick={copyToClipboard}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 transition-colors btn-secondary"
|
||||
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>
|
||||
</div>
|
||||
<button
|
||||
@@ -222,7 +278,9 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
)}
|
||||
@@ -230,6 +288,9 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
)}
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button onClick={handleCopyServerConfig} className={`px-3 py-1 btn-secondary`}>
|
||||
{t('server.copy')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleEdit}
|
||||
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">
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
className={`px-3 py-1 text-sm rounded transition-colors ${isToggling
|
||||
? 'bg-gray-200 text-gray-500'
|
||||
: server.enabled !== false
|
||||
? 'bg-green-100 text-green-800 hover:bg-green-200 btn-secondary'
|
||||
: 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-primary'
|
||||
}`}
|
||||
className={`px-3 py-1 text-sm rounded transition-colors ${
|
||||
isToggling
|
||||
? 'bg-gray-200 text-gray-500'
|
||||
: server.enabled !== false
|
||||
? '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}
|
||||
>
|
||||
{isToggling
|
||||
? t('common.processing')
|
||||
: server.enabled !== false
|
||||
? t('server.disable')
|
||||
: t('server.enable')
|
||||
}
|
||||
: t('server.enable')}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@@ -271,10 +332,19 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
<>
|
||||
{server.tools && (
|
||||
<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">
|
||||
{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>
|
||||
@@ -282,14 +352,18 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
|
||||
{server.prompts && (
|
||||
<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">
|
||||
{server.prompts.map((prompt, index) => (
|
||||
<PromptCard
|
||||
key={index}
|
||||
server={server.name}
|
||||
prompt={prompt}
|
||||
onToggle={handlePromptToggle}
|
||||
<PromptCard
|
||||
key={index}
|
||||
server={server.name}
|
||||
prompt={prompt}
|
||||
onToggle={handlePromptToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -309,4 +383,4 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
)
|
||||
}
|
||||
|
||||
export default ServerCard
|
||||
export default ServerCard
|
||||
|
||||
@@ -4,6 +4,7 @@ export const PERMISSIONS = {
|
||||
SETTINGS_SMART_ROUTING: 'settings:smart_routing',
|
||||
SETTINGS_SKIP_AUTH: 'settings:skip_auth',
|
||||
SETTINGS_INSTALL_CONFIG: 'settings:install_config',
|
||||
SETTINGS_EXPORT_CONFIG: 'settings:export_config',
|
||||
} as const;
|
||||
|
||||
export default PERMISSIONS;
|
||||
|
||||
@@ -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
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
@@ -454,5 +469,6 @@ export const useSettingsData = () => {
|
||||
updateMCPRouterConfig,
|
||||
updateMCPRouterConfigBatch,
|
||||
updateNameSeparator,
|
||||
exportMCPSettings,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,54 +1,55 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import ChangePasswordForm from '@/components/ChangePasswordForm';
|
||||
import { Switch } from '@/components/ui/ToggleGroup';
|
||||
import { useSettingsData } from '@/hooks/useSettingsData';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
import { generateRandomKey } from '@/utils/key';
|
||||
import { PermissionChecker } from '@/components/PermissionChecker';
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import ChangePasswordForm from '@/components/ChangePasswordForm'
|
||||
import { Switch } from '@/components/ui/ToggleGroup'
|
||||
import { useSettingsData } from '@/hooks/useSettingsData'
|
||||
import { useToast } from '@/contexts/ToastContext'
|
||||
import { generateRandomKey } from '@/utils/key'
|
||||
import { PermissionChecker } from '@/components/PermissionChecker'
|
||||
import { PERMISSIONS } from '@/constants/permissions'
|
||||
import { Copy, Check, Download } from 'lucide-react'
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
|
||||
const [installConfig, setInstallConfig] = useState<{
|
||||
pythonIndexUrl: string;
|
||||
npmRegistry: string;
|
||||
baseUrl: string;
|
||||
pythonIndexUrl: string
|
||||
npmRegistry: string
|
||||
baseUrl: string
|
||||
}>({
|
||||
pythonIndexUrl: '',
|
||||
npmRegistry: '',
|
||||
baseUrl: 'http://localhost:3000',
|
||||
});
|
||||
})
|
||||
|
||||
const [tempSmartRoutingConfig, setTempSmartRoutingConfig] = useState<{
|
||||
dbUrl: string;
|
||||
openaiApiBaseUrl: string;
|
||||
openaiApiKey: string;
|
||||
openaiApiEmbeddingModel: string;
|
||||
dbUrl: string
|
||||
openaiApiBaseUrl: string
|
||||
openaiApiKey: string
|
||||
openaiApiEmbeddingModel: string
|
||||
}>({
|
||||
dbUrl: '',
|
||||
openaiApiBaseUrl: '',
|
||||
openaiApiKey: '',
|
||||
openaiApiEmbeddingModel: '',
|
||||
});
|
||||
})
|
||||
|
||||
const [tempMCPRouterConfig, setTempMCPRouterConfig] = useState<{
|
||||
apiKey: string;
|
||||
referer: string;
|
||||
title: string;
|
||||
baseUrl: string;
|
||||
apiKey: string
|
||||
referer: string
|
||||
title: string
|
||||
baseUrl: string
|
||||
}>({
|
||||
apiKey: '',
|
||||
referer: 'https://www.mcphubx.com',
|
||||
title: 'MCPHub',
|
||||
baseUrl: 'https://api.mcprouter.to/v1',
|
||||
});
|
||||
})
|
||||
|
||||
const [tempNameSeparator, setTempNameSeparator] = useState<string>('-');
|
||||
const [tempNameSeparator, setTempNameSeparator] = useState<string>('-')
|
||||
|
||||
const {
|
||||
routingConfig,
|
||||
@@ -66,14 +67,15 @@ const SettingsPage: React.FC = () => {
|
||||
updateSmartRoutingConfigBatch,
|
||||
updateMCPRouterConfig,
|
||||
updateNameSeparator,
|
||||
} = useSettingsData();
|
||||
exportMCPSettings,
|
||||
} = useSettingsData()
|
||||
|
||||
// Update local installConfig when savedInstallConfig changes
|
||||
useEffect(() => {
|
||||
if (savedInstallConfig) {
|
||||
setInstallConfig(savedInstallConfig);
|
||||
setInstallConfig(savedInstallConfig)
|
||||
}
|
||||
}, [savedInstallConfig]);
|
||||
}, [savedInstallConfig])
|
||||
|
||||
// Update local tempSmartRoutingConfig when smartRoutingConfig changes
|
||||
useEffect(() => {
|
||||
@@ -83,9 +85,9 @@ const SettingsPage: React.FC = () => {
|
||||
openaiApiBaseUrl: smartRoutingConfig.openaiApiBaseUrl || '',
|
||||
openaiApiKey: smartRoutingConfig.openaiApiKey || '',
|
||||
openaiApiEmbeddingModel: smartRoutingConfig.openaiApiEmbeddingModel || '',
|
||||
});
|
||||
})
|
||||
}
|
||||
}, [smartRoutingConfig]);
|
||||
}, [smartRoutingConfig])
|
||||
|
||||
// Update local tempMCPRouterConfig when mcpRouterConfig changes
|
||||
useEffect(() => {
|
||||
@@ -95,14 +97,14 @@ const SettingsPage: React.FC = () => {
|
||||
referer: mcpRouterConfig.referer || 'https://www.mcphubx.com',
|
||||
title: mcpRouterConfig.title || 'MCPHub',
|
||||
baseUrl: mcpRouterConfig.baseUrl || 'https://api.mcprouter.to/v1',
|
||||
});
|
||||
})
|
||||
}
|
||||
}, [mcpRouterConfig]);
|
||||
}, [mcpRouterConfig])
|
||||
|
||||
// Update local tempNameSeparator when nameSeparator changes
|
||||
useEffect(() => {
|
||||
setTempNameSeparator(nameSeparator);
|
||||
}, [nameSeparator]);
|
||||
setTempNameSeparator(nameSeparator)
|
||||
}, [nameSeparator])
|
||||
|
||||
const [sectionsVisible, setSectionsVisible] = useState({
|
||||
routingConfig: false,
|
||||
@@ -110,138 +112,244 @@ const SettingsPage: React.FC = () => {
|
||||
smartRoutingConfig: false,
|
||||
mcpRouterConfig: false,
|
||||
nameSeparator: false,
|
||||
password: false
|
||||
});
|
||||
password: false,
|
||||
exportConfig: false,
|
||||
})
|
||||
|
||||
const toggleSection = (section: 'routingConfig' | 'installConfig' | 'smartRoutingConfig' | 'mcpRouterConfig' | 'nameSeparator' | 'password') => {
|
||||
setSectionsVisible(prev => ({
|
||||
const toggleSection = (
|
||||
section:
|
||||
| 'routingConfig'
|
||||
| 'installConfig'
|
||||
| 'smartRoutingConfig'
|
||||
| 'mcpRouterConfig'
|
||||
| 'nameSeparator'
|
||||
| 'password'
|
||||
| 'exportConfig',
|
||||
) => {
|
||||
setSectionsVisible((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 (key === 'enableBearerAuth' && value === true) {
|
||||
if (!tempRoutingConfig.bearerAuthKey && !routingConfig.bearerAuthKey) {
|
||||
const newKey = generateRandomKey();
|
||||
handleBearerAuthKeyChange(newKey);
|
||||
const newKey = generateRandomKey()
|
||||
handleBearerAuthKeyChange(newKey)
|
||||
|
||||
// Update both enableBearerAuth and bearerAuthKey in a single call
|
||||
const success = await updateRoutingConfigBatch({
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: newKey
|
||||
});
|
||||
bearerAuthKey: newKey,
|
||||
})
|
||||
|
||||
if (success) {
|
||||
// Update tempRoutingConfig to reflect the saved values
|
||||
setTempRoutingConfig(prev => ({
|
||||
setTempRoutingConfig((prev) => ({
|
||||
...prev,
|
||||
bearerAuthKey: newKey
|
||||
}));
|
||||
bearerAuthKey: newKey,
|
||||
}))
|
||||
}
|
||||
return;
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await updateRoutingConfig(key, value);
|
||||
};
|
||||
await updateRoutingConfig(key, value)
|
||||
}
|
||||
|
||||
const handleBearerAuthKeyChange = (value: string) => {
|
||||
setTempRoutingConfig(prev => ({
|
||||
setTempRoutingConfig((prev) => ({
|
||||
...prev,
|
||||
bearerAuthKey: value
|
||||
}));
|
||||
};
|
||||
bearerAuthKey: value,
|
||||
}))
|
||||
}
|
||||
|
||||
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({
|
||||
...installConfig,
|
||||
[key]: value
|
||||
});
|
||||
};
|
||||
[key]: value,
|
||||
})
|
||||
}
|
||||
|
||||
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({
|
||||
...tempSmartRoutingConfig,
|
||||
[key]: value
|
||||
});
|
||||
};
|
||||
[key]: value,
|
||||
})
|
||||
}
|
||||
|
||||
const saveSmartRoutingConfig = async (key: 'dbUrl' | 'openaiApiBaseUrl' | 'openaiApiKey' | 'openaiApiEmbeddingModel') => {
|
||||
await updateSmartRoutingConfig(key, tempSmartRoutingConfig[key]);
|
||||
};
|
||||
const saveSmartRoutingConfig = async (
|
||||
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({
|
||||
...tempMCPRouterConfig,
|
||||
[key]: value
|
||||
});
|
||||
};
|
||||
[key]: value,
|
||||
})
|
||||
}
|
||||
|
||||
const saveMCPRouterConfig = async (key: 'apiKey' | 'referer' | 'title' | 'baseUrl') => {
|
||||
await updateMCPRouterConfig(key, tempMCPRouterConfig[key]);
|
||||
};
|
||||
await updateMCPRouterConfig(key, tempMCPRouterConfig[key])
|
||||
}
|
||||
|
||||
const saveNameSeparator = async () => {
|
||||
await updateNameSeparator(tempNameSeparator);
|
||||
};
|
||||
await updateNameSeparator(tempNameSeparator)
|
||||
}
|
||||
|
||||
const handleSmartRoutingEnabledChange = async (value: boolean) => {
|
||||
// If enabling Smart Routing, validate required fields and save any unsaved changes
|
||||
if (value) {
|
||||
const currentDbUrl = tempSmartRoutingConfig.dbUrl || smartRoutingConfig.dbUrl;
|
||||
const currentOpenaiApiKey = tempSmartRoutingConfig.openaiApiKey || smartRoutingConfig.openaiApiKey;
|
||||
const currentDbUrl = tempSmartRoutingConfig.dbUrl || smartRoutingConfig.dbUrl
|
||||
const currentOpenaiApiKey =
|
||||
tempSmartRoutingConfig.openaiApiKey || smartRoutingConfig.openaiApiKey
|
||||
|
||||
if (!currentDbUrl || !currentOpenaiApiKey) {
|
||||
const missingFields = [];
|
||||
if (!currentDbUrl) missingFields.push(t('settings.dbUrl'));
|
||||
if (!currentOpenaiApiKey) missingFields.push(t('settings.openaiApiKey'));
|
||||
const missingFields = []
|
||||
if (!currentDbUrl) missingFields.push(t('settings.dbUrl'))
|
||||
if (!currentOpenaiApiKey) missingFields.push(t('settings.openaiApiKey'))
|
||||
|
||||
showToast(t('settings.smartRoutingValidationError', {
|
||||
fields: missingFields.join(', ')
|
||||
}));
|
||||
return;
|
||||
showToast(
|
||||
t('settings.smartRoutingValidationError', {
|
||||
fields: missingFields.join(', '),
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// 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
|
||||
if (tempSmartRoutingConfig.dbUrl !== smartRoutingConfig.dbUrl) {
|
||||
updates.dbUrl = tempSmartRoutingConfig.dbUrl;
|
||||
updates.dbUrl = tempSmartRoutingConfig.dbUrl
|
||||
}
|
||||
if (tempSmartRoutingConfig.openaiApiBaseUrl !== smartRoutingConfig.openaiApiBaseUrl) {
|
||||
updates.openaiApiBaseUrl = tempSmartRoutingConfig.openaiApiBaseUrl;
|
||||
updates.openaiApiBaseUrl = tempSmartRoutingConfig.openaiApiBaseUrl
|
||||
}
|
||||
if (tempSmartRoutingConfig.openaiApiKey !== smartRoutingConfig.openaiApiKey) {
|
||||
updates.openaiApiKey = tempSmartRoutingConfig.openaiApiKey;
|
||||
updates.openaiApiKey = tempSmartRoutingConfig.openaiApiKey
|
||||
}
|
||||
if (tempSmartRoutingConfig.openaiApiEmbeddingModel !== smartRoutingConfig.openaiApiEmbeddingModel) {
|
||||
updates.openaiApiEmbeddingModel = tempSmartRoutingConfig.openaiApiEmbeddingModel;
|
||||
if (
|
||||
tempSmartRoutingConfig.openaiApiEmbeddingModel !==
|
||||
smartRoutingConfig.openaiApiEmbeddingModel
|
||||
) {
|
||||
updates.openaiApiEmbeddingModel = tempSmartRoutingConfig.openaiApiEmbeddingModel
|
||||
}
|
||||
|
||||
// Save all changes in a single batch update
|
||||
await updateSmartRoutingConfigBatch(updates);
|
||||
await updateSmartRoutingConfigBatch(updates)
|
||||
} else {
|
||||
// If disabling, just update the enabled status
|
||||
await updateSmartRoutingConfig('enabled', value);
|
||||
await updateSmartRoutingConfig('enabled', value)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handlePasswordChangeSuccess = () => {
|
||||
setTimeout(() => {
|
||||
navigate('/');
|
||||
}, 2000);
|
||||
};
|
||||
navigate('/')
|
||||
}, 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 (
|
||||
<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>
|
||||
<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>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
@@ -277,7 +387,8 @@ const SettingsPage: React.FC = () => {
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<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>
|
||||
</div>
|
||||
<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="mb-2">
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -332,7 +444,9 @@ const SettingsPage: React.FC = () => {
|
||||
<input
|
||||
type="text"
|
||||
value={tempSmartRoutingConfig.openaiApiBaseUrl}
|
||||
onChange={(e) => handleSmartRoutingConfigChange('openaiApiBaseUrl', e.target.value)}
|
||||
onChange={(e) =>
|
||||
handleSmartRoutingConfigChange('openaiApiBaseUrl', e.target.value)
|
||||
}
|
||||
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"
|
||||
disabled={loading}
|
||||
@@ -349,13 +463,17 @@ const SettingsPage: React.FC = () => {
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<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 className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={tempSmartRoutingConfig.openaiApiEmbeddingModel}
|
||||
onChange={(e) => handleSmartRoutingConfigChange('openaiApiEmbeddingModel', e.target.value)}
|
||||
onChange={(e) =>
|
||||
handleSmartRoutingConfigChange('openaiApiEmbeddingModel', e.target.value)
|
||||
}
|
||||
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"
|
||||
disabled={loading}
|
||||
@@ -392,7 +510,9 @@ const SettingsPage: React.FC = () => {
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<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 className="flex items-center gap-3">
|
||||
<input
|
||||
@@ -416,7 +536,9 @@ const SettingsPage: React.FC = () => {
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<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 className="flex items-center gap-3">
|
||||
<input
|
||||
@@ -448,9 +570,7 @@ const SettingsPage: React.FC = () => {
|
||||
onClick={() => toggleSection('nameSeparator')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('settings.systemSettings')}</h2>
|
||||
<span className="text-gray-500">
|
||||
{sectionsVisible.nameSeparator ? '▼' : '►'}
|
||||
</span>
|
||||
<span className="text-gray-500">{sectionsVisible.nameSeparator ? '▼' : '►'}</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.nameSeparator && (
|
||||
@@ -490,9 +610,7 @@ const SettingsPage: React.FC = () => {
|
||||
onClick={() => toggleSection('routingConfig')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('pages.settings.routeConfig')}</h2>
|
||||
<span className="text-gray-500">
|
||||
{sectionsVisible.routingConfig ? '▼' : '►'}
|
||||
</span>
|
||||
<span className="text-gray-500">{sectionsVisible.routingConfig ? '▼' : '►'}</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.routingConfig && (
|
||||
@@ -505,7 +623,9 @@ const SettingsPage: React.FC = () => {
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableBearerAuth}
|
||||
onCheckedChange={(checked) => handleRoutingConfigChange('enableBearerAuth', checked)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleRoutingConfigChange('enableBearerAuth', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -538,24 +658,32 @@ const SettingsPage: React.FC = () => {
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<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>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableGlobalRoute}
|
||||
onCheckedChange={(checked) => handleRoutingConfigChange('enableGlobalRoute', checked)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleRoutingConfigChange('enableGlobalRoute', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<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>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableGroupNameRoute}
|
||||
onCheckedChange={(checked) => handleRoutingConfigChange('enableGroupNameRoute', checked)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleRoutingConfigChange('enableGroupNameRoute', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -572,7 +700,6 @@ const SettingsPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
</PermissionChecker>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -585,9 +712,7 @@ const SettingsPage: React.FC = () => {
|
||||
onClick={() => toggleSection('installConfig')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('settings.installConfig')}</h2>
|
||||
<span className="text-gray-500">
|
||||
{sectionsVisible.installConfig ? '▼' : '►'}
|
||||
</span>
|
||||
<span className="text-gray-500">{sectionsVisible.installConfig ? '▼' : '►'}</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.installConfig && (
|
||||
@@ -675,9 +800,7 @@ const SettingsPage: React.FC = () => {
|
||||
onClick={() => toggleSection('password')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('auth.changePassword')}</h2>
|
||||
<span className="text-gray-500">
|
||||
{sectionsVisible.password ? '▼' : '►'}
|
||||
</span>
|
||||
<span className="text-gray-500">{sectionsVisible.password ? '▼' : '►'}</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.password && (
|
||||
@@ -686,8 +809,61 @@ const SettingsPage: React.FC = () => {
|
||||
</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
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"addServer": "Add Server",
|
||||
"add": "Add",
|
||||
"edit": "Edit",
|
||||
"copy": "Copy",
|
||||
"delete": "Delete",
|
||||
"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.",
|
||||
@@ -124,6 +125,7 @@
|
||||
"argumentsPlaceholder": "Enter arguments",
|
||||
"errorDetails": "Error Details",
|
||||
"viewErrorDetails": "View error details",
|
||||
"copyConfig": "Copy Configuration",
|
||||
"confirmVariables": "Confirm Variable Configuration",
|
||||
"variablesDetected": "Variables detected in configuration. Please confirm these variables are properly configured:",
|
||||
"detectedVariables": "Detected Variables",
|
||||
@@ -200,6 +202,7 @@
|
||||
"copyJson": "Copy JSON",
|
||||
"copySuccess": "Copied to clipboard",
|
||||
"copyFailed": "Copy failed",
|
||||
"copied": "Copied",
|
||||
"close": "Close",
|
||||
"confirm": "Confirm",
|
||||
"language": "Language",
|
||||
@@ -502,7 +505,14 @@
|
||||
"systemSettings": "System Settings",
|
||||
"nameSeparatorLabel": "Name Separator",
|
||||
"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": {
|
||||
"upload": "Upload",
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"addServer": "Ajouter un serveur",
|
||||
"add": "Ajouter",
|
||||
"edit": "Modifier",
|
||||
"copy": "Copier",
|
||||
"delete": "Supprimer",
|
||||
"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.",
|
||||
@@ -124,6 +125,7 @@
|
||||
"argumentsPlaceholder": "Entrez les arguments",
|
||||
"errorDetails": "Détails de l'erreur",
|
||||
"viewErrorDetails": "Voir les détails de l'erreur",
|
||||
"copyConfig": "Copier la configuration",
|
||||
"confirmVariables": "Confirmer la configuration des variables",
|
||||
"variablesDetected": "Variables détectées dans la configuration. Veuillez confirmer que ces variables sont correctement configurées :",
|
||||
"detectedVariables": "Variables détectées",
|
||||
@@ -200,6 +202,7 @@
|
||||
"copyJson": "Copier le JSON",
|
||||
"copySuccess": "Copié dans le presse-papiers",
|
||||
"copyFailed": "Échec de la copie",
|
||||
"copied": "Copié",
|
||||
"close": "Fermer",
|
||||
"confirm": "Confirmer",
|
||||
"language": "Langue",
|
||||
@@ -502,7 +505,14 @@
|
||||
"systemSettings": "Paramètres système",
|
||||
"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 : -)",
|
||||
"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": {
|
||||
"upload": "Télécharger",
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"addServer": "添加服务器",
|
||||
"add": "添加",
|
||||
"edit": "编辑",
|
||||
"copy": "复制",
|
||||
"delete": "删除",
|
||||
"confirmDelete": "您确定要删除此服务器吗?",
|
||||
"deleteWarning": "删除服务器 '{{name}}' 将会移除该服务器及其所有数据。此操作无法撤销。",
|
||||
@@ -124,6 +125,7 @@
|
||||
"argumentsPlaceholder": "请输入参数",
|
||||
"errorDetails": "错误详情",
|
||||
"viewErrorDetails": "查看错误详情",
|
||||
"copyConfig": "复制配置",
|
||||
"confirmVariables": "确认变量配置",
|
||||
"variablesDetected": "检测到配置中包含变量,请确认这些变量是否已正确配置:",
|
||||
"detectedVariables": "检测到的变量",
|
||||
@@ -201,6 +203,7 @@
|
||||
"copyJson": "复制JSON",
|
||||
"copySuccess": "已复制到剪贴板",
|
||||
"copyFailed": "复制失败",
|
||||
"copied": "已复制",
|
||||
"close": "关闭",
|
||||
"confirm": "确认",
|
||||
"language": "语言",
|
||||
@@ -504,7 +507,14 @@
|
||||
"systemSettings": "系统设置",
|
||||
"nameSeparatorLabel": "名称分隔符",
|
||||
"nameSeparatorDescription": "用于分隔服务器名称和工具/提示名称(默认:-)",
|
||||
"restartRequired": "配置已保存。为确保所有服务正确加载新设置,建议重启应用。"
|
||||
"restartRequired": "配置已保存。为确保所有服务正确加载新设置,建议重启应用。",
|
||||
"exportMcpSettings": "导出配置",
|
||||
"mcpSettingsJson": "MCP 配置 JSON",
|
||||
"mcpSettingsJsonDescription": "查看、复制或下载当前的 mcp_settings.json 配置,可用于备份或迁移到其他工具",
|
||||
"copyToClipboard": "复制到剪贴板",
|
||||
"downloadJson": "下载 JSON",
|
||||
"exportSuccess": "配置导出成功",
|
||||
"exportError": "获取配置失败"
|
||||
},
|
||||
"dxt": {
|
||||
"upload": "上传",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response } from 'express';
|
||||
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 { DataService } from '../services/dataService.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',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -58,7 +58,7 @@ import {
|
||||
} from '../controllers/cloudController.js';
|
||||
import { login, register, getCurrentUser, changePassword } from '../controllers/authController.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 { getPrompt } from '../controllers/promptController.js';
|
||||
import { uploadDxtFile, uploadMiddleware } from '../controllers/dxtController.js';
|
||||
@@ -149,6 +149,9 @@ export const initRoutes = (app: express.Application): void => {
|
||||
router.delete('/logs', clearLogs);
|
||||
router.get('/logs/stream', streamLogs);
|
||||
|
||||
// MCP settings export route
|
||||
router.get('/mcp-settings/export', getMcpSettingsJson);
|
||||
|
||||
// Auth routes - move to router instead of app directly
|
||||
router.post(
|
||||
'/auth/login',
|
||||
|
||||
139
tests/controllers/configController.test.ts
Normal file
139
tests/controllers/configController.test.ts
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user