mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-24 10:40:07 +08:00
597 lines
20 KiB
TypeScript
597 lines
20 KiB
TypeScript
import { DeleteOutlined, SaveOutlined } from '@ant-design/icons'
|
|
import { useTheme } from '@renderer/context/ThemeProvider'
|
|
import { useMCPServers } from '@renderer/hooks/useMCPServers'
|
|
import MCPDescription from '@renderer/pages/settings/MCPSettings/McpDescription'
|
|
import { MCPPrompt, MCPResource, MCPServer, MCPTool } from '@renderer/types'
|
|
import { Button, Flex, Form, Input, Radio, Switch, Tabs } from 'antd'
|
|
import TextArea from 'antd/es/input/TextArea'
|
|
import React, { useCallback, useEffect, useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { useLocation, useNavigate } from 'react-router'
|
|
import styled from 'styled-components'
|
|
|
|
import { SettingContainer, SettingDivider, SettingGroup, SettingTitle } from '..'
|
|
import MCPPromptsSection from './McpPrompt'
|
|
import MCPResourcesSection from './McpResource'
|
|
import MCPToolsSection from './McpTool'
|
|
|
|
interface MCPFormValues {
|
|
name: string
|
|
description?: string
|
|
serverType: MCPServer['type']
|
|
baseUrl?: string
|
|
command?: string
|
|
registryUrl?: string
|
|
args?: string
|
|
env?: string
|
|
isActive: boolean
|
|
headers?: string
|
|
}
|
|
|
|
interface Registry {
|
|
name: string
|
|
url: string
|
|
}
|
|
|
|
const NpmRegistry: Registry[] = [{ name: '淘宝 NPM Mirror', url: 'https://registry.npmmirror.com' }]
|
|
const PipRegistry: Registry[] = [
|
|
{ name: '清华大学', url: 'https://pypi.tuna.tsinghua.edu.cn/simple' },
|
|
{ name: '阿里云', url: 'http://mirrors.aliyun.com/pypi/simple/' },
|
|
{ name: '中国科学技术大学', url: 'https://mirrors.ustc.edu.cn/pypi/simple/' },
|
|
{ name: '华为云', url: 'https://repo.huaweicloud.com/repository/pypi/simple/' },
|
|
{ name: '腾讯云', url: 'https://mirrors.cloud.tencent.com/pypi/simple/' }
|
|
]
|
|
|
|
type TabKey = 'settings' | 'tools' | 'prompts' | 'resources'
|
|
|
|
const parseKeyValueString = (str: string): Record<string, string> => {
|
|
const result: Record<string, string> = {}
|
|
str.split('\n').forEach((line) => {
|
|
if (line.trim()) {
|
|
const [key, ...value] = line.split('=')
|
|
const formatValue = value.join('=').trim()
|
|
const formatKey = key.trim()
|
|
if (formatKey && formatValue) {
|
|
result[formatKey] = formatValue
|
|
}
|
|
}
|
|
})
|
|
return result
|
|
}
|
|
|
|
const McpSettings: React.FC = () => {
|
|
const { t } = useTranslation()
|
|
const {
|
|
server: { id: serverId }
|
|
} = useLocation().state as { server: MCPServer }
|
|
const { mcpServers } = useMCPServers()
|
|
const server = mcpServers.find((it) => it.id === serverId) as MCPServer
|
|
const { deleteMCPServer, updateMCPServer } = useMCPServers()
|
|
const [serverType, setServerType] = useState<MCPServer['type']>('stdio')
|
|
const [form] = Form.useForm<MCPFormValues>()
|
|
const [loading, setLoading] = useState(false)
|
|
const [isFormChanged, setIsFormChanged] = useState(false)
|
|
const [loadingServer, setLoadingServer] = useState<string | null>(null)
|
|
const [activeTab, setActiveTab] = useState<TabKey>('settings')
|
|
|
|
const [tools, setTools] = useState<MCPTool[]>([])
|
|
const [prompts, setPrompts] = useState<MCPPrompt[]>([])
|
|
const [resources, setResources] = useState<MCPResource[]>([])
|
|
const [isShowRegistry, setIsShowRegistry] = useState(false)
|
|
const [registry, setRegistry] = useState<Registry[]>()
|
|
|
|
const { theme } = useTheme()
|
|
|
|
const navigate = useNavigate()
|
|
|
|
useEffect(() => {
|
|
const serverType: MCPServer['type'] = server.type || (server.baseUrl ? 'sse' : 'stdio')
|
|
setServerType(serverType)
|
|
|
|
// Set registry UI state based on command and registryUrl
|
|
if (server.command) {
|
|
handleCommandChange(server.command)
|
|
|
|
// If there's a registryUrl, ensure registry UI is shown
|
|
if (server.registryUrl) {
|
|
setIsShowRegistry(true)
|
|
|
|
// Determine registry type based on command
|
|
if (server.command.includes('uv') || server.command.includes('uvx')) {
|
|
setRegistry(PipRegistry)
|
|
} else if (
|
|
server.command.includes('npx') ||
|
|
server.command.includes('bun') ||
|
|
server.command.includes('bunx')
|
|
) {
|
|
setRegistry(NpmRegistry)
|
|
}
|
|
}
|
|
}
|
|
|
|
form.setFieldsValue({
|
|
name: server.name,
|
|
description: server.description,
|
|
serverType: serverType,
|
|
baseUrl: server.baseUrl || '',
|
|
command: server.command || '',
|
|
registryUrl: server.registryUrl || '',
|
|
isActive: server.isActive,
|
|
args: server.args ? server.args.join('\n') : '',
|
|
env: server.env
|
|
? Object.entries(server.env)
|
|
.map(([key, value]) => `${key}=${value}`)
|
|
.join('\n')
|
|
: '',
|
|
headers: server.headers
|
|
? Object.entries(server.headers)
|
|
.map(([key, value]) => `${key}=${value}`)
|
|
.join('\n')
|
|
: ''
|
|
})
|
|
}, [server, form])
|
|
|
|
useEffect(() => {
|
|
const currentServerType = form.getFieldValue('serverType')
|
|
if (currentServerType) {
|
|
setServerType(currentServerType)
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [form.getFieldValue('serverType')])
|
|
|
|
const fetchTools = async () => {
|
|
if (server.isActive) {
|
|
try {
|
|
setLoadingServer(server.id)
|
|
const localTools = await window.api.mcp.listTools(server)
|
|
setTools(localTools)
|
|
} catch (error) {
|
|
window.message.error({
|
|
content: t('settings.mcp.tools.loadError') + ' ' + formatError(error),
|
|
key: 'mcp-tools-error'
|
|
})
|
|
} finally {
|
|
setLoadingServer(null)
|
|
}
|
|
}
|
|
}
|
|
|
|
const fetchPrompts = async () => {
|
|
if (server.isActive) {
|
|
try {
|
|
setLoadingServer(server.id)
|
|
const localPrompts = await window.api.mcp.listPrompts(server)
|
|
setPrompts(localPrompts)
|
|
} catch (error) {
|
|
window.message.error({
|
|
content: t('settings.mcp.prompts.loadError') + ' ' + formatError(error),
|
|
key: 'mcp-prompts-error'
|
|
})
|
|
setPrompts([])
|
|
} finally {
|
|
setLoadingServer(null)
|
|
}
|
|
}
|
|
}
|
|
|
|
const fetchResources = async () => {
|
|
if (server.isActive) {
|
|
try {
|
|
setLoadingServer(server.id)
|
|
const localResources = await window.api.mcp.listResources(server)
|
|
setResources(localResources)
|
|
} catch (error) {
|
|
window.message.error({
|
|
content: t('settings.mcp.resources.loadError') + ' ' + formatError(error),
|
|
key: 'mcp-resources-error'
|
|
})
|
|
setResources([])
|
|
} finally {
|
|
setLoadingServer(null)
|
|
}
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (server.isActive) {
|
|
fetchTools()
|
|
fetchPrompts()
|
|
fetchResources()
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [server.id, server.isActive])
|
|
|
|
useEffect(() => {
|
|
setIsFormChanged(false)
|
|
}, [server.id])
|
|
|
|
// Save the form data
|
|
const onSave = async () => {
|
|
setLoading(true)
|
|
try {
|
|
const values = await form.validateFields()
|
|
|
|
// set basic fields
|
|
const mcpServer: MCPServer = {
|
|
id: server.id,
|
|
name: values.name,
|
|
type: values.serverType || server.type,
|
|
description: values.description,
|
|
isActive: values.isActive,
|
|
registryUrl: values.registryUrl,
|
|
searchKey: server.searchKey
|
|
}
|
|
|
|
// set stdio or sse server
|
|
if (values.serverType === 'sse' || server.type === 'streamableHttp') {
|
|
mcpServer.baseUrl = values.baseUrl
|
|
} else {
|
|
mcpServer.command = values.command
|
|
mcpServer.args = values.args ? values.args.split('\n').filter((arg) => arg.trim() !== '') : []
|
|
}
|
|
|
|
// set env variables
|
|
if (values.env) {
|
|
mcpServer.env = parseKeyValueString(values.env)
|
|
}
|
|
|
|
if (values.headers) {
|
|
mcpServer.headers = parseKeyValueString(values.headers)
|
|
}
|
|
|
|
try {
|
|
await window.api.mcp.restartServer(mcpServer)
|
|
updateMCPServer({ ...mcpServer, isActive: true })
|
|
window.message.success({ content: t('settings.mcp.updateSuccess'), key: 'mcp-update-success' })
|
|
setLoading(false)
|
|
setIsFormChanged(false)
|
|
} catch (error: any) {
|
|
updateMCPServer({ ...mcpServer, isActive: false })
|
|
window.modal.error({
|
|
title: t('settings.mcp.updateError'),
|
|
content: error.message,
|
|
centered: true
|
|
})
|
|
setLoading(false)
|
|
}
|
|
} catch (error: any) {
|
|
setLoading(false)
|
|
console.error('Failed to save MCP server settings:', error)
|
|
}
|
|
}
|
|
|
|
// Watch for command field changes
|
|
const handleCommandChange = (command: string) => {
|
|
if (command.includes('uv') || command.includes('uvx')) {
|
|
setIsShowRegistry(true)
|
|
setRegistry(PipRegistry)
|
|
} else if (command.includes('npx') || command.includes('bun') || command.includes('bunx')) {
|
|
setIsShowRegistry(true)
|
|
setRegistry(NpmRegistry)
|
|
} else {
|
|
setIsShowRegistry(false)
|
|
setRegistry(undefined)
|
|
}
|
|
}
|
|
|
|
const onSelectRegistry = (url: string) => {
|
|
const command = form.getFieldValue('command') || ''
|
|
|
|
// Add new registry env variables
|
|
if (command.includes('uv') || command.includes('uvx')) {
|
|
// envs['PIP_INDEX_URL'] = url
|
|
// envs['UV_DEFAULT_INDEX'] = url
|
|
form.setFieldsValue({ registryUrl: url })
|
|
} else if (command.includes('npx') || command.includes('bun') || command.includes('bunx')) {
|
|
// envs['NPM_CONFIG_REGISTRY'] = url
|
|
form.setFieldsValue({ registryUrl: url })
|
|
}
|
|
|
|
// Mark form as changed
|
|
setIsFormChanged(true)
|
|
}
|
|
|
|
const onDeleteMcpServer = useCallback(
|
|
async (server: MCPServer) => {
|
|
try {
|
|
window.modal.confirm({
|
|
title: t('settings.mcp.deleteServer'),
|
|
content: t('settings.mcp.deleteServerConfirm'),
|
|
centered: true,
|
|
onOk: async () => {
|
|
await window.api.mcp.removeServer(server)
|
|
deleteMCPServer(server.id)
|
|
window.message.success({ content: t('settings.mcp.deleteSuccess'), key: 'mcp-list' })
|
|
navigate('/settings/mcp')
|
|
}
|
|
})
|
|
} catch (error: any) {
|
|
window.message.error({
|
|
content: `${t('settings.mcp.deleteError')}: ${error.message}`,
|
|
key: 'mcp-list'
|
|
})
|
|
}
|
|
},
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[server, t]
|
|
)
|
|
|
|
const formatError = (error: any) => {
|
|
if (error.message.includes('32000')) {
|
|
return t('settings.mcp.errors.32000')
|
|
}
|
|
|
|
return error.message
|
|
}
|
|
|
|
const onToggleActive = async (active: boolean) => {
|
|
if (isFormChanged && active) {
|
|
await onSave()
|
|
return
|
|
}
|
|
|
|
await form.validateFields()
|
|
setLoadingServer(server.id)
|
|
const oldActiveState = server.isActive
|
|
|
|
try {
|
|
if (active) {
|
|
const localTools = await window.api.mcp.listTools(server)
|
|
setTools(localTools)
|
|
|
|
const localPrompts = await window.api.mcp.listPrompts(server)
|
|
setPrompts(localPrompts)
|
|
|
|
const localResources = await window.api.mcp.listResources(server)
|
|
setResources(localResources)
|
|
} else {
|
|
await window.api.mcp.stopServer(server)
|
|
}
|
|
updateMCPServer({ ...server, isActive: active })
|
|
} catch (error: any) {
|
|
window.modal.error({
|
|
title: t('settings.mcp.startError'),
|
|
content: formatError(error),
|
|
centered: true
|
|
})
|
|
updateMCPServer({ ...server, isActive: oldActiveState })
|
|
} finally {
|
|
setLoadingServer(null)
|
|
}
|
|
}
|
|
|
|
// Handle toggling a tool on/off
|
|
const handleToggleTool = useCallback(
|
|
async (tool: MCPTool, enabled: boolean) => {
|
|
// Create a new disabledTools array or use the existing one
|
|
let disabledTools = [...(server.disabledTools || [])]
|
|
|
|
if (enabled) {
|
|
// Remove tool from disabledTools if it's being enabled
|
|
disabledTools = disabledTools.filter((name) => name !== tool.name)
|
|
} else {
|
|
// Add tool to disabledTools if it's being disabled
|
|
if (!disabledTools.includes(tool.name)) {
|
|
disabledTools.push(tool.name)
|
|
}
|
|
}
|
|
|
|
// Update the server with new disabledTools
|
|
const updatedServer = {
|
|
...server,
|
|
disabledTools
|
|
}
|
|
|
|
// Save the updated server configuration
|
|
// await window.api.mcp.updateServer(updatedServer)
|
|
updateMCPServer(updatedServer)
|
|
},
|
|
[server, updateMCPServer]
|
|
)
|
|
|
|
const tabs = [
|
|
{
|
|
key: 'settings',
|
|
label: t('settings.mcp.tabs.general'),
|
|
children: (
|
|
<Form
|
|
form={form}
|
|
layout="vertical"
|
|
onValuesChange={() => setIsFormChanged(true)}
|
|
style={{
|
|
overflowY: 'auto',
|
|
width: 'calc(100% + 10px)',
|
|
paddingRight: '10px'
|
|
}}>
|
|
<Form.Item name="name" label={t('settings.mcp.name')} rules={[{ required: true, message: '' }]}>
|
|
<Input placeholder={t('common.name')} disabled={server.type === 'inMemory'} />
|
|
</Form.Item>
|
|
<Form.Item name="description" label={t('settings.mcp.description')}>
|
|
<TextArea rows={2} placeholder={t('common.description')} />
|
|
</Form.Item>
|
|
{server.type !== 'inMemory' && (
|
|
<Form.Item
|
|
name="serverType"
|
|
label={t('settings.mcp.type')}
|
|
rules={[{ required: true }]}
|
|
initialValue="stdio">
|
|
<Radio.Group
|
|
onChange={(e) => setServerType(e.target.value)}
|
|
options={[
|
|
{ label: t('settings.mcp.stdio'), value: 'stdio' },
|
|
{ label: t('settings.mcp.sse'), value: 'sse' },
|
|
{ label: t('settings.mcp.streamableHttp'), value: 'streamableHttp' }
|
|
]}
|
|
/>
|
|
</Form.Item>
|
|
)}
|
|
{serverType === 'sse' && (
|
|
<>
|
|
<Form.Item
|
|
name="baseUrl"
|
|
label={t('settings.mcp.url')}
|
|
rules={[{ required: serverType === 'sse', message: '' }]}
|
|
tooltip={t('settings.mcp.baseUrlTooltip')}>
|
|
<Input placeholder="http://localhost:3000/sse" />
|
|
</Form.Item>
|
|
<Form.Item name="headers" label={t('settings.mcp.headers')} tooltip={t('settings.mcp.headersTooltip')}>
|
|
<TextArea
|
|
rows={3}
|
|
placeholder={`Content-Type=application/json\nAuthorization=Bearer token`}
|
|
style={{ fontFamily: 'monospace' }}
|
|
/>
|
|
</Form.Item>
|
|
</>
|
|
)}
|
|
{serverType === 'streamableHttp' && (
|
|
<>
|
|
<Form.Item
|
|
name="baseUrl"
|
|
label={t('settings.mcp.url')}
|
|
rules={[{ required: serverType === 'streamableHttp', message: '' }]}
|
|
tooltip={t('settings.mcp.baseUrlTooltip')}>
|
|
<Input placeholder="http://localhost:3000/mcp" />
|
|
</Form.Item>
|
|
<Form.Item name="headers" label={t('settings.mcp.headers')} tooltip={t('settings.mcp.headersTooltip')}>
|
|
<TextArea
|
|
rows={3}
|
|
placeholder={`Content-Type=application/json\nAuthorization=Bearer token`}
|
|
style={{ fontFamily: 'monospace' }}
|
|
/>
|
|
</Form.Item>
|
|
</>
|
|
)}
|
|
{serverType === 'stdio' && (
|
|
<>
|
|
<Form.Item
|
|
name="command"
|
|
label={t('settings.mcp.command')}
|
|
rules={[{ required: serverType === 'stdio', message: '' }]}>
|
|
<Input placeholder="uvx or npx" onChange={(e) => handleCommandChange(e.target.value)} />
|
|
</Form.Item>
|
|
|
|
{isShowRegistry && registry && (
|
|
<Form.Item
|
|
name="registryUrl"
|
|
label={t('settings.mcp.registry')}
|
|
tooltip={t('settings.mcp.registryTooltip')}>
|
|
<Radio.Group>
|
|
<Radio
|
|
key="no-proxy"
|
|
value=""
|
|
onChange={(e) => {
|
|
onSelectRegistry(e.target.value)
|
|
}}>
|
|
{t('settings.mcp.registryDefault')}
|
|
</Radio>
|
|
{registry.map((reg) => (
|
|
<Radio
|
|
key={reg.url}
|
|
value={reg.url}
|
|
onChange={(e) => {
|
|
onSelectRegistry(e.target.value)
|
|
}}>
|
|
{reg.name}
|
|
</Radio>
|
|
))}
|
|
</Radio.Group>
|
|
</Form.Item>
|
|
)}
|
|
|
|
<Form.Item name="args" label={t('settings.mcp.args')} tooltip={t('settings.mcp.argsTooltip')}>
|
|
<TextArea rows={3} placeholder={`arg1\narg2`} style={{ fontFamily: 'monospace' }} />
|
|
</Form.Item>
|
|
|
|
<Form.Item name="env" label={t('settings.mcp.env')} tooltip={t('settings.mcp.envTooltip')}>
|
|
<TextArea rows={3} placeholder={`KEY1=value1\nKEY2=value2`} style={{ fontFamily: 'monospace' }} />
|
|
</Form.Item>
|
|
</>
|
|
)}
|
|
{serverType === 'inMemory' && (
|
|
<>
|
|
<Form.Item name="args" label={t('settings.mcp.args')} tooltip={t('settings.mcp.argsTooltip')}>
|
|
<TextArea rows={3} placeholder={`arg1\narg2`} style={{ fontFamily: 'monospace' }} />
|
|
</Form.Item>
|
|
|
|
<Form.Item name="env" label={t('settings.mcp.env')} tooltip={t('settings.mcp.envTooltip')}>
|
|
<TextArea rows={3} placeholder={`KEY1=value1\nKEY2=value2`} style={{ fontFamily: 'monospace' }} />
|
|
</Form.Item>
|
|
</>
|
|
)}
|
|
</Form>
|
|
)
|
|
}
|
|
]
|
|
if (server.searchKey) {
|
|
tabs.push({
|
|
key: 'description',
|
|
label: t('settings.mcp.tabs.description'),
|
|
children: <MCPDescription searchKey={server.searchKey} />
|
|
})
|
|
}
|
|
|
|
if (server.isActive) {
|
|
tabs.push(
|
|
{
|
|
key: 'tools',
|
|
label: t('settings.mcp.tabs.tools'),
|
|
children: <MCPToolsSection tools={tools} server={server} onToggleTool={handleToggleTool} />
|
|
},
|
|
{
|
|
key: 'prompts',
|
|
label: t('settings.mcp.tabs.prompts'),
|
|
children: <MCPPromptsSection prompts={prompts} />
|
|
},
|
|
{
|
|
key: 'resources',
|
|
label: t('settings.mcp.tabs.resources'),
|
|
children: <MCPResourcesSection resources={resources} />
|
|
}
|
|
)
|
|
}
|
|
|
|
return (
|
|
<SettingContainer theme={theme} style={{ width: '100%', paddingTop: 55, backgroundColor: 'transparent' }}>
|
|
<SettingGroup style={{ marginBottom: 0, borderRadius: 'var(--list-item-border-radius)' }}>
|
|
<SettingTitle>
|
|
<Flex justify="space-between" align="center" gap={5} style={{ marginRight: 10 }}>
|
|
<ServerName className="text-nowrap">{server?.name}</ServerName>
|
|
<Button danger icon={<DeleteOutlined />} type="text" onClick={() => onDeleteMcpServer(server)} />
|
|
</Flex>
|
|
<Flex align="center" gap={16}>
|
|
<Switch
|
|
value={server.isActive}
|
|
key={server.id}
|
|
loading={loadingServer === server.id}
|
|
onChange={onToggleActive}
|
|
/>
|
|
<Button
|
|
type="primary"
|
|
icon={<SaveOutlined />}
|
|
onClick={onSave}
|
|
loading={loading}
|
|
shape="round"
|
|
disabled={!isFormChanged || activeTab !== 'settings'}>
|
|
{t('common.save')}
|
|
</Button>
|
|
</Flex>
|
|
</SettingTitle>
|
|
<SettingDivider />
|
|
<Tabs
|
|
defaultActiveKey="settings"
|
|
items={tabs}
|
|
onChange={(key) => setActiveTab(key as TabKey)}
|
|
style={{ marginTop: 8, backgroundColor: 'transparent' }}
|
|
/>
|
|
</SettingGroup>
|
|
</SettingContainer>
|
|
)
|
|
}
|
|
|
|
const ServerName = styled.span`
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
`
|
|
|
|
export default McpSettings
|