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:
parent
de7f806bbc
commit
e22d076d67
@ -41,6 +41,8 @@ export enum IpcChannel {
|
|||||||
Mcp_CallTool = 'mcp:call-tool',
|
Mcp_CallTool = 'mcp:call-tool',
|
||||||
Mcp_ListPrompts = 'mcp:list-prompts',
|
Mcp_ListPrompts = 'mcp:list-prompts',
|
||||||
Mcp_GetPrompt = 'mcp:get-prompt',
|
Mcp_GetPrompt = 'mcp:get-prompt',
|
||||||
|
Mcp_ListResources = 'mcp:list-resources',
|
||||||
|
Mcp_GetResource = 'mcp:get-resource',
|
||||||
Mcp_GetInstallInfo = 'mcp:get-install-info',
|
Mcp_GetInstallInfo = 'mcp:get-install-info',
|
||||||
Mcp_ServersChanged = 'mcp:servers-changed',
|
Mcp_ServersChanged = 'mcp:servers-changed',
|
||||||
Mcp_ServersUpdated = 'mcp:servers-updated',
|
Mcp_ServersUpdated = 'mcp:servers-updated',
|
||||||
|
|||||||
@ -275,6 +275,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
|||||||
ipcMain.handle(IpcChannel.Mcp_CallTool, mcpService.callTool)
|
ipcMain.handle(IpcChannel.Mcp_CallTool, mcpService.callTool)
|
||||||
ipcMain.handle(IpcChannel.Mcp_ListPrompts, mcpService.listPrompts)
|
ipcMain.handle(IpcChannel.Mcp_ListPrompts, mcpService.listPrompts)
|
||||||
ipcMain.handle(IpcChannel.Mcp_GetPrompt, mcpService.getPrompt)
|
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.Mcp_GetInstallInfo, mcpService.getInstallInfo)
|
||||||
|
|
||||||
ipcMain.handle(IpcChannel.App_IsBinaryExist, (_, name: string) => isBinaryExists(name))
|
ipcMain.handle(IpcChannel.App_IsBinaryExist, (_, name: string) => isBinaryExists(name))
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
|
|||||||
import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
||||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory'
|
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory'
|
||||||
import { nanoid } from '@reduxjs/toolkit'
|
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 { app } from 'electron'
|
||||||
import Logger from 'electron-log'
|
import Logger from 'electron-log'
|
||||||
|
|
||||||
@ -71,6 +71,8 @@ class McpService {
|
|||||||
this.callTool = this.callTool.bind(this)
|
this.callTool = this.callTool.bind(this)
|
||||||
this.listPrompts = this.listPrompts.bind(this)
|
this.listPrompts = this.listPrompts.bind(this)
|
||||||
this.getPrompt = this.getPrompt.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.closeClient = this.closeClient.bind(this)
|
||||||
this.removeServer = this.removeServer.bind(this)
|
this.removeServer = this.removeServer.bind(this)
|
||||||
this.restartServer = this.restartServer.bind(this)
|
this.restartServer = this.restartServer.bind(this)
|
||||||
@ -117,9 +119,9 @@ class McpService {
|
|||||||
try {
|
try {
|
||||||
await inMemoryServer.connect(serverTransport)
|
await inMemoryServer.connect(serverTransport)
|
||||||
Logger.info(`[MCP] In-memory server started: ${server.name}`)
|
Logger.info(`[MCP] In-memory server started: ${server.name}`)
|
||||||
} catch (error) {
|
} catch (error: Error | any) {
|
||||||
Logger.error(`[MCP] Error starting in-memory server: ${error}`)
|
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
|
// set the client transport to the client
|
||||||
transport = clientTransport
|
transport = clientTransport
|
||||||
@ -203,7 +205,7 @@ class McpService {
|
|||||||
return client
|
return client
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
Logger.error(`[MCP] Error activating server ${server.name}:`, error)
|
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)
|
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
|
* Get enhanced PATH including common tool locations
|
||||||
*/
|
*/
|
||||||
|
|||||||
4
src/preload/index.d.ts
vendored
4
src/preload/index.d.ts
vendored
@ -1,7 +1,7 @@
|
|||||||
import { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
|
import { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
|
||||||
import { ElectronAPI } from '@electron-toolkit/preload'
|
import { ElectronAPI } from '@electron-toolkit/preload'
|
||||||
import type { FileMetadataResponse, ListFilesResponse, UploadFileResponse } from '@google/generative-ai/server'
|
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 { AppInfo, FileType, KnowledgeBaseParams, KnowledgeItem, LanguageVarious, WebDavConfig } from '@renderer/types'
|
||||||
import type { LoaderReturn } from '@shared/config/types'
|
import type { LoaderReturn } from '@shared/config/types'
|
||||||
import type { OpenDialogOptions } from 'electron'
|
import type { OpenDialogOptions } from 'electron'
|
||||||
@ -161,6 +161,8 @@ declare global {
|
|||||||
name: string
|
name: string
|
||||||
args?: Record<string, any>
|
args?: Record<string, any>
|
||||||
}) => Promise<GetMCPPromptResponse>
|
}) => Promise<GetMCPPromptResponse>
|
||||||
|
listResources: (server: MCPServer) => Promise<MCPResource[]>
|
||||||
|
getResource: ({ server, uri }: { server: MCPServer; uri: string }) => Promise<GetResourceResponse>
|
||||||
getInstallInfo: () => Promise<{ dir: string; uvPath: string; bunPath: string }>
|
getInstallInfo: () => Promise<{ dir: string; uvPath: string; bunPath: string }>
|
||||||
}
|
}
|
||||||
copilot: {
|
copilot: {
|
||||||
|
|||||||
@ -135,6 +135,9 @@ const api = {
|
|||||||
listPrompts: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_ListPrompts, server),
|
listPrompts: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_ListPrompts, server),
|
||||||
getPrompt: ({ server, name, args }: { server: MCPServer; name: string; args?: Record<string, any> }) =>
|
getPrompt: ({ server, name, args }: { server: MCPServer; name: string; args?: Record<string, any> }) =>
|
||||||
ipcRenderer.invoke(IpcChannel.Mcp_GetPrompt, { server, name, args }),
|
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)
|
getInstallInfo: () => ipcRenderer.invoke(IpcChannel.Mcp_GetInstallInfo)
|
||||||
},
|
},
|
||||||
shell: {
|
shell: {
|
||||||
|
|||||||
@ -1112,6 +1112,16 @@
|
|||||||
"genericError": "Get prompt Error",
|
"genericError": "Get prompt Error",
|
||||||
"loadError": "Get prompts 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",
|
"deleteServer": "Delete Server",
|
||||||
"deleteServerConfirm": "Are you sure you want to delete this server?",
|
"deleteServerConfirm": "Are you sure you want to delete this server?",
|
||||||
"registry": "Package Registry",
|
"registry": "Package Registry",
|
||||||
|
|||||||
@ -1111,6 +1111,16 @@
|
|||||||
"genericError": "プロンプト取得エラー",
|
"genericError": "プロンプト取得エラー",
|
||||||
"loadError": "プロンプト取得エラー"
|
"loadError": "プロンプト取得エラー"
|
||||||
},
|
},
|
||||||
|
"resources": {
|
||||||
|
"noResourcesAvailable": "利用可能なリソースはありません",
|
||||||
|
"availableResources": "利用可能なリソース",
|
||||||
|
"uri": "URI",
|
||||||
|
"mimeType": "MIMEタイプ",
|
||||||
|
"size": "サイズ",
|
||||||
|
"blob": "バイナリデータ",
|
||||||
|
"blobInvisible": "バイナリデータを非表示",
|
||||||
|
"text": "テキスト"
|
||||||
|
},
|
||||||
"deleteServer": "サーバーを削除",
|
"deleteServer": "サーバーを削除",
|
||||||
"deleteServerConfirm": "このサーバーを削除してもよろしいですか?",
|
"deleteServerConfirm": "このサーバーを削除してもよろしいですか?",
|
||||||
"registry": "パッケージ管理レジストリ",
|
"registry": "パッケージ管理レジストリ",
|
||||||
|
|||||||
@ -1111,6 +1111,16 @@
|
|||||||
"genericError": "Ошибка получения подсказки",
|
"genericError": "Ошибка получения подсказки",
|
||||||
"loadError": "Ошибка получения подсказок"
|
"loadError": "Ошибка получения подсказок"
|
||||||
},
|
},
|
||||||
|
"resources": {
|
||||||
|
"noResourcesAvailable": "Нет доступных ресурсов",
|
||||||
|
"availableResources": "Доступные ресурсы",
|
||||||
|
"uri": "URI",
|
||||||
|
"mimeType": "MIME-тип",
|
||||||
|
"size": "Размер",
|
||||||
|
"blob": "Двоичные данные",
|
||||||
|
"blobInvisible": "Скрытые двоичные данные",
|
||||||
|
"text": "Текст"
|
||||||
|
},
|
||||||
"deleteServer": "Удалить сервер",
|
"deleteServer": "Удалить сервер",
|
||||||
"deleteServerConfirm": "Вы уверены, что хотите удалить этот сервер?",
|
"deleteServerConfirm": "Вы уверены, что хотите удалить этот сервер?",
|
||||||
"registry": "Реестр пакетов",
|
"registry": "Реестр пакетов",
|
||||||
|
|||||||
@ -1112,6 +1112,16 @@
|
|||||||
"genericError": "获取提示错误",
|
"genericError": "获取提示错误",
|
||||||
"loadError": "获取提示失败"
|
"loadError": "获取提示失败"
|
||||||
},
|
},
|
||||||
|
"resources": {
|
||||||
|
"noResourcesAvailable": "无可用资源",
|
||||||
|
"availableResources": "可用资源",
|
||||||
|
"uri": "URI",
|
||||||
|
"mimeType": "MIME类型",
|
||||||
|
"size": "大小",
|
||||||
|
"blob": "二进制数据",
|
||||||
|
"blobInvisible": "隐藏二进制数据",
|
||||||
|
"text": "文本"
|
||||||
|
},
|
||||||
"deleteServer": "删除服务器",
|
"deleteServer": "删除服务器",
|
||||||
"deleteServerConfirm": "确定要删除此服务器吗?",
|
"deleteServerConfirm": "确定要删除此服务器吗?",
|
||||||
"registry": "包管理源",
|
"registry": "包管理源",
|
||||||
|
|||||||
@ -1111,6 +1111,16 @@
|
|||||||
"genericError": "獲取提示錯誤",
|
"genericError": "獲取提示錯誤",
|
||||||
"loadError": "獲取提示失敗"
|
"loadError": "獲取提示失敗"
|
||||||
},
|
},
|
||||||
|
"resources": {
|
||||||
|
"noResourcesAvailable": "無可用資源",
|
||||||
|
"availableResources": "可用資源",
|
||||||
|
"uri": "URI",
|
||||||
|
"mimeType": "MIME類型",
|
||||||
|
"size": "大小",
|
||||||
|
"blob": "二進位數據",
|
||||||
|
"blobInvisible": "隱藏二進位數據",
|
||||||
|
"text": "文字"
|
||||||
|
},
|
||||||
"deleteServer": "刪除伺服器",
|
"deleteServer": "刪除伺服器",
|
||||||
"deleteServerConfirm": "確定要刪除此伺服器嗎?",
|
"deleteServerConfirm": "確定要刪除此伺服器嗎?",
|
||||||
"registry": "套件管理源",
|
"registry": "套件管理源",
|
||||||
|
|||||||
@ -355,7 +355,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'MCP Prompt',
|
label: `MCP ${t('settings.mcp.tabs.prompts')}`,
|
||||||
description: '',
|
description: '',
|
||||||
icon: <CodeOutlined />,
|
icon: <CodeOutlined />,
|
||||||
isMenu: true,
|
isMenu: true,
|
||||||
@ -363,6 +363,15 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
|||||||
mcpToolsButtonRef.current?.openPromptList()
|
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'),
|
label: isVisionModel(model) ? t('chat.input.upload') : t('chat.input.upload.document'),
|
||||||
description: '',
|
description: '',
|
||||||
|
|||||||
@ -1,16 +1,17 @@
|
|||||||
import { CodeOutlined, PlusOutlined } from '@ant-design/icons'
|
import { CodeOutlined, PlusOutlined } from '@ant-design/icons'
|
||||||
import { QuickPanelListItem, useQuickPanel } from '@renderer/components/QuickPanel'
|
import { QuickPanelListItem, useQuickPanel } from '@renderer/components/QuickPanel'
|
||||||
import { useMCPServers } from '@renderer/hooks/useMCPServers'
|
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 { Form, Input, Modal, Tooltip } from 'antd'
|
||||||
import { SquareTerminal } from 'lucide-react'
|
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 { useTranslation } from 'react-i18next'
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
|
|
||||||
export interface MCPToolsButtonRef {
|
export interface MCPToolsButtonRef {
|
||||||
openQuickPanel: () => void
|
openQuickPanel: () => void
|
||||||
openPromptList: () => void
|
openPromptList: () => void
|
||||||
|
openResourcesList: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -168,6 +169,7 @@ const MCPToolsButton: FC<Props> = ({
|
|||||||
|
|
||||||
const handlePromptSelect = useCallback(
|
const handlePromptSelect = useCallback(
|
||||||
(prompt: MCPPrompt) => {
|
(prompt: MCPPrompt) => {
|
||||||
|
// Using a 10ms delay to ensure the modal or UI updates are fully rendered before executing the logic.
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
const server = enabledMCPs.find((s) => s.id === prompt.serverId)
|
const server = enabledMCPs.find((s) => s.id === prompt.serverId)
|
||||||
if (server) {
|
if (server) {
|
||||||
@ -284,6 +286,112 @@ const MCPToolsButton: FC<Props> = ({
|
|||||||
})
|
})
|
||||||
}, [promptList, quickPanel, t])
|
}, [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 = ``
|
||||||
|
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 = ``
|
||||||
|
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(() => {
|
const handleOpenQuickPanel = useCallback(() => {
|
||||||
if (quickPanel.isVisible && quickPanel.symbol === 'mcp') {
|
if (quickPanel.isVisible && quickPanel.symbol === 'mcp') {
|
||||||
quickPanel.close()
|
quickPanel.close()
|
||||||
@ -294,7 +402,8 @@ const MCPToolsButton: FC<Props> = ({
|
|||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
openQuickPanel,
|
openQuickPanel,
|
||||||
openPromptList
|
openPromptList,
|
||||||
|
openResourcesList
|
||||||
}))
|
}))
|
||||||
|
|
||||||
if (activedMcpServers.length === 0) {
|
if (activedMcpServers.length === 0) {
|
||||||
|
|||||||
108
src/renderer/src/pages/settings/MCPSettings/McpResource.tsx
Normal file
108
src/renderer/src/pages/settings/MCPSettings/McpResource.tsx
Normal 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
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { DeleteOutlined, SaveOutlined } from '@ant-design/icons'
|
import { DeleteOutlined, SaveOutlined } from '@ant-design/icons'
|
||||||
import { useMCPServers } from '@renderer/hooks/useMCPServers'
|
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 { Button, Flex, Form, Input, Radio, Switch, Tabs } from 'antd'
|
||||||
import TextArea from 'antd/es/input/TextArea'
|
import TextArea from 'antd/es/input/TextArea'
|
||||||
import React, { useCallback, useEffect, useState } from 'react'
|
import React, { useCallback, useEffect, useState } from 'react'
|
||||||
@ -10,6 +10,7 @@ import styled from 'styled-components'
|
|||||||
|
|
||||||
import { SettingContainer, SettingDivider, SettingGroup, SettingTitle } from '..'
|
import { SettingContainer, SettingDivider, SettingGroup, SettingTitle } from '..'
|
||||||
import MCPPromptsSection from './McpPrompt'
|
import MCPPromptsSection from './McpPrompt'
|
||||||
|
import MCPResourcesSection from './McpResource'
|
||||||
import MCPToolsSection from './McpTool'
|
import MCPToolsSection from './McpTool'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -42,7 +43,7 @@ const PipRegistry: Registry[] = [
|
|||||||
{ name: '腾讯云', url: 'https://mirrors.cloud.tencent.com/pypi/simple/' }
|
{ 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 McpSettings: React.FC<Props> = ({ server }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@ -56,6 +57,7 @@ const McpSettings: React.FC<Props> = ({ server }) => {
|
|||||||
|
|
||||||
const [tools, setTools] = useState<MCPTool[]>([])
|
const [tools, setTools] = useState<MCPTool[]>([])
|
||||||
const [prompts, setPrompts] = useState<MCPPrompt[]>([])
|
const [prompts, setPrompts] = useState<MCPPrompt[]>([])
|
||||||
|
const [resources, setResources] = useState<MCPResource[]>([])
|
||||||
const [isShowRegistry, setIsShowRegistry] = useState(false)
|
const [isShowRegistry, setIsShowRegistry] = useState(false)
|
||||||
const [registry, setRegistry] = useState<Registry[]>()
|
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(() => {
|
useEffect(() => {
|
||||||
if (server.isActive) {
|
if (server.isActive) {
|
||||||
fetchTools()
|
fetchTools()
|
||||||
fetchPrompts()
|
fetchPrompts()
|
||||||
|
fetchResources()
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [server.id, server.isActive])
|
}, [server.id, server.isActive])
|
||||||
@ -294,6 +315,9 @@ const McpSettings: React.FC<Props> = ({ server }) => {
|
|||||||
|
|
||||||
const localPrompts = await window.api.mcp.listPrompts(server)
|
const localPrompts = await window.api.mcp.listPrompts(server)
|
||||||
setPrompts(localPrompts)
|
setPrompts(localPrompts)
|
||||||
|
|
||||||
|
const localResources = await window.api.mcp.listResources(server)
|
||||||
|
setResources(localResources)
|
||||||
} else {
|
} else {
|
||||||
await window.api.mcp.stopServer(server)
|
await window.api.mcp.stopServer(server)
|
||||||
}
|
}
|
||||||
@ -466,6 +490,11 @@ const McpSettings: React.FC<Props> = ({ server }) => {
|
|||||||
key: 'prompts',
|
key: 'prompts',
|
||||||
label: t('settings.mcp.tabs.prompts'),
|
label: t('settings.mcp.tabs.prompts'),
|
||||||
children: <MCPPromptsSection prompts={prompts} />
|
children: <MCPPromptsSection prompts={prompts} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'resources',
|
||||||
|
label: t('settings.mcp.tabs.resources'),
|
||||||
|
children: <MCPResourcesSection resources={resources} />
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -444,6 +444,22 @@ export interface MCPToolResponse {
|
|||||||
response?: any
|
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 {
|
export interface QuickPhrase {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user