fix: mcp install ui

This commit is contained in:
kangfenmao 2025-03-28 11:15:49 +08:00
parent 7263a682b7
commit 403ed8cbf4
17 changed files with 388 additions and 107 deletions

View File

@ -266,6 +266,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle('mcp:remove-server', mcpService.removeServer) ipcMain.handle('mcp:remove-server', mcpService.removeServer)
ipcMain.handle('mcp:list-tools', mcpService.listTools) ipcMain.handle('mcp:list-tools', mcpService.listTools)
ipcMain.handle('mcp:call-tool', mcpService.callTool) ipcMain.handle('mcp:call-tool', mcpService.callTool)
ipcMain.handle('mcp:get-install-info', mcpService.getInstallInfo)
ipcMain.handle('app:is-binary-exist', (_, name: string) => isBinaryExists(name)) ipcMain.handle('app:is-binary-exist', (_, name: string) => isBinaryExists(name))
ipcMain.handle('app:get-binary-path', (_, name: string) => getBinaryPath(name)) ipcMain.handle('app:get-binary-path', (_, name: string) => getBinaryPath(name))

View File

@ -1,4 +1,7 @@
import { getBinaryPath } from '@main/utils/process' import os from 'node:os'
import path from 'node:path'
import { getBinaryName, getBinaryPath } from '@main/utils/process'
import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
@ -152,6 +155,15 @@ class McpService {
throw error throw error
} }
} }
public async getInstallInfo() {
const dir = path.join(os.homedir(), '.cherrystudio', 'bin')
const uvName = await getBinaryName('uv')
const bunName = await getBinaryName('bun')
const uvPath = path.join(dir, uvName)
const bunPath = path.join(dir, bunName)
return { dir, uvPath, bunPath }
}
} }
export default new McpService() export default new McpService()

View File

@ -35,12 +35,18 @@ export function runInstallScript(scriptPath: string): Promise<void> {
}) })
} }
export async function getBinaryName(name: string): Promise<string> {
if (process.platform === 'win32') {
return `${name}.exe`
}
return name
}
export async function getBinaryPath(name: string): Promise<string> { export async function getBinaryPath(name: string): Promise<string> {
let cmd = process.platform === 'win32' ? `${name}.exe` : name const binaryName = await getBinaryName(name)
const binariesDir = path.join(os.homedir(), '.cherrystudio', 'bin') const binariesDir = path.join(os.homedir(), '.cherrystudio', 'bin')
const binariesDirExists = await fs.existsSync(binariesDir) const binariesDirExists = await fs.existsSync(binariesDir)
cmd = binariesDirExists ? path.join(binariesDir, cmd) : name return binariesDirExists ? path.join(binariesDir, binaryName) : binaryName
return cmd
} }
export async function isBinaryExists(name: string): Promise<boolean> { export async function isBinaryExists(name: string): Promise<boolean> {

View File

@ -149,6 +149,7 @@ declare global {
removeServer: (server: MCPServer) => Promise<void> removeServer: (server: MCPServer) => Promise<void>
listTools: (server: MCPServer) => Promise<MCPTool[]> listTools: (server: MCPServer) => Promise<MCPTool[]>
callTool: ({ server, name, args }: { server: MCPServer; name: string; args: any }) => Promise<any> callTool: ({ server, name, args }: { server: MCPServer; name: string; args: any }) => Promise<any>
getInstallInfo: () => Promise<{ dir: string; uvPath: string; bunPath: string }>
} }
copilot: { copilot: {
getAuthMessage: ( getAuthMessage: (

View File

@ -123,7 +123,8 @@ const api = {
removeServer: (server: MCPServer) => ipcRenderer.invoke('mcp:remove-server', server), removeServer: (server: MCPServer) => ipcRenderer.invoke('mcp:remove-server', server),
listTools: (server: MCPServer) => ipcRenderer.invoke('mcp:list-tools', server), listTools: (server: MCPServer) => ipcRenderer.invoke('mcp:list-tools', server),
callTool: ({ server, name, args }: { server: MCPServer; name: string; args: any }) => callTool: ({ server, name, args }: { server: MCPServer; name: string; args: any }) =>
ipcRenderer.invoke('mcp:call-tool', { server, name, args }) ipcRenderer.invoke('mcp:call-tool', { server, name, args }),
getInstallInfo: () => ipcRenderer.invoke('mcp:get-install-info')
}, },
shell: { shell: {
openExternal: shell.openExternal openExternal: shell.openExternal

View File

@ -1010,7 +1010,9 @@
"type": "Type", "type": "Type",
"updateError": "Failed to update server", "updateError": "Failed to update server",
"updateSuccess": "Server updated successfully", "updateSuccess": "Server updated successfully",
"url": "URL" "url": "URL",
"editMcpJson": "Edit MCP Configuration",
"installHelp": "Get Installation Help"
}, },
"messages.divider": "Show divider between messages", "messages.divider": "Show divider between messages",
"messages.grid_columns": "Message grid display columns", "messages.grid_columns": "Message grid display columns",

View File

@ -1009,7 +1009,9 @@
"url": "URL", "url": "URL",
"errors": { "errors": {
"32000": "MCP サーバーが起動しませんでした。パラメーターを確認してください" "32000": "MCP サーバーが起動しませんでした。パラメーターを確認してください"
} },
"editMcpJson": "MCP 設定を編集",
"installHelp": "インストールヘルプを取得"
}, },
"messages.divider": "メッセージ間に区切り線を表示", "messages.divider": "メッセージ間に区切り線を表示",
"messages.grid_columns": "メッセージグリッドの表示列数", "messages.grid_columns": "メッセージグリッドの表示列数",

View File

@ -1009,7 +1009,9 @@
"type": "Тип", "type": "Тип",
"updateError": "Ошибка обновления сервера", "updateError": "Ошибка обновления сервера",
"updateSuccess": "Сервер успешно обновлен", "updateSuccess": "Сервер успешно обновлен",
"url": "URL" "url": "URL",
"editMcpJson": "Редактировать MCP 配置",
"installHelp": "Получить помощь по установке"
}, },
"messages.divider": "Показывать разделитель между сообщениями", "messages.divider": "Показывать разделитель между сообщениями",
"messages.grid_columns": "Количество столбцов сетки сообщений", "messages.grid_columns": "Количество столбцов сетки сообщений",

View File

@ -1010,7 +1010,9 @@
"type": "类型", "type": "类型",
"updateError": "更新服务器失败", "updateError": "更新服务器失败",
"updateSuccess": "服务器更新成功", "updateSuccess": "服务器更新成功",
"url": "URL" "url": "URL",
"editMcpJson": "编辑 MCP 配置",
"installHelp": "获取安装帮助"
}, },
"messages.divider": "消息分割线", "messages.divider": "消息分割线",
"messages.grid_columns": "消息网格展示列数", "messages.grid_columns": "消息网格展示列数",

View File

@ -1009,7 +1009,9 @@
"type": "類型", "type": "類型",
"updateError": "更新伺服器失敗", "updateError": "更新伺服器失敗",
"updateSuccess": "伺服器更新成功", "updateSuccess": "伺服器更新成功",
"url": "URL" "url": "URL",
"editMcpJson": "編輯 MCP 配置",
"installHelp": "獲取安裝幫助"
}, },
"messages.divider": "訊息間顯示分隔線", "messages.divider": "訊息間顯示分隔線",
"messages.grid_columns": "訊息網格展示列數", "messages.grid_columns": "訊息網格展示列數",

View File

@ -101,7 +101,7 @@ const MessageTools: FC<Props> = ({ message }) => {
), ),
children: isDone && result && ( children: isDone && result && (
<ToolResponseContainer style={{ fontFamily, fontSize: '12px' }}> <ToolResponseContainer style={{ fontFamily, fontSize: '12px' }}>
<pre>{JSON.stringify(result, null, 2)}</pre> <CodeBlock>{JSON.stringify(result, null, 2)}</CodeBlock>
</ToolResponseContainer> </ToolResponseContainer>
) )
}) })
@ -144,7 +144,7 @@ const MessageTools: FC<Props> = ({ message }) => {
aria-label={t('common.copy')}> aria-label={t('common.copy')}>
<i className="iconfont icon-copy"></i> <i className="iconfont icon-copy"></i>
</ActionButton> </ActionButton>
<pre>{expandedResponse.content}</pre> <CodeBlock>{expandedResponse.content}</CodeBlock>
</ExpandedResponseContainer> </ExpandedResponseContainer>
)} )}
</Modal> </Modal>
@ -255,13 +255,14 @@ const ToolResponseContainer = styled.div`
max-height: 300px; max-height: 300px;
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
position: relative; position: relative;
`
pre { const CodeBlock = styled.pre`
margin: 0; margin: 0;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
color: var(--color-text); color: var(--color-text);
} font-family: ubuntu;
` `
const ExpandedResponseContainer = styled.div` const ExpandedResponseContainer = styled.div`

View File

@ -0,0 +1,152 @@
import { TopView } from '@renderer/components/TopView'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setMCPServers } from '@renderer/store/mcp'
import { MCPServer } from '@renderer/types'
import { Modal, Typography } from 'antd'
import TextArea from 'antd/es/input/TextArea'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
interface Props {
resolve: (data: any) => void
}
const PopupContainer: React.FC<Props> = ({ resolve }) => {
const [open, setOpen] = useState(true)
const [jsonConfig, setJsonConfig] = useState('')
const [jsonSaving, setJsonSaving] = useState(false)
const [jsonError, setJsonError] = useState('')
const mcpServers = useAppSelector((state) => state.mcp.servers)
const dispatch = useAppDispatch()
const { t } = useTranslation()
useEffect(() => {
try {
const mcpServersObj: Record<string, any> = {}
mcpServers.forEach((server) => {
const { id, ...serverData } = server
mcpServersObj[id] = serverData
})
const standardFormat = {
mcpServers: mcpServersObj
}
const formattedJson = JSON.stringify(standardFormat, null, 2)
setJsonConfig(formattedJson)
setJsonError('')
} catch (error) {
console.error('Failed to format JSON:', error)
setJsonError(t('settings.mcp.jsonFormatError'))
}
}, [mcpServers, t])
const onOk = async () => {
setJsonSaving(true)
try {
if (!jsonConfig.trim()) {
dispatch(setMCPServers([]))
window.message.success(t('settings.mcp.jsonSaveSuccess'))
setJsonError('')
setJsonSaving(false)
return
}
const parsedConfig = JSON.parse(jsonConfig)
if (!parsedConfig.mcpServers || typeof parsedConfig.mcpServers !== 'object') {
throw new Error(t('settings.mcp.invalidMcpFormat'))
}
const serversArray: MCPServer[] = []
for (const [id, serverConfig] of Object.entries(parsedConfig.mcpServers)) {
const server: MCPServer = {
id,
isActive: false,
...(serverConfig as any)
}
serversArray.push(server)
}
dispatch(setMCPServers(serversArray))
window.message.success(t('settings.mcp.jsonSaveSuccess'))
setJsonError('')
setOpen(false)
} catch (error: any) {
console.error('Failed to save JSON config:', error)
setJsonError(error.message || t('settings.mcp.jsonSaveError'))
window.message.error(t('settings.mcp.jsonSaveError'))
} finally {
setJsonSaving(false)
}
}
const onCancel = () => {
setOpen(false)
}
const onClose = () => {
resolve({})
}
EditMcpJsonPopup.hide = onCancel
return (
<Modal
title={t('settings.mcp.editJson')}
open={open}
onOk={onOk}
onCancel={onCancel}
afterClose={onClose}
width={800}
height="80vh"
loading={jsonSaving}
transitionName="ant-move-down"
centered>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Typography.Text type="secondary">
{jsonError ? <span style={{ color: 'red' }}>{jsonError}</span> : ''}
</Typography.Text>
</div>
<TextArea
value={jsonConfig}
onChange={(e) => setJsonConfig(e.target.value)}
style={{
width: '100%',
fontFamily: 'monospace',
minHeight: '60vh',
marginBottom: '16px'
}}
onFocus={() => setJsonError('')}
/>
<Typography.Text type="secondary">{t('settings.mcp.jsonModeHint')}</Typography.Text>
</Modal>
)
}
const TopViewKey = 'EditMcpJsonPopup'
export default class EditMcpJsonPopup {
static topviewId = 0
static hide() {
TopView.hide(TopViewKey)
}
static show() {
return new Promise<any>((resolve) => {
TopView.show(
<PopupContainer
resolve={(v) => {
resolve(v)
TopView.hide(TopViewKey)
}}
/>,
TopViewKey
)
})
}
}

View File

@ -1,23 +1,37 @@
import { CheckCircleOutlined, QuestionCircleOutlined, WarningOutlined } from '@ant-design/icons'
import { Center, VStack } from '@renderer/components/Layout'
import { EventEmitter } from '@renderer/services/EventService'
import { Alert, Button } from 'antd' import { Alert, Button } from 'antd'
import { FC, useEffect, useState } from 'react' import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
import { SettingRow, SettingSubtitle } from '..' import { SettingDescription, SettingRow, SettingSubtitle } from '..'
const InstallNpxUv: FC = () => { interface Props {
mini?: boolean
}
const InstallNpxUv: FC<Props> = ({ mini = false }) => {
const [isUvInstalled, setIsUvInstalled] = useState(true) const [isUvInstalled, setIsUvInstalled] = useState(true)
const [isBunInstalled, setIsBunInstalled] = useState(true) const [isBunInstalled, setIsBunInstalled] = useState(true)
const [isInstallingUv, setIsInstallingUv] = useState(false) const [isInstallingUv, setIsInstallingUv] = useState(false)
const [isInstallingBun, setIsInstallingBun] = useState(false) const [isInstallingBun, setIsInstallingBun] = useState(false)
const [uvPath, setUvPath] = useState<string | null>(null)
const [bunPath, setBunPath] = useState<string | null>(null)
const [binariesDir, setBinariesDir] = useState<string | null>(null)
const { t } = useTranslation() const { t } = useTranslation()
const checkBinaries = async () => { const checkBinaries = async () => {
const uvExists = await window.api.isBinaryExist('uv') const uvExists = await window.api.isBinaryExist('uv')
const bunExists = await window.api.isBinaryExist('bun') const bunExists = await window.api.isBinaryExist('bun')
const { uvPath, bunPath, dir } = await window.api.mcp.getInstallInfo()
setIsUvInstalled(uvExists) setIsUvInstalled(uvExists)
setIsBunInstalled(bunExists) setIsBunInstalled(bunExists)
setUvPath(uvPath)
setBunPath(bunPath)
setBinariesDir(dir)
} }
const installUV = async () => { const installUV = async () => {
@ -53,56 +67,99 @@ const InstallNpxUv: FC = () => {
checkBinaries() checkBinaries()
}, []) }, [])
if (isUvInstalled && isBunInstalled) { if (mini) {
return null const installed = isUvInstalled && isBunInstalled
return (
<Button
type="primary"
size="small"
variant="filled"
shape="circle"
icon={installed ? <CheckCircleOutlined /> : <WarningOutlined />}
className="nodrag"
color={installed ? 'green' : 'danger'}
onClick={() => EventEmitter.emit('mcp:mcp-install')}
/>
)
}
const openBinariesDir = () => {
if (binariesDir) {
window.api.openPath(binariesDir)
}
}
const onHelp = () => {
window.open('https://docs.cherry-ai.com/advanced-basic/mcp', '_blank')
} }
return ( return (
<Container> <Container>
{!isUvInstalled && ( <Alert
<Alert type={isUvInstalled ? 'success' : 'warning'}
type="warning" banner
banner description={
style={{ padding: 8 }} <VStack>
description={ <SettingRow style={{ width: '100%' }}>
<SettingRow>
<SettingSubtitle style={{ margin: 0, fontWeight: 'normal' }}> <SettingSubtitle style={{ margin: 0, fontWeight: 'normal' }}>
{isUvInstalled ? 'UV Installed' : `UV ${t('settings.mcp.missingDependencies')}`} {isUvInstalled ? 'UV Installed' : `UV ${t('settings.mcp.missingDependencies')}`}
</SettingSubtitle> </SettingSubtitle>
<Button {!isUvInstalled && (
type="primary" <Button
onClick={installUV} type="primary"
loading={isInstallingUv} onClick={installUV}
disabled={isInstallingUv} loading={isInstallingUv}
size="small"> disabled={isInstallingUv}
{isInstallingUv ? t('settings.mcp.dependenciesInstalling') : t('settings.mcp.install')} size="small">
</Button> {isInstallingUv ? t('settings.mcp.dependenciesInstalling') : t('settings.mcp.install')}
</Button>
)}
</SettingRow> </SettingRow>
} <SettingRow style={{ width: '100%' }}>
/> <SettingDescription
)} onClick={openBinariesDir}
{!isBunInstalled && ( style={{ margin: 0, fontWeight: 'normal', cursor: 'pointer' }}>
<Alert {uvPath}
type="warning" </SettingDescription>
banner </SettingRow>
style={{ padding: 8 }} </VStack>
description={ }
<SettingRow> />
<Alert
type={isBunInstalled ? 'success' : 'warning'}
banner
description={
<VStack>
<SettingRow style={{ width: '100%' }}>
<SettingSubtitle style={{ margin: 0, fontWeight: 'normal' }}> <SettingSubtitle style={{ margin: 0, fontWeight: 'normal' }}>
{isBunInstalled ? 'Bun Installed' : `Bun ${t('settings.mcp.missingDependencies')}`} {isBunInstalled ? 'Bun Installed' : `Bun ${t('settings.mcp.missingDependencies')}`}
</SettingSubtitle> </SettingSubtitle>
<Button {!isBunInstalled && (
type="primary" <Button
onClick={installBun} type="primary"
loading={isInstallingBun} onClick={installBun}
disabled={isInstallingBun} loading={isInstallingBun}
size="small"> disabled={isInstallingBun}
{isInstallingBun ? t('settings.mcp.dependenciesInstalling') : t('settings.mcp.install')} size="small">
</Button> {isInstallingBun ? t('settings.mcp.dependenciesInstalling') : t('settings.mcp.install')}
</Button>
)}
</SettingRow> </SettingRow>
} <SettingRow style={{ width: '100%' }}>
/> <SettingDescription
)} onClick={openBinariesDir}
style={{ margin: 0, fontWeight: 'normal', cursor: 'pointer' }}>
{bunPath}
</SettingDescription>
</SettingRow>
</VStack>
}
/>
<Center>
<Button type="link" onClick={onHelp} icon={<QuestionCircleOutlined />}>
{t('settings.mcp.installHelp')}
</Button>
</Center>
</Container> </Container>
) )
} }
@ -111,7 +168,7 @@ const Container = styled.div`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
margin-bottom: 20px; margin-bottom: 20px;
gap: 10px; gap: 12px;
` `
export default InstallNpxUv export default InstallNpxUv

View File

@ -7,7 +7,6 @@ import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
import { SettingContainer, SettingDivider, SettingTitle } from '..' import { SettingContainer, SettingDivider, SettingTitle } from '..'
import InstallNpxUv from './InstallNpxUv'
interface Props { interface Props {
server: MCPServer server: MCPServer
@ -165,7 +164,6 @@ const McpSettings: React.FC<Props> = ({ server }) => {
return ( return (
<SettingContainer style={{ background: 'transparent' }}> <SettingContainer style={{ background: 'transparent' }}>
<InstallNpxUv />
<SettingTitle> <SettingTitle>
<Flex align="center" gap={8}> <Flex align="center" gap={8}>
<ServerName>{server?.name}</ServerName> <ServerName>{server?.name}</ServerName>

View File

@ -0,0 +1,49 @@
import { EditOutlined, ExportOutlined, SearchOutlined } from '@ant-design/icons'
import { NavbarRight } from '@renderer/components/app/Navbar'
import { HStack } from '@renderer/components/Layout'
import { EventEmitter } from '@renderer/services/EventService'
import { Button } from 'antd'
import { useTranslation } from 'react-i18next'
import EditMcpJsonPopup from './EditMcpJsonPopup'
import InstallNpxUv from './InstallNpxUv'
export const McpSettingsNavbar = () => {
const { t } = useTranslation()
const onClick = () => window.open('https://mcp.so/', '_blank')
return (
<NavbarRight>
<HStack alignItems="center" gap={5}>
<Button
size="small"
type="text"
onClick={() => EventEmitter.emit('mcp:npx-search')}
icon={<SearchOutlined />}
className="nodrag"
style={{ fontSize: 13, height: 28, borderRadius: 20 }}>
{t('settings.mcp.searchNpx')}
</Button>
<Button
size="small"
type="text"
onClick={() => EditMcpJsonPopup.show()}
icon={<EditOutlined />}
className="nodrag"
style={{ fontSize: 13, height: 28, borderRadius: 20 }}>
{t('settings.mcp.editMcpJson')}
</Button>
<Button
size="small"
type="text"
onClick={onClick}
icon={<ExportOutlined />}
className="nodrag"
style={{ fontSize: 13, height: 28, borderRadius: 20 }}>
{t('settings.mcp.findMore')}
</Button>
<InstallNpxUv mini />
</HStack>
</NavbarRight>
)
}

View File

@ -1,21 +1,22 @@
import { CodeOutlined, DeleteOutlined, ExportOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons' import { CodeOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons'
import { nanoid } from '@reduxjs/toolkit' import { nanoid } from '@reduxjs/toolkit'
import { NavbarRight } from '@renderer/components/app/Navbar'
import DragableList from '@renderer/components/DragableList' import DragableList from '@renderer/components/DragableList'
import IndicatorLight from '@renderer/components/IndicatorLight' import IndicatorLight from '@renderer/components/IndicatorLight'
import { HStack } from '@renderer/components/Layout' import { HStack } from '@renderer/components/Layout'
import ListItem from '@renderer/components/ListItem' import ListItem from '@renderer/components/ListItem'
import Scrollbar from '@renderer/components/Scrollbar' import Scrollbar from '@renderer/components/Scrollbar'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useMCPServers } from '@renderer/hooks/useMCPServers' import { useMCPServers } from '@renderer/hooks/useMCPServers'
import { EventEmitter } from '@renderer/services/EventService' import { EventEmitter } from '@renderer/services/EventService'
import { MCPServer } from '@renderer/types' import { MCPServer } from '@renderer/types'
import { Button, Dropdown, MenuProps } from 'antd' import { Dropdown, MenuProps } from 'antd'
import { isEmpty } from 'lodash' import { isEmpty } from 'lodash'
import { FC, useCallback, useEffect, useState } from 'react' import { FC, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
import { SettingContainer } from '..' import { SettingContainer } from '..'
import InstallNpxUv from './InstallNpxUv'
import McpSettings from './McpSettings' import McpSettings from './McpSettings'
import NpxSearch from './NpxSearch' import NpxSearch from './NpxSearch'
@ -23,11 +24,15 @@ const MCPSettings: FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const { mcpServers, addMCPServer, updateMcpServers, deleteMCPServer } = useMCPServers() const { mcpServers, addMCPServer, updateMcpServers, deleteMCPServer } = useMCPServers()
const [selectedMcpServer, setSelectedMcpServer] = useState<MCPServer | null>(mcpServers[0]) const [selectedMcpServer, setSelectedMcpServer] = useState<MCPServer | null>(mcpServers[0])
const [isNpxSearch, setIsNpxSearch] = useState(false) const [route, setRoute] = useState<'npx-search' | 'mcp-install' | null>(null)
const { theme } = useTheme()
useEffect(() => { useEffect(() => {
const unsub = EventEmitter.on('open-npx-search', () => setIsNpxSearch(true)) const unsubs = [
return () => unsub() EventEmitter.on('mcp:npx-search', () => setRoute('npx-search')),
EventEmitter.on('mcp:mcp-install', () => setRoute('mcp-install'))
]
return () => unsubs.forEach((unsub) => unsub())
}, []) }, [])
const onAddMcpServer = async () => { const onAddMcpServer = async () => {
@ -83,6 +88,30 @@ const MCPSettings: FC = () => {
setSelectedMcpServer(_selectedMcpServer || mcpServers[0]) setSelectedMcpServer(_selectedMcpServer || mcpServers[0])
}, [mcpServers, selectedMcpServer]) }, [mcpServers, selectedMcpServer])
const MainContent = () => {
if (route === 'npx-search' || isEmpty(mcpServers)) {
return (
<SettingContainer theme={theme}>
<NpxSearch />
</SettingContainer>
)
}
if (route === 'mcp-install') {
return (
<SettingContainer theme={theme}>
<InstallNpxUv />
</SettingContainer>
)
}
if (selectedMcpServer) {
return <McpSettings server={selectedMcpServer} />
}
return <NpxSearch />
}
return ( return (
<Container> <Container>
<McpList> <McpList>
@ -105,7 +134,7 @@ const MCPSettings: FC = () => {
active={selectedMcpServer?.id === server.id} active={selectedMcpServer?.id === server.id}
onClick={() => { onClick={() => {
setSelectedMcpServer(server) setSelectedMcpServer(server)
setIsNpxSearch(false) setRoute(null)
}} }}
titleStyle={{ fontWeight: 500 }} titleStyle={{ fontWeight: 500 }}
icon={<CodeOutlined />} icon={<CodeOutlined />}
@ -124,48 +153,11 @@ const MCPSettings: FC = () => {
)} )}
</DragableList> </DragableList>
</McpList> </McpList>
<MainContent />
{isNpxSearch || isEmpty(mcpServers) ? (
<SettingContainer>
<NpxSearch />
</SettingContainer>
) : (
selectedMcpServer && <McpSettings server={selectedMcpServer} />
)}
</Container> </Container>
) )
} }
export const McpSettingsNavbar = () => {
const { t } = useTranslation()
const onClick = () => window.open('https://mcp.so/', '_blank')
return (
<NavbarRight>
<HStack alignItems="center" gap={5}>
<Button
size="small"
type="text"
onClick={() => EventEmitter.emit('open-npx-search')}
icon={<SearchOutlined />}
className="nodrag"
style={{ fontSize: 14 }}>
{t('settings.mcp.searchNpx')}
</Button>
<Button
size="small"
type="text"
onClick={onClick}
icon={<ExportOutlined />}
className="nodrag"
style={{ fontSize: 14 }}>
{t('settings.mcp.findMore')}
</Button>
</HStack>
</NavbarRight>
)
}
const Container = styled(HStack)` const Container = styled(HStack)`
flex: 1; flex: 1;
` `

View File

@ -21,7 +21,8 @@ import AboutSettings from './AboutSettings'
import DataSettings from './DataSettings/DataSettings' import DataSettings from './DataSettings/DataSettings'
import DisplaySettings from './DisplaySettings/DisplaySettings' import DisplaySettings from './DisplaySettings/DisplaySettings'
import GeneralSettings from './GeneralSettings' import GeneralSettings from './GeneralSettings'
import MCPSettings, { McpSettingsNavbar } from './MCPSettings' import MCPSettings from './MCPSettings'
import { McpSettingsNavbar } from './MCPSettings/McpSettingsNavbar'
import ProvidersList from './ProviderSettings' import ProvidersList from './ProviderSettings'
import QuickAssistantSettings from './QuickAssistantSettings' import QuickAssistantSettings from './QuickAssistantSettings'
import ShortcutSettings from './ShortcutSettings' import ShortcutSettings from './ShortcutSettings'