refactor: mcp servers settings

This commit is contained in:
kangfenmao 2025-09-20 21:31:18 +08:00
parent f42afe28d7
commit 3417acafe2
6 changed files with 488 additions and 26 deletions

View File

@ -16,7 +16,7 @@ const BuiltinMCPServerList: FC = () => {
return (
<>
<SettingTitle style={{ gap: 3 }}>{t('settings.mcp.builtinServers')}</SettingTitle>
<SettingTitle style={{ marginBottom: 10 }}>{t('settings.mcp.builtinServers')}</SettingTitle>
<ServersGrid>
{builtinMCPServers.map((server) => {
const isInstalled = mcpServers.some((existingServer) => existingServer.name === server.name)

View File

@ -74,7 +74,7 @@ const McpMarketList: FC = () => {
return (
<>
<SettingTitle style={{ gap: 3 }}>{t('settings.mcp.findMore')}</SettingTitle>
<SettingTitle style={{ marginBottom: 10 }}>{t('settings.mcp.findMore')}</SettingTitle>
<MarketGrid>
{mcpMarkets.map((resource) => (
<MarketCard key={resource.name} onClick={() => window.open(resource.url, '_blank', 'noopener,noreferrer')}>

View File

@ -0,0 +1,186 @@
import { Flex, RowFlex } from '@cherrystudio/ui'
import { useMCPServers } from '@renderer/hooks/useMCPServers'
import type { MCPServer } from '@renderer/types'
import { Button, Divider, Input, Space } from 'antd'
import Link from 'antd/es/typography/Link'
import { SquareArrowOutUpRight } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { SettingHelpLink, SettingHelpTextRow, SettingSubtitle } from '..'
import type { ProviderConfig } from './providers/config'
interface Props {
provider: ProviderConfig
existingServers: MCPServer[]
}
const McpProviderSettings: React.FC<Props> = ({ provider, existingServers }) => {
const { addMCPServer, updateMCPServer } = useMCPServers()
const [isFetching, setIsFetching] = useState(false)
const [token, setToken] = useState<string>('')
const [availableServers, setAvailableServers] = useState<MCPServer[]>([])
const { t } = useTranslation()
useEffect(() => {
const savedToken = provider.getToken()
if (savedToken) {
setToken(savedToken)
}
}, [provider])
const handleFetch = useCallback(async () => {
if (!token.trim()) {
window.toast.error(t('settings.mcp.sync.tokenRequired', 'API Token is required'))
return
}
setIsFetching(true)
try {
provider.saveToken(token)
const result = await provider.syncServers(token, existingServers)
if (result.success) {
setAvailableServers(result.addedServers || [])
window.toast.success(t('settings.mcp.fetch.success', 'Successfully fetched MCP servers'))
} else {
window.toast.error(result.message)
}
} catch (error: any) {
window.toast.error(`${t('settings.mcp.sync.error')}: ${error.message}`)
} finally {
setIsFetching(false)
}
}, [existingServers, provider, t, token])
const isFetchDisabled = !token
return (
<DetailContainer>
<ProviderHeader>
<Flex className="items-center gap-2">
<ProviderName>{provider.name}</ProviderName>
{provider.discoverUrl && (
<Link target="_blank" href={provider.discoverUrl} style={{ display: 'flex' }}>
<Button type="text" size="small" icon={<SquareArrowOutUpRight size={14} />} />
</Link>
)}
</Flex>
<Button type="primary" onClick={handleFetch} loading={isFetching} disabled={isFetchDisabled}>
{t('settings.mcp.fetch.button', 'Fetch Servers')}
</Button>
</ProviderHeader>
<Divider style={{ width: '100%', margin: '10px 0' }} />
<SettingSubtitle style={{ marginTop: 5 }}>{t('settings.provider.api_key.label')}</SettingSubtitle>
<Space.Compact style={{ width: '100%', marginTop: 5 }}>
<Input.Password
value={token}
placeholder={t('settings.mcp.sync.tokenPlaceholder', 'Enter API token here')}
onChange={(e) => setToken(e.target.value)}
spellCheck={false}
/>
</Space.Compact>
<SettingHelpTextRow>
<RowFlex>
{provider.apiKeyUrl && (
<SettingHelpLink target="_blank" href={provider.apiKeyUrl}>
{t('settings.provider.get_api_key')}
</SettingHelpLink>
)}
</RowFlex>
</SettingHelpTextRow>
{availableServers.length > 0 && (
<>
<SettingSubtitle style={{ marginTop: 20 }}>
{t('settings.mcp.available.servers', 'Available MCP Servers')}
</SettingSubtitle>
<ServerList>
{availableServers.map((server) => (
<ServerItem key={server.id}>
<ServerInfo>
<ServerName>{server.name}</ServerName>
<ServerDescription>{server.description}</ServerDescription>
</ServerInfo>
{(() => {
const isAlreadyAdded = existingServers.some(existing => existing.id === server.id)
return (
<Button
type={isAlreadyAdded ? 'default' : 'primary'}
size="small"
disabled={isAlreadyAdded}
onClick={() => {
if (!isAlreadyAdded) {
addMCPServer(server)
window.toast.success(t('settings.mcp.server.added', 'MCP server added'))
}
}}
>
{isAlreadyAdded ? t('settings.mcp.server.added', 'Added') : t('settings.mcp.add.server', 'Add')}
</Button>
)
})()}
</ServerItem>
))}
</ServerList>
</>
)}
</DetailContainer>
)
}
const DetailContainer = styled.div`
padding: 20px;
display: flex;
flex-direction: column;
`
const ProviderHeader = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
`
const ProviderName = styled.span`
font-size: 14px;
font-weight: 500;
margin-right: -2px;
`
const ServerList = styled.div`
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 8px;
`
const ServerItem = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border: 1px solid var(--color-border);
border-radius: 8px;
background-color: var(--color-background);
`
const ServerInfo = styled.div`
display: flex;
flex-direction: column;
flex: 1;
`
const ServerName = styled.div`
font-weight: 500;
font-size: 14px;
margin-bottom: 4px;
`
const ServerDescription = styled.div`
color: var(--color-text-secondary);
font-size: 12px;
`
export default McpProviderSettings

View File

@ -1,39 +1,128 @@
import { ArrowLeftOutlined } from '@ant-design/icons'
import { Button } from '@cherrystudio/ui'
import { ErrorBoundary } from '@renderer/components/ErrorBoundary'
import { Button, DividerWithText, ListItem } from '@cherrystudio/ui'
import { RowFlex, Scrollbar } from '@cherrystudio/ui'
import Ai302ProviderLogo from '@renderer/assets/images/providers/302ai.webp'
import BailianProviderLogo from '@renderer/assets/images/providers/bailian.png'
import LanyunProviderLogo from '@renderer/assets/images/providers/lanyun.png'
import ModelScopeProviderLogo from '@renderer/assets/images/providers/modelscope.png'
import TokenFluxProviderLogo from '@renderer/assets/images/providers/tokenflux.png'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useMCPServers } from '@renderer/hooks/useMCPServers'
import { FolderCog, Package, ShoppingBag } from 'lucide-react'
import type { FC } from 'react'
import { Route, Routes, useLocation } from 'react-router'
import { useTranslation } from 'react-i18next'
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router'
import { Link } from 'react-router-dom'
import styled from 'styled-components'
import { SettingContainer } from '..'
import BuiltinMCPServerList from './BuiltinMCPServerList'
import InstallNpxUv from './InstallNpxUv'
import McpMarketList from './McpMarketList'
import ProviderDetail from './McpProviderSettings'
import McpServersList from './McpServersList'
import McpSettings from './McpSettings'
import NpxSearch from './NpxSearch'
import { providers } from './providers/config'
const MCPSettings: FC = () => {
const { theme } = useTheme()
const { t } = useTranslation()
const { mcpServers } = useMCPServers()
const navigate = useNavigate()
const location = useLocation()
const pathname = location.pathname
const isHome = pathname === '/settings/mcp'
// 获取当前激活的页面
const getActiveView = () => {
const path = location.pathname
// 精确匹配路径
if (path === '/settings/mcp/builtin') return 'builtin'
if (path === '/settings/mcp/marketplaces') return 'marketplaces'
// 检查是否是服务商页面 - 精确匹配
for (const provider of providers) {
if (path === `/settings/mcp/${provider.key}`) {
return provider.key
}
}
// 其他所有情况(包括 servers、settings/:serverId、npx-search、mcp-install都属于 servers
return 'servers'
}
const activeView = getActiveView()
// 判断是否为主页面(是否显示返回按钮)
const isHomePage = () => {
const path = location.pathname
// 主页面不显示返回按钮
if (path === '/settings/mcp' || path === '/settings/mcp/servers') return true
if (path === '/settings/mcp/builtin' || path === '/settings/mcp/marketplaces') return true
// 服务商页面也是主页面
return providers.some((p) => path === `/settings/mcp/${p.key}`)
}
// Provider icons map
const providerIcons: Record<string, React.ReactNode> = {
modelscope: <ProviderIcon src={ModelScopeProviderLogo} alt="ModelScope" />,
tokenflux: <ProviderIcon src={TokenFluxProviderLogo} alt="TokenFlux" />,
lanyun: <ProviderIcon src={LanyunProviderLogo} alt="Lanyun" />,
'302ai': <ProviderIcon src={Ai302ProviderLogo} alt="302AI" />,
bailian: <ProviderIcon src={BailianProviderLogo} alt="Bailian" />
}
return (
<SettingContainer theme={theme} style={{ padding: 0, position: 'relative' }}>
{!isHome && (
<BackButtonContainer>
<Link to="/settings/mcp">
<Button variant="solid" startContent={<ArrowLeftOutlined />} radius="full" isIconOnly />
</Link>
</BackButtonContainer>
)}
<Container>
<MainContainer>
<ErrorBoundary>
<MenuList>
<DividerWithText text={t('settings.mcp.management', 'Management')} style={{ margin: '8px 0' }} />
<ListItem
title={t('settings.mcp.servers', 'MCP Servers')}
active={activeView === 'servers'}
onClick={() => navigate('/settings/mcp/servers')}
icon={<FolderCog size={16} />}
titleStyle={{ fontWeight: 500 }}
/>
<DividerWithText text={t('settings.mcp.discover', 'Discover')} style={{ margin: '16px 0 8px 0' }} />
<ListItem
title={t('settings.mcp.builtinServers', 'Built-in Servers')}
active={activeView === 'builtin'}
onClick={() => navigate('/settings/mcp/builtin')}
icon={<Package size={16} />}
titleStyle={{ fontWeight: 500 }}
/>
<ListItem
title={t('settings.mcp.marketplaces', 'Marketplaces')}
active={activeView === 'marketplaces'}
onClick={() => navigate('/settings/mcp/marketplaces')}
icon={<ShoppingBag size={16} />}
titleStyle={{ fontWeight: 500 }}
/>
<DividerWithText text={t('settings.mcp.providers', 'Providers')} style={{ margin: '16px 0 8px 0' }} />
{providers.map((provider) => (
<ListItem
key={provider.key}
title={provider.name}
active={activeView === provider.key}
onClick={() => navigate(`/settings/mcp/${provider.key}`)}
icon={providerIcons[provider.key] || <FolderCog size={16} />}
titleStyle={{ fontWeight: 500 }}
/>
))}
</MenuList>
<RightContainer>
{!isHomePage() && (
<BackButtonContainer>
<Link to="/settings/mcp/servers">
<Button variant="solid" startContent={<ArrowLeftOutlined />} radius="full" isIconOnly />
</Link>
</BackButtonContainer>
)}
<Routes>
<Route path="/" element={<McpServersList />} />
<Route index element={<Navigate to="servers" replace />} />
<Route path="servers" element={<McpServersList />} />
<Route path="settings/:serverId" element={<McpSettings />} />
<Route
path="npx-search"
@ -51,13 +140,79 @@ const MCPSettings: FC = () => {
</SettingContainer>
}
/>
<Route
path="builtin"
element={
<ContentWrapper>
<BuiltinMCPServerList />
</ContentWrapper>
}
/>
<Route
path="marketplaces"
element={
<ContentWrapper>
<McpMarketList />
</ContentWrapper>
}
/>
{providers.map((provider) => (
<Route
key={provider.key}
path={provider.key}
element={<ProviderDetail provider={provider} existingServers={mcpServers} />}
/>
))}
</Routes>
</ErrorBoundary>
</RightContainer>
</MainContainer>
</SettingContainer>
</Container>
)
}
const Container = styled(RowFlex)`
flex: 1;
`
const MainContainer = styled.div`
display: flex;
flex: 1;
flex-direction: row;
width: 100%;
height: calc(100vh - var(--navbar-height) - 6px);
overflow: hidden;
`
const MenuList = styled(Scrollbar)`
display: flex;
flex-direction: column;
gap: 5px;
width: var(--settings-width);
padding: 12px;
padding-bottom: 48px;
border-right: 0.5px solid var(--color-border);
height: calc(100vh - var(--navbar-height));
`
const RightContainer = styled(Scrollbar)`
flex: 1;
position: relative;
`
const ProviderIcon = styled.img`
width: 24px;
height: 24px;
object-fit: cover;
border-radius: 50%;
background-color: var(--color-background-soft);
`
const ContentWrapper = styled.div`
padding: 20px;
overflow-y: auto;
height: 100%;
`
const BackButtonContainer = styled.div`
display: flex;
align-items: center;
@ -70,10 +225,4 @@ const BackButtonContainer = styled.div`
z-index: 1000;
`
const MainContainer = styled.div`
display: flex;
flex: 1;
width: 100%;
`
export default MCPSettings

View File

@ -0,0 +1,76 @@
import { getAI302Token, saveAI302Token, syncAi302Servers } from './302ai'
import { getBailianToken, saveBailianToken, syncBailianServers } from './bailian'
import { getTokenLanYunToken, LANYUN_KEY_HOST, saveTokenLanYunToken, syncTokenLanYunServers } from './lanyun'
import { getModelScopeToken, MODELSCOPE_HOST, saveModelScopeToken, syncModelScopeServers } from './modelscope'
import { getTokenFluxToken, saveTokenFluxToken, syncTokenFluxServers, TOKENFLUX_HOST } from './tokenflux'
import type { MCPServer } from '@renderer/types'
export 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>
}
export const providers: ProviderConfig[] = [
{
key: 'modelscope',
name: 'ModelScope',
description: 'ModelScope 平台 MCP 服务',
discoverUrl: `${MODELSCOPE_HOST}/mcp?hosted=1&page=1`,
apiKeyUrl: `${MODELSCOPE_HOST}/my/myaccesstoken`,
tokenFieldName: 'modelScopeToken',
getToken: getModelScopeToken,
saveToken: saveModelScopeToken,
syncServers: syncModelScopeServers
},
{
key: 'tokenflux',
name: 'TokenFlux',
description: 'TokenFlux 平台 MCP 服务',
discoverUrl: `${TOKENFLUX_HOST}/mcps`,
apiKeyUrl: `${TOKENFLUX_HOST}/dashboard/api-keys`,
tokenFieldName: 'tokenfluxToken',
getToken: getTokenFluxToken,
saveToken: saveTokenFluxToken,
syncServers: syncTokenFluxServers
},
{
key: 'lanyun',
name: '蓝耘科技',
description: '蓝耘科技云平台 MCP 服务',
discoverUrl: 'https://mcp.lanyun.net',
apiKeyUrl: LANYUN_KEY_HOST,
tokenFieldName: 'tokenLanyunToken',
getToken: getTokenLanYunToken,
saveToken: saveTokenLanYunToken,
syncServers: syncTokenLanYunServers
},
{
key: '302ai',
name: '302.AI',
description: '302.AI 平台 MCP 服务',
discoverUrl: 'https://302.ai',
apiKeyUrl: 'https://dash.302.ai/apis/list',
tokenFieldName: 'token302aiToken',
getToken: getAI302Token,
saveToken: saveAI302Token,
syncServers: syncAi302Servers
},
{
key: 'bailian',
name: '阿里云百炼',
description: '百炼平台服务',
discoverUrl: `https://bailian.console.aliyun.com/?tab=mcp#/mcp-market`,
apiKeyUrl: `https://bailian.console.aliyun.com/?tab=app#/api-key`,
tokenFieldName: 'bailianToken',
getToken: getBailianToken,
saveToken: saveBailianToken,
syncServers: syncBailianServers
}
]

View File

@ -0,0 +1,51 @@
/**
* MCP Settings
*/
export const MCPRoutes = {
// 管理类页面
servers: '/settings/mcp/servers',
npxSearch: '/settings/mcp/npx-search',
mcpInstall: '/settings/mcp/mcp-install',
// 发现类页面
builtin: '/settings/mcp/builtin',
marketplaces: '/settings/mcp/marketplaces',
// 服务商页面
modelscope: '/settings/mcp/modelscope',
tokenflux: '/settings/mcp/tokenflux',
lanyun: '/settings/mcp/lanyun',
'302ai': '/settings/mcp/302ai',
bailian: '/settings/mcp/bailian',
} as const
/**
* MCP
* @param page
* @returns
*/
export function getMCPRoute(page: keyof typeof MCPRoutes): string {
return MCPRoutes[page]
}
/**
* MCP
* @param providerKey
* @returns
*/
export function getMCPProviderRoute(providerKey: string): string {
return `/settings/mcp/${providerKey}`
}
/**
* MCP
* @param serverId ID
* @returns
*/
export function getMCPServerSettingsRoute(serverId: string): string {
return `/settings/mcp/settings/${serverId}`
}
// 类型定义
export type MCPPage = keyof typeof MCPRoutes