feat(MCP): add resource management features and localization support (#4746)

* feat(MCP): add resource management features and localization support

* feat(MCP): enhance resource handling with improved error messages and response structure

* fix(MCPToolsButton): add missing useEffect import for resource handling

---------

Co-authored-by: 亢奋猫 <kangfenmao@qq.com>
This commit is contained in:
LiuVaayne 2025-04-13 21:08:57 +08:00 committed by GitHub
parent de7f806bbc
commit e22d076d67
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 426 additions and 11 deletions

View File

@ -41,6 +41,8 @@ export enum IpcChannel {
Mcp_CallTool = 'mcp:call-tool',
Mcp_ListPrompts = 'mcp:list-prompts',
Mcp_GetPrompt = 'mcp:get-prompt',
Mcp_ListResources = 'mcp:list-resources',
Mcp_GetResource = 'mcp:get-resource',
Mcp_GetInstallInfo = 'mcp:get-install-info',
Mcp_ServersChanged = 'mcp:servers-changed',
Mcp_ServersUpdated = 'mcp:servers-updated',

View File

@ -275,6 +275,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.Mcp_CallTool, mcpService.callTool)
ipcMain.handle(IpcChannel.Mcp_ListPrompts, mcpService.listPrompts)
ipcMain.handle(IpcChannel.Mcp_GetPrompt, mcpService.getPrompt)
ipcMain.handle(IpcChannel.Mcp_ListResources, mcpService.listResources)
ipcMain.handle(IpcChannel.Mcp_GetResource, mcpService.getResource)
ipcMain.handle(IpcChannel.Mcp_GetInstallInfo, mcpService.getInstallInfo)
ipcMain.handle(IpcChannel.App_IsBinaryExist, (_, name: string) => isBinaryExists(name))

View File

@ -10,7 +10,7 @@ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory'
import { nanoid } from '@reduxjs/toolkit'
import { GetMCPPromptResponse, MCPPrompt, MCPServer, MCPTool } from '@types'
import { GetMCPPromptResponse, GetResourceResponse, MCPPrompt, MCPResource, MCPServer, MCPTool } from '@types'
import { app } from 'electron'
import Logger from 'electron-log'
@ -71,6 +71,8 @@ class McpService {
this.callTool = this.callTool.bind(this)
this.listPrompts = this.listPrompts.bind(this)
this.getPrompt = this.getPrompt.bind(this)
this.listResources = this.listResources.bind(this)
this.getResource = this.getResource.bind(this)
this.closeClient = this.closeClient.bind(this)
this.removeServer = this.removeServer.bind(this)
this.restartServer = this.restartServer.bind(this)
@ -117,9 +119,9 @@ class McpService {
try {
await inMemoryServer.connect(serverTransport)
Logger.info(`[MCP] In-memory server started: ${server.name}`)
} catch (error) {
} catch (error: Error | any) {
Logger.error(`[MCP] Error starting in-memory server: ${error}`)
throw new Error(`Failed to start in-memory server: ${error}`)
throw new Error(`Failed to start in-memory server: ${error.message}`)
}
// set the client transport to the client
transport = clientTransport
@ -203,7 +205,7 @@ class McpService {
return client
} catch (error: any) {
Logger.error(`[MCP] Error activating server ${server.name}:`, error)
throw error
throw new Error(`[MCP] Error activating server ${server.name}: ${error.message}`)
}
}
@ -385,6 +387,89 @@ class McpService {
return await cachedGetPrompt(server, name, args)
}
/**
* List resources available on an MCP server (implementation)
*/
private async listResourcesImpl(server: MCPServer): Promise<MCPResource[]> {
Logger.info(`[MCP] Listing resources for server: ${server.name}`)
const client = await this.initClient(server)
try {
const result = await client.listResources()
const resources = result.resources || []
const serverResources = (Array.isArray(resources) ? resources : []).map((resource: any) => ({
...resource,
serverId: server.id,
serverName: server.name
}))
return serverResources
} catch (error) {
Logger.error(`[MCP] Failed to list resources for server: ${server.name}`, error)
return []
}
}
/**
* List resources available on an MCP server with caching
*/
public async listResources(_: Electron.IpcMainInvokeEvent, server: MCPServer): Promise<MCPResource[]> {
const cachedListResources = withCache<[MCPServer], MCPResource[]>(
this.listResourcesImpl.bind(this),
(server) => {
const serverKey = this.getServerKey(server)
return `mcp:list_resources:${serverKey}`
},
60 * 60 * 1000, // 60 minutes TTL
`[MCP] Resources from ${server.name}`
)
return cachedListResources(server)
}
/**
* Get a specific resource from an MCP server (implementation)
*/
private async getResourceImpl(server: MCPServer, uri: string): Promise<GetResourceResponse> {
Logger.info(`[MCP] Getting resource ${uri} from server: ${server.name}`)
const client = await this.initClient(server)
try {
const result = await client.readResource({ uri: uri })
const contents: MCPResource[] = []
if (result.contents && result.contents.length > 0) {
result.contents.forEach((content: any) => {
contents.push({
...content,
serverId: server.id,
serverName: server.name
})
})
}
return {
contents: contents
}
} catch (error: Error | any) {
Logger.error(`[MCP] Failed to get resource ${uri} from server: ${server.name}`, error)
throw new Error(`Failed to get resource ${uri} from server: ${server.name}: ${error.message}`)
}
}
/**
* Get a specific resource from an MCP server with caching
*/
public async getResource(
_: Electron.IpcMainInvokeEvent,
{ server, uri }: { server: MCPServer; uri: string }
): Promise<GetResourceResponse> {
const cachedGetResource = withCache<[MCPServer, string], GetResourceResponse>(
this.getResourceImpl.bind(this),
(server, uri) => {
const serverKey = this.getServerKey(server)
return `mcp:get_resource:${serverKey}:${uri}`
},
30 * 60 * 1000, // 30 minutes TTL
`[MCP] Resource ${uri} from ${server.name}`
)
return await cachedGetResource(server, uri)
}
/**
* Get enhanced PATH including common tool locations
*/

View File

@ -1,7 +1,7 @@
import { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { ElectronAPI } from '@electron-toolkit/preload'
import type { FileMetadataResponse, ListFilesResponse, UploadFileResponse } from '@google/generative-ai/server'
import type { MCPServer, MCPTool } from '@renderer/types'
import type { GetMCPPromptResponse, MCPPrompt, MCPResource, MCPServer, MCPTool } from '@renderer/types'
import { AppInfo, FileType, KnowledgeBaseParams, KnowledgeItem, LanguageVarious, WebDavConfig } from '@renderer/types'
import type { LoaderReturn } from '@shared/config/types'
import type { OpenDialogOptions } from 'electron'
@ -161,6 +161,8 @@ declare global {
name: string
args?: Record<string, any>
}) => Promise<GetMCPPromptResponse>
listResources: (server: MCPServer) => Promise<MCPResource[]>
getResource: ({ server, uri }: { server: MCPServer; uri: string }) => Promise<GetResourceResponse>
getInstallInfo: () => Promise<{ dir: string; uvPath: string; bunPath: string }>
}
copilot: {

View File

@ -135,6 +135,9 @@ const api = {
listPrompts: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_ListPrompts, server),
getPrompt: ({ server, name, args }: { server: MCPServer; name: string; args?: Record<string, any> }) =>
ipcRenderer.invoke(IpcChannel.Mcp_GetPrompt, { server, name, args }),
listResources: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_ListResources, server),
getResource: ({ server, uri }: { server: MCPServer; uri: string }) =>
ipcRenderer.invoke(IpcChannel.Mcp_GetResource, { server, uri }),
getInstallInfo: () => ipcRenderer.invoke(IpcChannel.Mcp_GetInstallInfo)
},
shell: {

View File

@ -1112,6 +1112,16 @@
"genericError": "Get prompt Error",
"loadError": "Get prompts Error"
},
"resources": {
"noResourcesAvailable": "No resources available",
"availableResources": "Available Resources",
"uri": "URI",
"mimeType": "MIME Type",
"size": "Size",
"blob": "Blob",
"blobInvisible": "Blob Invisible",
"text": "Text"
},
"deleteServer": "Delete Server",
"deleteServerConfirm": "Are you sure you want to delete this server?",
"registry": "Package Registry",

View File

@ -1111,6 +1111,16 @@
"genericError": "プロンプト取得エラー",
"loadError": "プロンプト取得エラー"
},
"resources": {
"noResourcesAvailable": "利用可能なリソースはありません",
"availableResources": "利用可能なリソース",
"uri": "URI",
"mimeType": "MIMEタイプ",
"size": "サイズ",
"blob": "バイナリデータ",
"blobInvisible": "バイナリデータを非表示",
"text": "テキスト"
},
"deleteServer": "サーバーを削除",
"deleteServerConfirm": "このサーバーを削除してもよろしいですか?",
"registry": "パッケージ管理レジストリ",

View File

@ -1111,6 +1111,16 @@
"genericError": "Ошибка получения подсказки",
"loadError": "Ошибка получения подсказок"
},
"resources": {
"noResourcesAvailable": "Нет доступных ресурсов",
"availableResources": "Доступные ресурсы",
"uri": "URI",
"mimeType": "MIME-тип",
"size": "Размер",
"blob": "Двоичные данные",
"blobInvisible": "Скрытые двоичные данные",
"text": "Текст"
},
"deleteServer": "Удалить сервер",
"deleteServerConfirm": "Вы уверены, что хотите удалить этот сервер?",
"registry": "Реестр пакетов",

View File

@ -1112,6 +1112,16 @@
"genericError": "获取提示错误",
"loadError": "获取提示失败"
},
"resources": {
"noResourcesAvailable": "无可用资源",
"availableResources": "可用资源",
"uri": "URI",
"mimeType": "MIME类型",
"size": "大小",
"blob": "二进制数据",
"blobInvisible": "隐藏二进制数据",
"text": "文本"
},
"deleteServer": "删除服务器",
"deleteServerConfirm": "确定要删除此服务器吗?",
"registry": "包管理源",

View File

@ -1111,6 +1111,16 @@
"genericError": "獲取提示錯誤",
"loadError": "獲取提示失敗"
},
"resources": {
"noResourcesAvailable": "無可用資源",
"availableResources": "可用資源",
"uri": "URI",
"mimeType": "MIME類型",
"size": "大小",
"blob": "二進位數據",
"blobInvisible": "隱藏二進位數據",
"text": "文字"
},
"deleteServer": "刪除伺服器",
"deleteServerConfirm": "確定要刪除此伺服器嗎?",
"registry": "套件管理源",

View File

@ -355,7 +355,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
}
},
{
label: 'MCP Prompt',
label: `MCP ${t('settings.mcp.tabs.prompts')}`,
description: '',
icon: <CodeOutlined />,
isMenu: true,
@ -363,6 +363,15 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
mcpToolsButtonRef.current?.openPromptList()
}
},
{
label: `MCP ${t('settings.mcp.tabs.resources')}`,
description: '',
icon: <CodeOutlined />,
isMenu: true,
action: () => {
mcpToolsButtonRef.current?.openResourcesList()
}
},
{
label: isVisionModel(model) ? t('chat.input.upload') : t('chat.input.upload.document'),
description: '',

View File

@ -1,16 +1,17 @@
import { CodeOutlined, PlusOutlined } from '@ant-design/icons'
import { QuickPanelListItem, useQuickPanel } from '@renderer/components/QuickPanel'
import { useMCPServers } from '@renderer/hooks/useMCPServers'
import { MCPPrompt, MCPServer } from '@renderer/types'
import { MCPPrompt, MCPResource, MCPServer } from '@renderer/types'
import { Form, Input, Modal, Tooltip } from 'antd'
import { SquareTerminal } from 'lucide-react'
import { FC, useCallback, useImperativeHandle, useMemo } from 'react'
import { FC, useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router'
export interface MCPToolsButtonRef {
openQuickPanel: () => void
openPromptList: () => void
openResourcesList: () => void
}
interface Props {
@ -168,6 +169,7 @@ const MCPToolsButton: FC<Props> = ({
const handlePromptSelect = useCallback(
(prompt: MCPPrompt) => {
// Using a 10ms delay to ensure the modal or UI updates are fully rendered before executing the logic.
setTimeout(async () => {
const server = enabledMCPs.find((s) => s.id === prompt.serverId)
if (server) {
@ -284,6 +286,112 @@ const MCPToolsButton: FC<Props> = ({
})
}, [promptList, quickPanel, t])
const handleResourceSelect = useCallback(
(resource: MCPResource) => {
setTimeout(async () => {
const server = enabledMCPs.find((s) => s.id === resource.serverId)
if (server) {
try {
// Fetch the resource data
const response = await window.api.mcp.getResource({
server,
uri: resource.uri
})
console.log('Resource Data:', response)
// Check if the response has the expected structure
if (response && response.contents && Array.isArray(response.contents)) {
// Process each resource in the contents array
for (const resourceData of response.contents) {
// Determine how to handle the resource based on its MIME type
if (resourceData.blob) {
// Handle binary data (images, etc.)
if (resourceData.mimeType?.startsWith('image/')) {
// Insert image as markdown
const imageMarkdown = `![${resourceData.name || 'Image'}](data:${resourceData.mimeType};base64,${resourceData.blob})`
insertPromptIntoTextArea(imageMarkdown)
} else {
// For other binary types, just mention it's available
const resourceInfo = `[${resourceData.name || resource.name} - ${resourceData.mimeType || t('settings.mcp.resources.blobInvisible')}]`
insertPromptIntoTextArea(resourceInfo)
}
} else if (resourceData.text) {
// Handle text data
insertPromptIntoTextArea(resourceData.text)
} else {
// Fallback for resources without content
const resourceInfo = `[${resourceData.name || resource.name} - ${resourceData.uri || resource.uri}]`
insertPromptIntoTextArea(resourceInfo)
}
}
} else {
// Handle legacy format or direct resource data
const resourceData = response
// Determine how to handle the resource based on its MIME type
if (resourceData.blob) {
// Handle binary data (images, etc.)
if (resourceData.mimeType?.startsWith('image/')) {
// Insert image as markdown
const imageMarkdown = `![${resourceData.name || resource.name}](data:${resourceData.mimeType};base64,${resourceData.blob})`
insertPromptIntoTextArea(imageMarkdown)
} else {
// For other binary types, just mention it's available
const resourceInfo = `[${resourceData.name || resource.name} - ${resourceData.mimeType || t('settings.mcp.resources.blobInvisible')}]`
insertPromptIntoTextArea(resourceInfo)
}
} else if (resourceData.text) {
// Handle text data
insertPromptIntoTextArea(resourceData.text)
} else {
// Fallback for resources without content
const resourceInfo = `[${resourceData.name || resource.name} - ${resourceData.uri || resource.uri}]`
insertPromptIntoTextArea(resourceInfo)
}
}
} catch (error: Error | any) {
Modal.error({
title: t('common.error'),
content: error.message || t('settings.mcp.resources.genericError')
})
}
}
}, 10)
},
[enabledMCPs, t, insertPromptIntoTextArea]
)
const [resourcesList, setResourcesList] = useState<QuickPanelListItem[]>([])
useEffect(() => {
const fetchResources = async () => {
const resources: MCPResource[] = []
for (const server of enabledMCPs) {
const serverResources = await window.api.mcp.listResources(server)
resources.push(...serverResources)
}
setResourcesList(
resources.map((resource) => ({
label: resource.name,
description: resource.description,
icon: <CodeOutlined />,
action: () => handleResourceSelect(resource)
}))
)
}
fetchResources()
}, [handleResourceSelect, enabledMCPs])
const openResourcesList = useCallback(async () => {
const resources = resourcesList
quickPanel.open({
title: t('settings.mcp.title'),
list: resources,
symbol: 'mcp-resource',
multiple: true
})
}, [resourcesList, quickPanel, t])
const handleOpenQuickPanel = useCallback(() => {
if (quickPanel.isVisible && quickPanel.symbol === 'mcp') {
quickPanel.close()
@ -294,7 +402,8 @@ const MCPToolsButton: FC<Props> = ({
useImperativeHandle(ref, () => ({
openQuickPanel,
openPromptList
openPromptList,
openResourcesList
}))
if (activedMcpServers.length === 0) {

View File

@ -0,0 +1,108 @@
import { MCPResource } from '@renderer/types'
import { Collapse, Descriptions, Empty, Flex, Tag, Typography } from 'antd'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface MCPResourcesSectionProps {
resources: MCPResource[]
}
const MCPResourcesSection = ({ resources }: MCPResourcesSectionProps) => {
const { t } = useTranslation()
// Format file size to human-readable format
const formatFileSize = (size?: number) => {
if (size === undefined) return 'Unknown size'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let formattedSize = size
let unitIndex = 0
while (formattedSize >= 1024 && unitIndex < units.length - 1) {
formattedSize /= 1024
unitIndex++
}
return `${formattedSize.toFixed(2)} ${units[unitIndex]}`
}
// Render resource properties
const renderResourceProperties = (resource: MCPResource) => {
return (
<Descriptions column={1} size="small" bordered>
{resource.mimeType && (
<Descriptions.Item label={t('settings.mcp.resources.mimeType') || 'MIME Type'}>
<Tag color="blue">{resource.mimeType}</Tag>
</Descriptions.Item>
)}
{resource.size !== undefined && (
<Descriptions.Item label={t('settings.mcp.resources.size') || 'Size'}>
{formatFileSize(resource.size)}
</Descriptions.Item>
)}
{resource.text && (
<Descriptions.Item label={t('settings.mcp.resources.text') || 'Text'}>{resource.text}</Descriptions.Item>
)}
{resource.blob && (
<Descriptions.Item label={t('settings.mcp.resources.blob') || 'Binary Data'}>
{t('settings.mcp.resources.blobInvisible') || 'Binary data is not visible here.'}
</Descriptions.Item>
)}
</Descriptions>
)
}
return (
<Section>
<SectionTitle>{t('settings.mcp.resources.availableResources') || 'Available Resources'}</SectionTitle>
{resources.length > 0 ? (
<Collapse bordered={false} ghost>
{resources.map((resource) => (
<Collapse.Panel
key={resource.uri}
header={
<Flex vertical align="flex-start" style={{ width: '100%' }}>
<Flex align="center" style={{ width: '100%' }}>
<Typography.Text strong>{`${resource.name} (${resource.uri})`}</Typography.Text>
</Flex>
{resource.description && (
<Typography.Text type="secondary" style={{ fontSize: '13px', marginTop: 4 }}>
{resource.description.length > 100
? `${resource.description.substring(0, 100)}...`
: resource.description}
</Typography.Text>
)}
</Flex>
}>
<SelectableContent>{renderResourceProperties(resource)}</SelectableContent>
</Collapse.Panel>
))}
</Collapse>
) : (
<Empty
description={t('settings.mcp.resources.noResourcesAvailable') || 'No resources available'}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
)}
</Section>
)
}
const Section = styled.div`
margin-top: 8px;
padding-top: 8px;
`
const SectionTitle = styled.h3`
font-size: 14px;
font-weight: 500;
margin-bottom: 8px;
color: var(--color-text-secondary);
`
const SelectableContent = styled.div`
user-select: text;
padding: 0 12px;
`
export default MCPResourcesSection

View File

@ -1,6 +1,6 @@
import { DeleteOutlined, SaveOutlined } from '@ant-design/icons'
import { useMCPServers } from '@renderer/hooks/useMCPServers'
import { MCPPrompt, MCPServer, MCPTool } from '@renderer/types'
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'
@ -10,6 +10,7 @@ import styled from 'styled-components'
import { SettingContainer, SettingDivider, SettingGroup, SettingTitle } from '..'
import MCPPromptsSection from './McpPrompt'
import MCPResourcesSection from './McpResource'
import MCPToolsSection from './McpTool'
interface Props {
@ -42,7 +43,7 @@ const PipRegistry: Registry[] = [
{ name: '腾讯云', url: 'https://mirrors.cloud.tencent.com/pypi/simple/' }
]
type TabKey = 'settings' | 'tools' | 'prompts'
type TabKey = 'settings' | 'tools' | 'prompts' | 'resources'
const McpSettings: React.FC<Props> = ({ server }) => {
const { t } = useTranslation()
@ -56,6 +57,7 @@ const McpSettings: React.FC<Props> = ({ server }) => {
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[]>()
@ -146,10 +148,29 @@ const McpSettings: React.FC<Props> = ({ server }) => {
}
}
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])
@ -294,6 +315,9 @@ const McpSettings: React.FC<Props> = ({ server }) => {
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)
}
@ -466,6 +490,11 @@ const McpSettings: React.FC<Props> = ({ server }) => {
key: 'prompts',
label: t('settings.mcp.tabs.prompts'),
children: <MCPPromptsSection prompts={prompts} />
},
{
key: 'resources',
label: t('settings.mcp.tabs.resources'),
children: <MCPResourcesSection resources={resources} />
}
)
}

View File

@ -444,6 +444,22 @@ export interface MCPToolResponse {
response?: any
}
export interface MCPResource {
serverId: string
serverName: string
uri: string
name: string
description?: string
mimeType?: string
size?: number
text?: string
blob?: string
}
export interface GetResourceResponse {
contents: MCPResource[]
}
export interface QuickPhrase {
id: string
title: string