feat(MCPSettings): add special error boundary & data validation for mcp server (#9633)

* feat(MCPSettings): 为McpServerCard添加错误边界处理

在McpServerCard组件外层添加ErrorBoundary,防止组件内部错误导致整个页面崩溃

* feat(types): 添加 MCP 服务器配置的类型定义和验证函数

添加 MCP 服务器配置的 Zod schema 类型定义,包括服务器类型、配置和验证函数
将 MCPServer 的 type 字段更新为复用 McpServerType

* feat(MCPSettings): 添加ErrorBoundary包装路由组件以捕获错误

* feat(types): 为McpServerConfigSchema添加url、headers和tags字段

添加服务器配置的URL地址、请求头配置和标签字段,以支持更灵活的服务器配置选项

* refactor(MCPSettings): 重构JSON解析逻辑为独立函数并使用zod验证

将原有的parseAndExtractServer函数重构为getServerFromJson,使用zod进行配置验证
移除重复的解析逻辑,简化代码结构

* feat(设置): 添加MCP服务器错误处理和详情展示功能

在MCP服务器卡片中添加错误边界处理,当服务器无效时显示错误提示和详情按钮
新增GeneralPopup组件用于展示错误详情
更新i18n翻译文件添加相关文本

* fix(MCPSettings): 修复导入服务器配置时的类型检查和错误处理

修正 getServerFromJson 返回类型定义,明确区分成功和错误状态
修复错误判断逻辑,使用 null 明确检查而非隐式转换
修复服务器名称存在时的错误提示,移除不必要的非空断言

* feat(MCPSettings): 添加临时测试用的无效服务器功能

添加一个临时测试按钮用于模拟添加无效服务器配置,方便测试错误处理流程

* feat(i18n): 添加错误处理页面的多语言翻译

添加"details"和"mcp.invalid"字段的翻译,用于错误处理页面

* fix(MCPSettings): 修复导入MCP服务器配置时的JSON解析和验证逻辑

将JSON解析和验证拆分为两个步骤,分别捕获解析和验证错误并记录日志
修复服务器配置名称赋值逻辑,使用正确的键名

* refactor(MCPSettings): 替换删除图标为DeleteIcon组件

* feat(MCPSettings): 在McpServerCard中添加错误详情点击展示功能

- 提取错误信息格式化逻辑到变量errorDetails
- 添加点击卡片展示完整错误详情的功能
- 统一按钮点击事件处理,阻止事件冒泡
- 优化错误展示样式,增加内边距和文字省略效果

* refactor(utils): 移除错误处理模块中的日志记录

* docs(MCPSettings): 移除AddMcpServerModal中多余的t参数注释

* test(utils): 移除对console.error的冗余断言

* fix(types): 将args字段从必需改为可选并设置默认值

修改McpServerConfigSchema中的args字段,使其从必需字段变为可选字段并设置默认空数组

* fix(types): 将服务器配置的command和args字段改为可选

command字段现在默认为空字符串,args字段默认为空数组,以提供更灵活的配置方式

* feat(types): 扩展 MCP 服务器配置类型,新增 baseUrl 等字段

添加 baseUrl、description、registryUrl 和 provider 字段以增强服务器配置能力

* fix(MCPSettings): 修复导入MCP服务器时未设置名称的问题

当导入MCP服务器配置时,仅在名称未设置时使用key作为默认名称

* refactor(types): 重构 MCP 相关类型定义并添加更多配置字段

将 MCPConfigSample 从接口改为 zod 推断类型
为 McpServerConfigSchema 添加更多可选配置字段
重新组织 MCPServer 接口字段并添加内部使用注释

* refactor(types): 将 MCP 相关类型定义提取到独立文件

将 MCP 服务器配置相关的 Zod schema 和类型定义从 index.ts 移动到新的 mcp.ts 文件
保持原有功能不变,提高代码组织性和可维护性

* docs(types): 更新MCP服务器内部字段的注释说明

添加关于JSON数据格式暴露的额外警告信息

* feat(types): 添加 strip 工具函数用于移除对象属性

添加一个通用的 strip 工具函数,用于从对象中移除指定的属性并返回新对象

* refactor(types): 调整MCPServer接口和strip函数参数格式

将MCPServer接口中的disabledTools和disabledAutoApproveTools字段移动到文档注释下方
修改strip函数参数从可变参数改为数组形式
更新McpServerConfigSchema字段的默认值和描述

* feat(mcp): 改进 MCP 配置验证并添加 Zod 错误格式化功能

添加 formatZodError 工具函数用于格式化 Zod 验证错误
修改 MCP 配置验证逻辑,使用 safeValidateMcpConfig 替代直接验证
允许 inMemory 类型服务器并添加额外校验规则
更新相关组件使用新的验证方式和错误处理

* refactor(MCPSettings): 移除临时测试代码和无效server添加按钮

* fix(MCPSettings): 修复EditMcpJsonPopup中json错误显示样式问题
This commit is contained in:
Phantom 2025-09-01 10:20:02 +08:00 committed by GitHub
parent 9df7ac0ac2
commit 7303c785aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 606 additions and 198 deletions

View File

@ -0,0 +1,66 @@
import { TopView } from '@renderer/components/TopView'
import { Modal, ModalProps } from 'antd'
import { ReactNode, useState } from 'react'
interface ShowParams extends ModalProps {
content: ReactNode
}
interface Props extends ShowParams {
resolve: (data: any) => void
}
const PopupContainer: React.FC<Props> = ({ content, resolve, ...rest }) => {
const [open, setOpen] = useState(true)
const onOk = () => {
setOpen(false)
}
const onCancel = () => {
setOpen(false)
}
const onClose = () => {
resolve({})
}
GeneralPopup.hide = onCancel
return (
<Modal
open={open}
onOk={onOk}
onCancel={onCancel}
afterClose={onClose}
transitionName="animation-move-down"
centered
{...rest}>
{content}
</Modal>
)
}
const TopViewKey = 'GeneralPopup'
/** 在这个 Popup 中展示任意内容 */
export default class GeneralPopup {
static topviewId = 0
static hide() {
TopView.hide(TopViewKey)
}
static show(props: ShowParams) {
return new Promise<any>((resolve) => {
TopView.show(
<PopupContainer
{...props}
resolve={(v) => {
resolve(v)
TopView.hide(TopViewKey)
}}
/>,
TopViewKey
)
})
}
}

View File

@ -820,6 +820,10 @@
"devtools": "Open debug panel",
"message": "It seems that something went wrong...",
"reload": "Reload"
},
"details": "Details",
"mcp": {
"invalid": "Invalid MCP server"
}
},
"chat": {

View File

@ -820,6 +820,10 @@
"devtools": "デバッグパネルを開く",
"message": "何か問題が発生したようです...",
"reload": "再読み込み"
},
"details": "詳細情報",
"mcp": {
"invalid": "無効なMCPサーバー"
}
},
"chat": {

View File

@ -820,6 +820,10 @@
"devtools": "Открыть панель отладки",
"message": "Похоже, возникла какая-то проблема...",
"reload": "Перезагрузить"
},
"details": "Подробности",
"mcp": {
"invalid": "Недействительный сервер MCP"
}
},
"chat": {

View File

@ -820,6 +820,10 @@
"devtools": "打开调试面板",
"message": "似乎出现了一些问题...",
"reload": "重新加载"
},
"details": "详细信息",
"mcp": {
"invalid": "无效的MCP服务器"
}
},
"chat": {

View File

@ -820,6 +820,10 @@
"devtools": "打開除錯面板",
"message": "似乎出現了一些問題...",
"reload": "重新載入"
},
"details": "詳細信息",
"mcp": {
"invalid": "無效的MCP伺服器"
}
},
"chat": {

View File

@ -820,6 +820,10 @@
"devtools": "Άνοιγμα πίνακα αποσφαλμάτωσης",
"message": "Φαίνεται ότι προέκυψε κάποιο πρόβλημα...",
"reload": "Επαναφόρτωση"
},
"details": "Λεπτομέρειες",
"mcp": {
"invalid": "Μη έγκυρος διακομιστής MCP"
}
},
"chat": {

View File

@ -820,6 +820,10 @@
"devtools": "Abrir el panel de depuración",
"message": "Parece que ha surgido un problema...",
"reload": "Recargar"
},
"details": "Detalles",
"mcp": {
"invalid": "Servidor MCP no válido"
}
},
"chat": {

View File

@ -820,6 +820,10 @@
"devtools": "Ouvrir le panneau de débogage",
"message": "Il semble que quelques problèmes soient survenus...",
"reload": "Recharger"
},
"details": "Détails",
"mcp": {
"invalid": "Serveur MCP invalide"
}
},
"chat": {

View File

@ -820,6 +820,10 @@
"devtools": "Abrir o painel de depuração",
"message": "Parece que ocorreu um problema...",
"reload": "Recarregar"
},
"details": "Detalhes",
"mcp": {
"invalid": "Servidor MCP inválido"
}
},
"chat": {

View File

@ -5,7 +5,9 @@ import CodeEditor from '@renderer/components/CodeEditor'
import { useTimer } from '@renderer/hooks/useTimer'
import { useAppDispatch } from '@renderer/store'
import { setMCPServerActive } from '@renderer/store/mcp'
import { MCPServer } from '@renderer/types'
import { MCPServer, objectKeys, safeValidateMcpConfig } from '@renderer/types'
import { parseJSON } from '@renderer/utils'
import { formatZodError } from '@renderer/utils/error'
import { Button, Form, Modal, Upload } from 'antd'
import { FC, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -80,6 +82,49 @@ const AddMcpServerModal: FC<AddMcpServerModalProps> = ({
setImportMethod(initialImportMethod)
}, [initialImportMethod])
/**
* JSON字符串中解析MCP服务器配置
* @param inputValue - JSON格式的服务器配置字符串
* @returns
* - serverToAdd: 解析成功时返回服务器配置对象null
* - error: 解析失败时返回错误信息null
*/
const getServerFromJson = (
inputValue: string
): { serverToAdd: Partial<ParsedServerData>; error: null } | { serverToAdd: null; error: string } => {
const trimmedInput = inputValue.trim()
const parsedJson = parseJSON(trimmedInput)
if (parsedJson === null) {
logger.error('Failed to parse json.', { input: trimmedInput })
return { serverToAdd: null, error: t('settings.mcp.addServer.importFrom.invalid') }
}
const { data: validConfig, error } = safeValidateMcpConfig(parsedJson)
if (error) {
logger.error('Failed to validate json.', { parsedJson, error })
return { serverToAdd: null, error: formatZodError(error, t('settings.mcp.addServer.importFrom.invalid')) }
}
let serverToAdd: Partial<ParsedServerData> | null = null
if (objectKeys(validConfig.mcpServers).length > 1) {
return { serverToAdd: null, error: t('settings.mcp.addServer.importFrom.error.multipleServers') }
}
if (objectKeys(validConfig.mcpServers).length > 0) {
const key = objectKeys(validConfig.mcpServers)[0]
serverToAdd = validConfig.mcpServers[key]
if (!serverToAdd.name) {
serverToAdd.name = key
}
} else {
return { serverToAdd: null, error: t('settings.mcp.addServer.importFrom.invalid') }
}
// zod 太好用了你们知道吗
return { serverToAdd, error: null }
}
const handleOk = async () => {
try {
setLoading(true)
@ -194,9 +239,9 @@ const AddMcpServerModal: FC<AddMcpServerModalProps> = ({
const values = await form.validateFields()
const inputValue = values.serverConfig.trim()
const { serverToAdd, error } = parseAndExtractServer(inputValue, t)
const { serverToAdd, error } = getServerFromJson(inputValue)
if (error) {
if (error !== null) {
form.setFields([
{
name: 'serverConfig',
@ -208,11 +253,11 @@ const AddMcpServerModal: FC<AddMcpServerModalProps> = ({
}
// 檢查重複名稱
if (existingServers && existingServers.some((server) => server.name === serverToAdd!.name)) {
if (existingServers && existingServers.some((server) => server.name === serverToAdd.name)) {
form.setFields([
{
name: 'serverConfig',
errors: [t('settings.mcp.addServer.importFrom.nameExists', { name: serverToAdd!.name })]
errors: [t('settings.mcp.addServer.importFrom.nameExists', { name: serverToAdd.name })]
}
])
setLoading(false)
@ -222,9 +267,9 @@ const AddMcpServerModal: FC<AddMcpServerModalProps> = ({
// 如果成功解析並通過所有檢查,立即加入伺服器(非啟用狀態)並關閉對話框
const newServer: MCPServer = {
id: nanoid(),
...serverToAdd!,
name: serverToAdd!.name || t('settings.mcp.newServer'),
baseUrl: serverToAdd!.baseUrl ?? serverToAdd!.url ?? '',
...serverToAdd,
name: serverToAdd.name || t('settings.mcp.newServer'),
baseUrl: serverToAdd.baseUrl ?? serverToAdd.url ?? '',
isActive: false // 初始狀態為非啟用
}
@ -330,93 +375,4 @@ const AddMcpServerModal: FC<AddMcpServerModalProps> = ({
)
}
// 解析 JSON 提取伺服器資料
const parseAndExtractServer = (
inputValue: string,
t: (key: string, options?: any) => string
): { serverToAdd: Partial<ParsedServerData> | null; error: string | null } => {
const trimmedInput = inputValue.trim()
let parsedJson
try {
parsedJson = JSON.parse(trimmedInput)
} catch (e) {
// JSON 解析失敗,返回錯誤
return { serverToAdd: null, error: t('settings.mcp.addServer.importFrom.invalid') }
}
let serverToAdd: Partial<ParsedServerData> | null = null
// 檢查是否包含多個伺服器配置 (適用於 JSON 格式)
if (
parsedJson.mcpServers &&
typeof parsedJson.mcpServers === 'object' &&
Object.keys(parsedJson.mcpServers).length > 1
) {
return { serverToAdd: null, error: t('settings.mcp.addServer.importFrom.error.multipleServers') }
} else if (Array.isArray(parsedJson) && parsedJson.length > 1) {
return { serverToAdd: null, error: t('settings.mcp.addServer.importFrom.error.multipleServers') }
}
if (
parsedJson.mcpServers &&
typeof parsedJson.mcpServers === 'object' &&
Object.keys(parsedJson.mcpServers).length > 0
) {
// Case 1: {"mcpServers": {"serverName": {...}}}
const firstServerKey = Object.keys(parsedJson.mcpServers)[0]
const potentialServer = parsedJson.mcpServers[firstServerKey]
if (typeof potentialServer === 'object' && potentialServer !== null) {
serverToAdd = { ...potentialServer }
serverToAdd!.name = potentialServer.name ?? firstServerKey
} else {
logger.error('Invalid server data under mcpServers key:', potentialServer)
return { serverToAdd: null, error: t('settings.mcp.addServer.importFrom.invalid') }
}
} else if (Array.isArray(parsedJson) && parsedJson.length > 0) {
// Case 2: [{...}, ...] - 取第一個伺服器,確保它是物件
if (typeof parsedJson[0] === 'object' && parsedJson[0] !== null) {
serverToAdd = { ...parsedJson[0] }
serverToAdd!.name = parsedJson[0].name ?? t('settings.mcp.newServer')
} else {
logger.error('Invalid server data in array:', parsedJson[0])
return { serverToAdd: null, error: t('settings.mcp.addServer.importFrom.invalid') }
}
} else if (
typeof parsedJson === 'object' &&
!Array.isArray(parsedJson) &&
!parsedJson.mcpServers // 確保是直接的伺服器物件
) {
// Case 3: {...} (單一伺服器物件)
// 檢查物件是否為空
if (Object.keys(parsedJson).length > 0) {
serverToAdd = { ...parsedJson }
serverToAdd!.name = parsedJson.name ?? t('settings.mcp.newServer')
} else {
// 空物件,視為無效
serverToAdd = null
}
} else {
// 無效結構或空的 mcpServers
serverToAdd = null
}
// 確保 serverToAdd 存在且 name 存在
if (!serverToAdd || !serverToAdd.name) {
logger.error('Invalid JSON structure for server config or missing name:', parsedJson)
return { serverToAdd: null, error: t('settings.mcp.addServer.importFrom.invalid') }
}
// Ensure tags is string[]
if (
serverToAdd.tags &&
(!Array.isArray(serverToAdd.tags) || !serverToAdd.tags.every((tag) => typeof tag === 'string'))
) {
logger.error('Tags must be an array of strings:', serverToAdd.tags)
return { serverToAdd: null, error: t('settings.mcp.addServer.importFrom.invalid') }
}
return { serverToAdd, error: null }
}
export default AddMcpServerModal

View File

@ -3,7 +3,9 @@ import CodeEditor from '@renderer/components/CodeEditor'
import { TopView } from '@renderer/components/TopView'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setMCPServers } from '@renderer/store/mcp'
import { MCPServer } from '@renderer/types'
import { MCPServer, safeValidateMcpConfig } from '@renderer/types'
import { parseJSON } from '@renderer/utils'
import { formatZodError } from '@renderer/utils/error'
import { Modal, Spin, Typography } from 'antd'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -62,15 +64,19 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
return
}
const parsedConfig = JSON.parse(jsonConfig)
if (!parsedConfig.mcpServers || typeof parsedConfig.mcpServers !== 'object') {
const parsedJson = parseJSON(jsonConfig)
if (parseJSON === null) {
throw new Error(t('settings.mcp.addServer.importFrom.invalid'))
}
const { data: parsedServers, error } = safeValidateMcpConfig(parsedJson)
if (error) {
throw new Error(formatZodError(error, t('settings.mcp.addServer.importFrom.invalid')))
}
const serversArray: MCPServer[] = []
for (const [id, serverConfig] of Object.entries(parsedConfig.mcpServers)) {
for (const [id, serverConfig] of Object.entries(parsedServers.mcpServers)) {
const server: MCPServer = {
id,
isActive: false,
@ -117,13 +123,12 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
afterClose={onClose}
maskClosable={false}
width={800}
height="80vh"
loading={jsonSaving}
transitionName="animation-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 style={{ width: '100%' }} type="danger">
{jsonError ? <pre>{jsonError}</pre> : ''}
</Typography.Text>
</div>
{isLoading ? (

View File

@ -1,10 +1,15 @@
import { ErrorBoundary } from '@renderer/components/ErrorBoundary'
import { DeleteIcon } from '@renderer/components/Icons'
import GeneralPopup from '@renderer/components/Popups/GeneralPopup'
import Scrollbar from '@renderer/components/Scrollbar'
import { getMcpTypeLabel } from '@renderer/i18n/label'
import { MCPServer } from '@renderer/types'
import { Button, Switch, Tag, Typography } from 'antd'
import { Settings2, SquareArrowOutUpRight } from 'lucide-react'
import { FC } from 'react'
import { formatErrorMessage } from '@renderer/utils/error'
import { Alert, Button, Space, Switch, Tag, Tooltip, Typography } from 'antd'
import { CircleXIcon, Settings2, SquareArrowOutUpRight } from 'lucide-react'
import { FC, useCallback } from 'react'
import { FallbackProps } from 'react-error-boundary'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface McpServerCardProps {
@ -26,6 +31,7 @@ const McpServerCard: FC<McpServerCardProps> = ({
onEdit,
onOpenUrl
}) => {
const { t } = useTranslation()
const handleOpenUrl = (e: React.MouseEvent) => {
e.stopPropagation()
if (server.providerUrl) {
@ -33,61 +39,135 @@ const McpServerCard: FC<McpServerCardProps> = ({
}
}
const Fallback = useCallback(
(props: FallbackProps) => {
const { error } = props
const errorDetails = formatErrorMessage(error)
const ErrorDetails = () => {
return (
<div
style={{
padding: 8,
textWrap: 'pretty',
fontFamily: 'monospace',
userSelect: 'text',
marginRight: 20,
color: 'var(--color-status-error)'
}}>
{errorDetails}
</div>
)
}
const onClickDetails = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
GeneralPopup.show({ content: <ErrorDetails /> })
}
return (
<Alert
message={t('error.boundary.mcp.invalid')}
showIcon
type="error"
style={{ height: 125, alignItems: 'flex-start', padding: 12 }}
description={
<Typography.Paragraph style={{ color: 'var(--color-status-error)' }} ellipsis={{ rows: 3 }}>
{errorDetails}
</Typography.Paragraph>
}
onClick={onClickDetails}
action={
<Space.Compact>
<Button
danger
type="text"
icon={
<Tooltip title={t('error.boundary.details')}>
<CircleXIcon size={16} />
</Tooltip>
}
size="small"
onClick={onClickDetails}
/>
<Button
danger
type="text"
icon={
<Tooltip title={t('common.delete')}>
<DeleteIcon size={16} />
</Tooltip>
}
size="small"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
/>
</Space.Compact>
}
/>
)
},
[onDelete, t]
)
return (
<CardContainer $isActive={server.isActive} onClick={onEdit}>
<ServerHeader>
<ServerNameWrapper>
{server.logoUrl && <ServerLogo src={server.logoUrl} alt={`${server.name} logo`} />}
<ServerNameText ellipsis={{ tooltip: true }}>{server.name}</ServerNameText>
{server.providerUrl && (
<Button
type="text"
<ErrorBoundary fallbackComponent={Fallback}>
<CardContainer $isActive={server.isActive} onClick={onEdit}>
<ServerHeader>
<ServerNameWrapper>
{server.logoUrl && <ServerLogo src={server.logoUrl} alt={`${server.name} logo`} />}
<ServerNameText ellipsis={{ tooltip: true }}>{server.name}</ServerNameText>
{server.providerUrl && (
<Button
type="text"
size="small"
shape="circle"
icon={<SquareArrowOutUpRight size={14} />}
onClick={handleOpenUrl}
data-no-dnd
/>
)}
</ServerNameWrapper>
<ToolbarWrapper onClick={(e) => e.stopPropagation()}>
<Switch
value={server.isActive}
key={server.id}
loading={isLoading}
onChange={onToggle}
size="small"
shape="circle"
icon={<SquareArrowOutUpRight size={14} />}
onClick={handleOpenUrl}
data-no-dnd
/>
<Button
type="text"
shape="circle"
icon={<DeleteIcon size={14} className="lucide-custom" />}
danger
onClick={onDelete}
data-no-dnd
/>
<Button type="text" shape="circle" icon={<Settings2 size={14} />} onClick={onEdit} data-no-dnd />
</ToolbarWrapper>
</ServerHeader>
<ServerDescription>{server.description}</ServerDescription>
<ServerFooter>
{version && (
<VersionBadge color="#108ee9">
<VersionText ellipsis={{ tooltip: true }}>{version}</VersionText>
</VersionBadge>
)}
</ServerNameWrapper>
<ToolbarWrapper onClick={(e) => e.stopPropagation()}>
<Switch
value={server.isActive}
key={server.id}
loading={isLoading}
onChange={onToggle}
size="small"
data-no-dnd
/>
<Button
type="text"
shape="circle"
icon={<DeleteIcon size={14} className="lucide-custom" />}
danger
onClick={onDelete}
data-no-dnd
/>
<Button type="text" shape="circle" icon={<Settings2 size={14} />} onClick={onEdit} data-no-dnd />
</ToolbarWrapper>
</ServerHeader>
<ServerDescription>{server.description}</ServerDescription>
<ServerFooter>
{version && (
<VersionBadge color="#108ee9">
<VersionText ellipsis={{ tooltip: true }}>{version}</VersionText>
</VersionBadge>
)}
<ServerTag color="processing">{getMcpTypeLabel(server.type ?? 'stdio')}</ServerTag>
{server.provider && <ServerTag color="success">{server.provider}</ServerTag>}
{server.tags
?.filter((tag): tag is string => typeof tag === 'string') // Avoid existing non-string tags crash the UI
.map((tag) => (
<ServerTag key={tag} color="default">
{tag}
</ServerTag>
))}
</ServerFooter>
</CardContainer>
<ServerTag color="processing">{getMcpTypeLabel(server.type ?? 'stdio')}</ServerTag>
{server.provider && <ServerTag color="success">{server.provider}</ServerTag>}
{server.tags
?.filter((tag): tag is string => typeof tag === 'string') // Avoid existing non-string tags crash the UI
.map((tag) => (
<ServerTag key={tag} color="default">
{tag}
</ServerTag>
))}
</ServerFooter>
</CardContainer>
</ErrorBoundary>
)
}

View File

@ -1,4 +1,5 @@
import { ArrowLeftOutlined } from '@ant-design/icons'
import { ErrorBoundary } from '@renderer/components/ErrorBoundary'
import { useTheme } from '@renderer/context/ThemeProvider'
import { Button } from 'antd'
import { FC } from 'react'
@ -30,26 +31,28 @@ const MCPSettings: FC = () => {
</BackButtonContainer>
)}
<MainContainer>
<Routes>
<Route path="/" element={<McpServersList />} />
<Route path="settings/:serverId" element={<McpSettings />} />
<Route
path="npx-search"
element={
<SettingContainer theme={theme}>
<NpxSearch />
</SettingContainer>
}
/>
<Route
path="mcp-install"
element={
<SettingContainer theme={theme}>
<InstallNpxUv />
</SettingContainer>
}
/>
</Routes>
<ErrorBoundary>
<Routes>
<Route path="/" element={<McpServersList />} />
<Route path="settings/:serverId" element={<McpSettings />} />
<Route
path="npx-search"
element={
<SettingContainer theme={theme}>
<NpxSearch />
</SettingContainer>
}
/>
<Route
path="mcp-install"
element={
<SettingContainer theme={theme}>
<InstallNpxUv />
</SettingContainer>
}
/>
</Routes>
</ErrorBoundary>
</MainContainer>
</SettingContainer>
)

View File

@ -8,8 +8,10 @@ export * from './file'
export * from './note'
import type { FileMetadata } from './file'
import { MCPConfigSample, McpServerType } from './mcp'
import type { Message } from './newMessage'
export * from './mcp'
export * from './ocr'
export type Assistant = {
@ -831,6 +833,7 @@ export type KnowledgeReference = {
file?: FileMetadata
}
// TODO: 把 mcp 相关类型定义迁移到独立文件中
export type MCPArgType = 'string' | 'list' | 'number'
export type MCPEnvType = 'string' | 'number'
export type MCPArgParameter = { [key: string]: MCPArgType }
@ -842,29 +845,17 @@ export interface MCPServerParameter {
description: string
}
export interface MCPConfigSample {
command: string
args: string[]
env?: Record<string, string> | undefined
}
export interface MCPServer {
id: string
name: string
type?: 'stdio' | 'sse' | 'inMemory' | 'streamableHttp'
id: string // internal id
name: string // mcp name, generally as unique key
type?: McpServerType | 'inMemory'
description?: string
baseUrl?: string
command?: string
registryUrl?: string
args?: string[]
env?: Record<string, string>
shouldConfig?: boolean
isActive: boolean
disabledTools?: string[] // List of tool names that are disabled for this server
disabledAutoApproveTools?: string[] // Whether to auto-approve tools for this server
configSample?: MCPConfigSample
headers?: Record<string, string> // Custom headers to be sent with requests to this server
searchKey?: string
provider?: string // Provider name for this server like ModelScope, Higress, etc.
providerUrl?: string // URL of the MCP server in provider's website or documentation
logoUrl?: string // URL of the MCP server's logo
@ -874,6 +865,17 @@ export interface MCPServer {
dxtVersion?: string // Version of the DXT package
dxtPath?: string // Path where the DXT package was extracted
reference?: string // Reference link for the server, e.g., documentation or homepage
searchKey?: string
configSample?: MCPConfigSample
/** List of tool names that are disabled for this server */
disabledTools?: string[]
/** Whether to auto-approve tools for this server */
disabledAutoApproveTools?: string[]
/** 用于标记内置 MCP 是否需要配置 */
shouldConfig?: boolean
/** 用于标记服务器是否运行中 */
isActive: boolean
}
export type BuiltinMCPServer = MCPServer & {
@ -1248,6 +1250,28 @@ export type AtLeast<T extends string, U> = {
[key: string]: U
}
/**
*
* @template T -
* @template K - T的键
* @param obj -
* @param keys -
* @returns
* @example
* ```ts
* const obj = { a: 1, b: 2, c: 3 };
* const result = strip(obj, ['a', 'b']);
* // result = { c: 3 }
* ```
*/
export function strip<T, K extends keyof T>(obj: T, keys: K[]): Omit<T, K> {
const result = { ...obj }
for (const key of keys) {
delete (result as any)[key] // 类型上 Omit 已保证安全
}
return result
}
export type HexColor = string
/**

View File

@ -0,0 +1,227 @@
import z from 'zod'
import { isBuiltinMCPServerName } from '.'
export const MCPConfigSampleSchema = z.object({
command: z.string(),
args: z.array(z.string()),
env: z.record(z.string(), z.string()).optional()
})
export type MCPConfigSample = z.infer<typeof MCPConfigSampleSchema>
/**
* MCP
* stdio: 通过标准输入/ ()
* sse: 通过HTTP Server-Sent Events
*
* inMemory name builtin
*/
export const McpServerTypeSchema = z
.union([z.literal('stdio'), z.literal('sse'), z.literal('streamableHttp'), z.literal('inMemory')])
.default('stdio') // 大多数情况下默认使用 stdio
/**
* MCP
* FIXME: 为了兼容性
* type inMemory
*/
export const McpServerConfigSchema = z
.object({
/**
* ID
*
*/
id: z.string().optional().describe('Server internal id.'),
/**
*
*
*/
name: z.string().optional().describe('Server name for identification and display'),
/**
*
* "stdio"
*/
type: McpServerTypeSchema.optional(),
/**
*
*
*/
description: z.string().optional().describe('Server description'),
/**
* URL地址
* 访
*/
url: z.string().optional().describe('Server URL address'),
/**
* url 使 baseUrl
* 访
*/
baseUrl: z.string().optional().describe('Server URL address'),
/**
* ( "uvx", "npx")
*
*/
command: z.string().optional().describe("The command to execute (e.g., 'uvx', 'npx')"),
/**
* registry URL
* registry
*/
registryUrl: z.string().optional().describe('Registry URL for the server'),
/**
*
*
*
*/
args: z.array(z.string()).optional().describe('The arguments to pass to the command'),
/**
*
*
*
*/
env: z.record(z.string(), z.string()).optional().describe('Environment variables for the server process'),
/**
*
* headers
*/
headers: z.record(z.string(), z.string()).optional().describe('Custom headers configuration'),
/**
* provider
*
*/
provider: z.string().optional().describe('Provider name for the server'),
/**
* provider URL
*
*/
providerUrl: z.string().optional().describe('URL of the provider website or documentation'),
/**
* logo URL
* logo图片地址
*/
logoUrl: z.string().optional().describe('URL of the server logo'),
/**
*
*
*/
tags: z.array(z.string()).optional().describe('Server tags for categorization'),
/**
*
*
*/
longRunning: z.boolean().optional().describe('Whether the server is long running'),
/**
*
* 60
*/
timeout: z.number().optional().describe('Timeout in seconds for requests to this server'),
/**
* DXT包版本号
* DXT包的版本
*/
dxtVersion: z.string().optional().describe('Version of the DXT package'),
/**
* DXT包解压路径
* DXT包解压后的存放路径
*/
dxtPath: z.string().optional().describe('Path where the DXT package was extracted'),
/**
*
*
*/
reference: z.string().optional().describe('Reference link for the server'),
/**
*
*
*/
searchKey: z.string().optional().describe('Search key for the server'),
/**
*
*
*/
configSample: MCPConfigSampleSchema.optional().describe('Configuration sample for the server'),
/**
*
*
*/
disabledTools: z.array(z.string()).optional().describe('List of disabled tools for this server'),
/**
*
*
*/
disabledAutoApproveTools: z
.array(z.string())
.optional()
.describe('List of tools that are disabled for auto-approval on this server'),
/**
*
*
*/
shouldConfig: z.boolean().optional().describe('Whether the server should be configured'),
/**
*
*
*/
isActive: z.boolean().optional().describe('Whether the server is active')
})
.strict()
// 在这里定义额外的校验逻辑
.refine(
(schema) => {
if (schema.type === 'inMemory' && schema.name && !isBuiltinMCPServerName(schema.name)) {
return false
}
return true
},
{
message: 'Server type is inMemory but this is not a builtin MCP server, which is not allowed'
}
)
/**
* ID
* : { "my-tools": { command: "...", args: [...] }, "github": { ... } }
*/
export const McpServersMapSchema = z.record(z.string(), McpServerConfigSchema)
/**
* Schema
* MCP配置文件的结构
*/
export const McpConfigSchema = z.object({
/**
* MCP服务器定义的映射
*
*
*/
// 不在这里 refine 服务器数量,因为在类型定义文件中不能用 i18n 处理错误信息
mcpServers: McpServersMapSchema.describe('Mapping of server aliases to their configurations')
})
// 数据校验用类型McpServerType 复用于 MCPServer
export type McpServerType = z.infer<typeof McpServerTypeSchema>
export type McpServerConfig = z.infer<typeof McpServerConfigSchema>
export type McpServersMap = z.infer<typeof McpServersMapSchema>
export type McpConfig = z.infer<typeof McpConfigSchema>
/**
* MCP配置
* @param config -
* @returns `McpConfig` ZodError
*/
export function validateMcpConfig(config: unknown): McpConfig {
return McpConfigSchema.parse(config)
}
/**
*
* @param config -
* @returns / `SafeParseResult`
*/
export function safeValidateMcpConfig(config: unknown) {
return McpConfigSchema.safeParse(config)
}
/**
* MCP服务器配置
* @param config -
* @returns / `SafeParseResult`
*/
export function safeValidateMcpServerConfig(config: unknown) {
return McpServerConfigSchema.safeParse(config)
}

View File

@ -56,7 +56,6 @@ describe('error', () => {
const error = new Error('Test error')
const result = formatErrorMessage(error)
expect(console.error).toHaveBeenCalled()
expect(result).toContain('Error Details:')
expect(result).toContain(' {')
expect(result).toContain(' "message": "Test error"')

View File

@ -1,7 +1,8 @@
import { loggerService } from '@logger'
// import { loggerService } from '@logger'
import { t } from 'i18next'
import z from 'zod'
const logger = loggerService.withContext('Utils:error')
// const logger = loggerService.withContext('Utils:error')
export function getErrorDetails(err: any, seen = new WeakSet()): any {
// Handle circular references
@ -31,8 +32,6 @@ export function getErrorDetails(err: any, seen = new WeakSet()): any {
}
export function formatErrorMessage(error: any): string {
logger.error('Original error:', error)
try {
const detailedError = getErrorDetails(error)
delete detailedError?.headers
@ -102,3 +101,15 @@ export const formatMcpError = (error: any) => {
}
return error.message
}
/**
* Zod
* @param error - Zod
* @param title -
* @returns
*/
export const formatZodError = (error: z.ZodError, title?: string) => {
const readableErrors = error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`)
const errorMessage = readableErrors.join('\n')
return title ? `${title}: \n${errorMessage}` : errorMessage
}

View File

@ -15,6 +15,7 @@ export function isJSON(str: any): boolean {
}
}
// TODO: unknown 代替 any
/**
* JSON null
* @param {string} str