feat(McpServersList): add ModelScope sync functionality (#5250)

* feat: add sync servers functionality and UI components for MCP settings

* Rename 'Discover Models' to 'Discover MCP Servers'

Replace Radio button group with Select dropdown for provider selection
in the sync servers popup UI and update related styling
Rename 'Discover Models' to 'Discover MCP Servers'

* Add error messages for MCP server sync

Improve error handling in modelscopeSyncUtils with new localization
strings for unauthorized access and no available servers. Update error
message handling in all language files and use nanoid for unique server
naming.
This commit is contained in:
LiuVaayne 2025-04-25 22:11:23 +08:00 committed by GitHub
parent 0fa8ae9560
commit a58f23f68e
8 changed files with 577 additions and 4 deletions

View File

@ -1171,6 +1171,22 @@
"sse": "SSE",
"streamableHttp": "Streamable HTTP",
"stdio": "STDIO"
},
"sync": {
"title": "Sync Servers",
"selectProvider": "Select Provider:",
"discoverMcpServers": "Discover MCP Servers",
"discoverMcpServersDescription": "Visit the platform to discover available MCP servers",
"getToken": "Get API Token",
"getTokenDescription": "Retrieve your personal API token from your account",
"setToken": "Enter Your Token",
"tokenRequired": "API Token is required",
"tokenPlaceholder": "Enter API token here",
"button": "Sync",
"error": "Sync MCP Servers error",
"success": "Sync MCP Servers successful",
"unauthorized": "Sync Unauthorized",
"noServersAvailable": "No MCP servers available"
}
},
"messages.divider": "Show divider between messages",

View File

@ -1169,6 +1169,22 @@
"sse": "SSE",
"streamableHttp": "ストリーミング",
"stdio": "STDIO"
},
"sync": {
"title": "サーバーの同期",
"selectProvider": "プロバイダーを選択:",
"discoverMcpServers": "MCPサーバーを発見",
"discoverMcpServersDescription": "プラットフォームを訪れて利用可能なMCPサーバーを発見",
"getToken": "API トークンを取得する",
"getTokenDescription": "アカウントから個人用 API トークンを取得します",
"setToken": "トークンを入力してください",
"tokenRequired": "API トークンは必須です",
"tokenPlaceholder": "ここに API トークンを入力してください",
"button": "同期する",
"error": "MCPサーバーの同期エラー",
"success": "MCPサーバーの同期成功",
"unauthorized": "同期が許可されていません",
"noServersAvailable": "利用可能な MCP サーバーがありません"
}
},
"messages.divider": "メッセージ間に区切り線を表示",

View File

@ -1169,6 +1169,22 @@
"sse": "SSE",
"streamableHttp": "Потоковый HTTP",
"stdio": "STDIO"
},
"sync": {
"title": "Синхронизация серверов",
"selectProvider": "Выберите провайдера:",
"discoverMcpServers": "Обнаружить серверы MCP",
"discoverMcpServersDescription": "Посетите платформу, чтобы обнаружить доступные серверы MCP",
"getToken": "Получить API токен",
"getTokenDescription": "Получите персональный API токен из вашей учетной записи",
"setToken": "Введите ваш токен",
"tokenRequired": "Требуется API токен",
"tokenPlaceholder": "Введите API токен здесь",
"button": "Синхронизировать",
"error": "Ошибка синхронизации серверов MCP",
"success": "Синхронизация серверов MCP успешна",
"unauthorized": "Синхронизация не разрешена",
"noServersAvailable": "Нет доступных серверов MCP"
}
},
"messages.divider": "Показывать разделитель между сообщениями",

View File

@ -1171,6 +1171,22 @@
"sse": "SSE",
"streamableHttp": "流式",
"stdio": "STDIO"
},
"sync": {
"title": "同步服务器",
"selectProvider": "选择提供商:",
"discoverMcpServers": "发现MCP服务器",
"discoverMcpServersDescription": "访问平台以发现可用的MCP服务器",
"getToken": "获取 API 令牌",
"getTokenDescription": "从您的帐户中获取个人 API 令牌",
"setToken": "输入您的令牌",
"tokenRequired": "需要 API 令牌",
"tokenPlaceholder": "在此输入 API 令牌",
"button": "同步",
"error": "同步MCP服务器出错",
"success": "同步MCP服务器成功",
"unauthorized": "同步未授权",
"noServersAvailable": "无可用的 MCP 服务器"
}
},
"messages.divider": "消息分割线",

View File

@ -1170,6 +1170,22 @@
"sse": "SSE",
"streamableHttp": "流式",
"stdio": "STDIO"
},
"sync": {
"title": "同步伺服器",
"selectProvider": "選擇提供者:",
"discoverMcpServers": "發現MCP伺服器",
"discoverMcpServersDescription": "訪問平台以發現可用的MCP伺服器",
"getToken": "獲取 API 令牌",
"getTokenDescription": "從您的帳戶獲取個人 API 令牌",
"setToken": "輸入您的令牌",
"tokenRequired": "需要 API 令牌",
"tokenPlaceholder": "在此輸入 API 令牌",
"button": "同步",
"error": "同步MCP伺服器出錯",
"success": "同步MCP伺服器成功",
"unauthorized": "同步未授權",
"noServersAvailable": "無可用的 MCP 伺服器"
}
},
"messages.divider": "訊息間顯示分隔線",

View File

@ -5,7 +5,7 @@ import Scrollbar from '@renderer/components/Scrollbar'
import { useMCPServers } from '@renderer/hooks/useMCPServers'
import { MCPServer } from '@renderer/types'
import { Button, Empty, Tag } from 'antd'
import { MonitorCheck, Plus, Settings2 } from 'lucide-react'
import { MonitorCheck, Plus, RefreshCw, Settings2 } from 'lucide-react'
import { FC, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router'
@ -13,6 +13,8 @@ import styled from 'styled-components'
import { SettingTitle } from '..'
import EditMcpJsonPopup from './EditMcpJsonPopup'
import SyncServersPopup from './SyncServersPopup'
const McpServersList: FC = () => {
const { mcpServers, addMCPServer, updateMcpServers } = useMCPServers()
const { t } = useTranslation()
@ -34,6 +36,10 @@ const McpServersList: FC = () => {
window.message.success({ content: t('settings.mcp.addSuccess'), key: 'mcp-list' })
}, [addMCPServer, navigate, t])
const onSyncServers = useCallback(() => {
SyncServersPopup.show(mcpServers)
}, [mcpServers])
return (
<Container>
<ListHeader>
@ -41,9 +47,14 @@ const McpServersList: FC = () => {
<span>{t('settings.mcp.newServer')}</span>
<Button icon={<EditOutlined />} type="text" onClick={() => EditMcpJsonPopup.show()} shape="circle" />
</SettingTitle>
<Button icon={<Plus size={16} />} type="default" onClick={onAddMcpServer} shape="round">
{t('settings.mcp.addServer')}
</Button>
<ButtonGroup>
<Button icon={<Plus size={16} />} type="default" onClick={onAddMcpServer} shape="round">
{t('settings.mcp.addServer')}
</Button>
<Button icon={<RefreshCw size={16} />} type="default" onClick={onSyncServers} shape="round">
{t('settings.mcp.sync.title', 'Sync Servers')}
</Button>
</ButtonGroup>
</ListHeader>
<DragableList style={{ width: '100%' }} list={mcpServers} onUpdate={updateMcpServers}>
{(server: MCPServer) => (
@ -176,4 +187,9 @@ const ServerFooter = styled.div`
margin-top: 10px;
`
const ButtonGroup = styled.div`
display: flex;
gap: 8px;
`
export default McpServersList

View File

@ -0,0 +1,348 @@
import { TopView } from '@renderer/components/TopView'
import { useMCPServers } from '@renderer/hooks/useMCPServers'
import { MCPServer } from '@renderer/types'
import { Button, Form, Input, Modal, Select } from 'antd'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { getModelScopeToken, saveModelScopeToken, syncModelScopeServers } from './modelscopeSyncUtils'
// Provider configuration interface
interface ProviderConfig {
key: string
name: string
description: string
discoverUrl: string
apiKeyUrl: string
tokenFieldName: string
getToken: () => string | null
saveToken: (token: string) => void
syncServers: (token: string, existingServers: MCPServer[]) => Promise<any>
}
// Provider configurations
const providers: ProviderConfig[] = [
{
key: 'modelscope',
name: 'ModelScope',
description: 'ModelScope 平台 MCP 服务',
discoverUrl: 'https://www.modelscope.cn/mcp?hosted=1&page=1',
apiKeyUrl: 'https://www.modelscope.cn/my/myaccesstoken',
tokenFieldName: 'modelScopeToken',
getToken: getModelScopeToken,
saveToken: saveModelScopeToken,
syncServers: syncModelScopeServers
}
]
interface Props {
resolve: (data: any) => void
existingServers: MCPServer[]
}
const PopupContainer: React.FC<Props> = ({ resolve, existingServers }) => {
const { addMCPServer } = useMCPServers()
const [open, setOpen] = useState(true)
const [isSyncing, setIsSyncing] = useState(false)
const [selectedProviderKey, setSelectedProviderKey] = useState(providers[0].key)
const [tokens, setTokens] = useState<Record<string, string>>({})
const { t } = useTranslation()
const [form] = Form.useForm()
// Get the currently selected provider config
const selectedProvider = providers.find((p) => p.key === selectedProviderKey) || providers[0]
useEffect(() => {
// Initialize tokens for all providers
const initialTokens: Record<string, string> = {}
providers.forEach((provider) => {
const token = provider.getToken()
if (token) {
initialTokens[provider.tokenFieldName] = token
form.setFieldsValue({ [provider.tokenFieldName]: token })
}
})
setTokens(initialTokens)
}, [form])
const handleSync = useCallback(async () => {
try {
await form.validateFields()
} catch (error) {
return
}
setIsSyncing(true)
try {
const token = form.getFieldValue(selectedProvider.tokenFieldName)
// Save token if present
if (token) {
selectedProvider.saveToken(token)
setTokens((prev) => ({ ...prev, [selectedProvider.tokenFieldName]: token }))
}
// Sync servers
const result = await selectedProvider.syncServers(token, existingServers)
if (result.success && result.addedServers?.length > 0) {
// Add the new servers to the store
for (const server of result.addedServers) {
addMCPServer(server)
}
window.message.success(result.message)
setOpen(false)
} else {
// Show message but keep dialog open
if (result.success) {
window.message.info(result.message)
} else {
window.message.error(result.message)
}
}
} catch (error: any) {
window.message.error(`${t('settings.mcp.sync.error')}: ${error.message}`)
} finally {
setIsSyncing(false)
}
}, [addMCPServer, existingServers, form, selectedProvider, t])
const onCancel = () => {
setOpen(false)
}
const onClose = () => {
resolve({})
}
SyncServersPopup.hide = onCancel
// Check if sync button should be disabled
const isSyncDisabled = () => {
const token = tokens[selectedProvider.tokenFieldName]
return !token
}
return (
<Modal
title={t('settings.mcp.sync.title', 'Sync Servers')}
open={open}
onCancel={onCancel}
afterClose={onClose}
width={550}
footer={null}
transitionName="animation-move-down"
centered>
<ContentContainer>
{/* Only show provider selector if there are multiple providers */}
<ProviderSelector>
<SelectorLabel>{t('settings.mcp.sync.selectProvider', 'Select Provider:')}</SelectorLabel>
<Select
value={selectedProviderKey}
onChange={setSelectedProviderKey}
style={{ width: 200 }}
options={providers.map((provider) => ({
value: provider.key,
label: provider.name
}))}
/>
</ProviderSelector>
<ProviderContent>
<Form form={form} layout="vertical" style={{ width: '100%' }}>
<StepSection>
<StepNumber>1</StepNumber>
<StepContent>
<StepTitle>{t('settings.mcp.sync.discoverMcpServers', 'Discover MCP Servers')}</StepTitle>
<StepDescription>
{t(
'settings.mcp.sync.discoverMcpServersDescription',
'Visit the platform to discover available MCP servers'
)}
</StepDescription>
<LinkContainer>
<ExternalLink href={selectedProvider.discoverUrl} target="_blank">
<LinkIcon>🌐</LinkIcon>
<span>{t('settings.mcp.sync.discoverMcpServers', 'Discover MCP Servers')}</span>
</ExternalLink>
</LinkContainer>
</StepContent>
</StepSection>
<StepSection>
<StepNumber>2</StepNumber>
<StepContent>
<StepTitle>{t('settings.mcp.sync.getToken', 'Get API Token')}</StepTitle>
<StepDescription>
{t('settings.mcp.sync.getTokenDescription', 'Retrieve your personal API token from your account')}
</StepDescription>
<LinkContainer>
<ExternalLink href={selectedProvider.apiKeyUrl} target="_blank">
<LinkIcon>🔑</LinkIcon>
<span>{t('settings.mcp.sync.getToken', 'Get API Token')}</span>
</ExternalLink>
</LinkContainer>
</StepContent>
</StepSection>
<StepSection>
<StepNumber>3</StepNumber>
<StepContent>
<StepTitle>{t('settings.mcp.sync.setToken', 'Enter Your Token')}</StepTitle>
<Form.Item
name={selectedProvider.tokenFieldName}
rules={[{ required: true, message: t('settings.mcp.sync.tokenRequired', 'API Token is required') }]}>
<Input.Password
placeholder={t('settings.mcp.sync.tokenPlaceholder', 'Enter API token here')}
onChange={(e) => {
setTokens((prev) => ({ ...prev, [selectedProvider.tokenFieldName]: e.target.value }))
}}
/>
</Form.Item>
</StepContent>
</StepSection>
</Form>
</ProviderContent>
<ButtonContainer>
<Button type="default" onClick={onCancel}>
{t('common.cancel')}
</Button>
<Button type="primary" onClick={handleSync} loading={isSyncing} disabled={isSyncDisabled()}>
{t('settings.mcp.sync.button', 'Sync')}
</Button>
</ButtonContainer>
</ContentContainer>
</Modal>
)
}
const ContentContainer = styled.div`
display: flex;
flex-direction: column;
gap: 20px;
`
const ProviderSelector = styled.div`
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 15px;
`
const SelectorLabel = styled.div`
font-weight: 500;
white-space: nowrap;
`
const ProviderContent = styled.div`
border-top: 1px solid var(--color-border);
padding-top: 20px;
&.no-border {
border-top: none;
padding-top: 0;
}
`
const StepSection = styled.div`
display: flex;
align-items: flex-start;
gap: 15px;
margin-bottom: 20px;
`
const StepNumber = styled.div`
background-color: var(--color-primary);
color: white;
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 14px;
flex-shrink: 0;
margin-top: 2px;
`
const StepContent = styled.div`
display: flex;
flex-direction: column;
flex-grow: 1;
`
const StepTitle = styled.div`
font-weight: 600;
font-size: 15px;
margin-bottom: 4px;
`
const StepDescription = styled.div`
color: var(--color-text-secondary);
font-size: 13px;
margin-bottom: 8px;
`
const LinkContainer = styled.div`
margin-top: 4px;
`
const ExternalLink = styled.a`
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 6px;
background-color: var(--color-background-2);
font-size: 14px;
color: var(--color-primary);
transition: all 0.2s ease;
&:hover {
background-color: var(--color-background-3);
text-decoration: none;
}
`
const LinkIcon = styled.span`
font-size: 16px;
`
const ButtonContainer = styled.div`
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 10px;
padding-top: 15px;
border-top: 1px solid var(--color-border);
`
const TopViewKey = 'SyncServersPopup'
export default class SyncServersPopup {
static topviewId = 0
static hide() {
TopView.hide(TopViewKey)
}
static show(existingServers: MCPServer[]) {
return new Promise<any>((resolve) => {
TopView.show(
<PopupContainer
existingServers={existingServers}
resolve={(v) => {
resolve(v)
TopView.hide(TopViewKey)
}}
/>,
TopViewKey
)
})
}
}

View File

@ -0,0 +1,129 @@
import { nanoid } from '@reduxjs/toolkit'
import { MCPServer } from '@renderer/types'
import i18next from 'i18next'
// Token storage constants and utilities
const TOKEN_STORAGE_KEY = 'modelscope_token'
export const saveModelScopeToken = (token: string): void => {
localStorage.setItem(TOKEN_STORAGE_KEY, token)
}
export const getModelScopeToken = (): string | null => {
return localStorage.getItem(TOKEN_STORAGE_KEY)
}
export const clearModelScopeToken = (): void => {
localStorage.removeItem(TOKEN_STORAGE_KEY)
}
export const hasModelScopeToken = (): boolean => {
return !!getModelScopeToken()
}
interface ModelScopeServer {
id: string
name: string
chinese_name?: string
description?: string
operational_urls?: { url: string }[]
}
interface ModelScopeSyncResult {
success: boolean
message: string
addedServers: MCPServer[]
errorDetails?: string
}
// Function to fetch and process ModelScope servers
export const syncModelScopeServers = async (
token: string,
existingServers: MCPServer[]
): Promise<ModelScopeSyncResult> => {
const t = i18next.t
try {
const response = await fetch('https://www.modelscope.cn/api/v1/mcp/services/operational', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
// Handle authentication errors
if (response.status === 401 || response.status === 403) {
clearModelScopeToken()
return {
success: false,
message: t('settings.mcp.sync.unauthorized', 'Sync Unauthorized'),
addedServers: []
}
}
// Handle server errors
if (response.status === 500 || !response.ok) {
return {
success: false,
message: t('settings.mcp.sync.error'),
addedServers: [],
errorDetails: `Status: ${response.status}`
}
}
// Process successful response
const data = await response.json()
const servers: ModelScopeServer[] = data.Data?.Result || []
if (servers.length === 0) {
return {
success: true,
message: t('settings.mcp.sync.noServersAvailable', 'No MCP servers available'),
addedServers: []
}
}
// Transform ModelScope servers to MCP servers format
const addedServers: MCPServer[] = []
for (const server of servers) {
try {
if (!server.operational_urls?.[0]?.url) continue
// Skip if server already exists
if (existingServers.some((s) => s.id === `@modelscope/${server.id}`)) continue
const mcpServer: MCPServer = {
id: `@modelscope/${server.id}`,
name: server.chinese_name || server.name || `ModelScope Server ${nanoid()}`,
description: server.description || '',
type: 'sse',
baseUrl: server.operational_urls[0].url,
command: '',
args: [],
env: {},
isActive: true
}
addedServers.push(mcpServer)
} catch (err) {
console.error('Error processing ModelScope server:', err)
}
}
return {
success: true,
message: t('settings.mcp.sync.success', { count: addedServers.length }),
addedServers
}
} catch (error) {
console.error('ModelScope sync error:', error)
return {
success: false,
message: t('settings.mcp.sync.error'),
addedServers: [],
errorDetails: String(error)
}
}
}