mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-08 22:39:36 +08:00
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:
parent
9df7ac0ac2
commit
7303c785aa
66
src/renderer/src/components/Popups/GeneralPopup.tsx
Normal file
66
src/renderer/src/components/Popups/GeneralPopup.tsx
Normal 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
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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": {
|
||||
|
||||
@ -820,6 +820,10 @@
|
||||
"devtools": "デバッグパネルを開く",
|
||||
"message": "何か問題が発生したようです...",
|
||||
"reload": "再読み込み"
|
||||
},
|
||||
"details": "詳細情報",
|
||||
"mcp": {
|
||||
"invalid": "無効なMCPサーバー"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
|
||||
@ -820,6 +820,10 @@
|
||||
"devtools": "Открыть панель отладки",
|
||||
"message": "Похоже, возникла какая-то проблема...",
|
||||
"reload": "Перезагрузить"
|
||||
},
|
||||
"details": "Подробности",
|
||||
"mcp": {
|
||||
"invalid": "Недействительный сервер MCP"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
|
||||
@ -820,6 +820,10 @@
|
||||
"devtools": "打开调试面板",
|
||||
"message": "似乎出现了一些问题...",
|
||||
"reload": "重新加载"
|
||||
},
|
||||
"details": "详细信息",
|
||||
"mcp": {
|
||||
"invalid": "无效的MCP服务器"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
|
||||
@ -820,6 +820,10 @@
|
||||
"devtools": "打開除錯面板",
|
||||
"message": "似乎出現了一些問題...",
|
||||
"reload": "重新載入"
|
||||
},
|
||||
"details": "詳細信息",
|
||||
"mcp": {
|
||||
"invalid": "無效的MCP伺服器"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
|
||||
@ -820,6 +820,10 @@
|
||||
"devtools": "Άνοιγμα πίνακα αποσφαλμάτωσης",
|
||||
"message": "Φαίνεται ότι προέκυψε κάποιο πρόβλημα...",
|
||||
"reload": "Επαναφόρτωση"
|
||||
},
|
||||
"details": "Λεπτομέρειες",
|
||||
"mcp": {
|
||||
"invalid": "Μη έγκυρος διακομιστής MCP"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
/**
|
||||
|
||||
227
src/renderer/src/types/mcp.ts
Normal file
227
src/renderer/src/types/mcp.ts
Normal 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)
|
||||
}
|
||||
@ -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"')
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -15,6 +15,7 @@ export function isJSON(str: any): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: unknown 代替 any
|
||||
/**
|
||||
* 尝试解析 JSON 字符串,如果解析失败则返回 null。
|
||||
* @param {string} str 要解析的字符串
|
||||
|
||||
Loading…
Reference in New Issue
Block a user