feat: add Model Context Protocol (MCP) support (#2809)

*  feat: add Model Context Protocol (MCP) server configuration (main)

- Added `@modelcontextprotocol/sdk` dependency for MCP integration.
- Introduced MCP server configuration UI in settings with add, edit, delete, and activation functionalities.
- Created `useMCPServers` hook to manage MCP server state and actions.
- Added i18n support for MCP settings with translation keys.
- Integrated MCP settings into the application's settings navigation and routing.
- Implemented Redux state management for MCP servers.
- Updated `yarn.lock` with new dependencies and their resolutions.

* 🌟 feat: implement mcp service and integrate with ipc handlers

- Added `MCPService` class to manage Model Context Protocol servers.
- Implemented various handlers in `ipc.ts` for managing MCP servers including listing, adding, updating, deleting, and activating/deactivating servers.
- Integrated MCP related types into existing type declarations for consistency across the application.
- Updated `preload` to expose new MCP related APIs to the renderer process.
- Enhanced `MCPSettings` component to interact directly with the new MCP service for adding, updating, deleting servers and setting their active states.
- Introduced selectors in the MCP Redux slice for fetching active and all servers from the store.
- Moved MCP types to a centralized location in `@renderer/types` for reuse across different parts of the application.

* feat: enhance MCPService initialization to prevent recursive calls and improve error handling

* feat: enhance MCP integration by adding MCPTool type and updating related methods

* feat: implement streaming support for tool calls in OpenAIProvider and enhance message processing
This commit is contained in:
LiuVaayne
2025-03-05 17:59:08 +08:00
committed by kangfenmao
parent 4a8307195b
commit eda82fa23e
18 changed files with 1609 additions and 97 deletions
+1 -2
View File
@@ -19,8 +19,7 @@ import {
updateItemProcessingStatus,
updateNotes
} from '@renderer/store/knowledge'
import { FileType, KnowledgeBase, ProcessingStatus } from '@renderer/types'
import { KnowledgeItem } from '@renderer/types'
import { FileType, KnowledgeBase, KnowledgeItem, ProcessingStatus } from '@renderer/types'
import { runAsyncFunction } from '@renderer/utils'
import { useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
+42
View File
@@ -0,0 +1,42 @@
import { useAppDispatch, useAppSelector } from '@renderer/store'
import {
addMCPServer as _addMCPServer,
deleteMCPServer as _deleteMCPServer,
setMCPServerActive as _setMCPServerActive,
updateMCPServer as _updateMCPServer
} from '@renderer/store/mcp'
import { MCPServer } from '@renderer/types'
export const useMCPServers = () => {
const mcpServers = useAppSelector((state) => state.mcp.servers)
const dispatch = useAppDispatch()
const addMCPServer = (server: MCPServer) => {
dispatch(_addMCPServer(server))
}
const updateMCPServer = (server: MCPServer) => {
dispatch(_updateMCPServer(server))
}
const deleteMCPServer = (name: string) => {
dispatch(_deleteMCPServer(name))
}
const setMCPServerActive = (name: string, isActive: boolean) => {
dispatch(_setMCPServerActive({ name, isActive }))
}
const getActiveMCPServers = () => {
return mcpServers.filter((server) => server.isActive)
}
return {
mcpServers,
addMCPServer,
updateMCPServer,
deleteMCPServer,
setMCPServerActive,
getActiveMCPServers
}
}
+26
View File
@@ -859,6 +859,32 @@
"blacklist_tooltip": "Please use the following format (separated by line breaks)\nexample.com\nhttps://www.example.com\nhttps://example.com\n*://*.example.com",
"search_max_result": "Number of search results",
"search_result_default": "Default"
},
"mcp": {
"title": "MCP Servers",
"config_description": "Configure Model Context Protocol servers",
"description": "Description",
"addServer": "Add Server",
"editServer": "Edit Server",
"name": "Name",
"command": "Command",
"args": "Arguments",
"argsTooltip": "Each argument on a new line",
"env": "Environment Variables",
"envTooltip": "Format: KEY=value, one per line",
"active": "Active",
"actions": "Actions",
"noServers": "No servers configured",
"nameRequired": "Please enter a server name",
"commandRequired": "Please enter a command",
"confirmDelete": "Delete Server",
"confirmDeleteMessage": "Are you sure you want to delete the server?",
"addSuccess": "Server added successfully",
"updateSuccess": "Server updated successfully",
"deleteSuccess": "Server deleted successfully",
"duplicateName": "A server with this name already exists",
"serverSingular": "server",
"serverPlural": "servers"
}
},
"translate": {
@@ -0,0 +1,299 @@
import { DeleteOutlined, EditOutlined, PlusOutlined, QuestionCircleOutlined } from '@ant-design/icons'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { addMCPServer, deleteMCPServer, setMCPServerActive, updateMCPServer } from '@renderer/store/mcp'
import { MCPServer } from '@renderer/types'
import { Button, Card, Form, Input, message, Modal, Space, Switch, Table, Tooltip, Typography } from 'antd'
import TextArea from 'antd/es/input/TextArea'
import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingContainer, SettingDivider, SettingGroup, SettingTitle } from '.'
interface MCPFormValues {
name: string
command: string
description?: string
args: string
env?: string
isActive: boolean
}
const MCPSettings: FC = () => {
const { t } = useTranslation()
const { theme } = useTheme()
const { Paragraph, Text } = Typography
const dispatch = useAppDispatch()
const mcpServers = useAppSelector((state) => state.mcp.servers)
const [isModalVisible, setIsModalVisible] = useState(false)
const [editingServer, setEditingServer] = useState<MCPServer | null>(null)
const [loading, setLoading] = useState(false)
const [form] = Form.useForm<MCPFormValues>()
const showAddModal = () => {
form.resetFields()
setEditingServer(null)
setIsModalVisible(true)
}
const showEditModal = (server: MCPServer) => {
setEditingServer(server)
form.setFieldsValue({
name: server.name,
command: server.command,
description: server.description,
args: server.args.join('\n'),
env: server.env
? Object.entries(server.env)
.map(([key, value]) => `${key}=${value}`)
.join('\n')
: '',
isActive: server.isActive
})
setIsModalVisible(true)
}
const handleCancel = () => {
setIsModalVisible(false)
form.resetFields()
}
const handleSubmit = () => {
setLoading(true)
form
.validateFields()
.then((values) => {
const args = values.args ? values.args.split('\n').filter((arg) => arg.trim() !== '') : []
const env: Record<string, string> = {}
if (values.env) {
values.env.split('\n').forEach((line) => {
if (line.trim()) {
const [key, value] = line.split('=')
if (key && value) {
env[key.trim()] = value.trim()
}
}
})
}
const mcpServer: MCPServer = {
name: values.name,
command: values.command,
description: values.description,
args,
env: Object.keys(env).length > 0 ? env : undefined,
isActive: values.isActive
}
if (editingServer) {
window.api.mcp
.updateServer(mcpServer)
.then(() => {
message.success(t('settings.mcp.updateSuccess'))
setLoading(false)
setIsModalVisible(false)
form.resetFields()
})
.catch((error) => {
message.error(`${t('settings.mcp.updateError')}: ${error.message}`)
setLoading(false)
})
dispatch(updateMCPServer(mcpServer))
} else {
// Check for duplicate name
if (mcpServers.some((server: MCPServer) => server.name === mcpServer.name)) {
message.error(t('settings.mcp.duplicateName'))
setLoading(false)
return
}
window.api.mcp
.addServer(mcpServer)
.then(() => {
message.success(t('settings.mcp.addSuccess'))
setLoading(false)
setIsModalVisible(false)
form.resetFields()
})
.catch((error) => {
message.error(`${t('settings.mcp.addError')}: ${error.message}`)
setLoading(false)
})
dispatch(addMCPServer(mcpServer))
}
})
.catch(() => {
setLoading(false)
})
}
const handleDelete = (serverName: string) => {
Modal.confirm({
title: t('settings.mcp.confirmDelete'),
content: t('settings.mcp.confirmDeleteMessage'),
okText: t('common.delete'),
okButtonProps: { danger: true },
cancelText: t('common.cancel'),
onOk: () => {
window.api.mcp
.deleteServer(serverName)
.then(() => {
message.success(t('settings.mcp.deleteSuccess'))
})
.catch((error) => {
message.error(`${t('settings.mcp.deleteError')}: ${error.message}`)
})
dispatch(deleteMCPServer(serverName))
}
})
}
const handleToggleActive = (name: string, isActive: boolean) => {
window.api.mcp
.setServerActive(name, isActive)
.then(() => {
// Optional: Show success message or update UI
})
.catch((error) => {
message.error(`${t('settings.mcp.toggleError')}: ${error.message}`)
})
dispatch(setMCPServerActive({ name, isActive }))
}
const columns = [
{
title: t('settings.mcp.name'),
dataIndex: 'name',
key: 'name',
width: '20%',
render: (text: string, record: MCPServer) => <Text strong={record.isActive}>{text}</Text>
},
{
title: t('settings.mcp.description'),
dataIndex: 'description',
key: 'description',
width: '40%',
render: (text: string) =>
text || (
<Text type="secondary" italic>
{t('common.description')}
</Text>
)
},
{
title: t('settings.mcp.active'),
dataIndex: 'isActive',
key: 'isActive',
width: '15%',
render: (isActive: boolean, record: MCPServer) => (
<Switch checked={isActive} onChange={(checked) => handleToggleActive(record.name, checked)} />
)
},
{
title: t('settings.mcp.actions'),
key: 'actions',
width: '25%',
render: (_: any, record: MCPServer) => (
<Space>
<Tooltip title={t('common.edit')}>
<Button type="primary" ghost icon={<EditOutlined />} onClick={() => showEditModal(record)} />
</Tooltip>
<Tooltip title={t('common.delete')}>
<Button danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.name)} />
</Tooltip>
</Space>
)
}
]
// Create a CSS class for inactive rows instead of using jsx global
const inactiveRowStyle = {
opacity: 0.7,
backgroundColor: theme === 'dark' ? '#1a1a1a' : '#f5f5f5'
}
return (
<SettingContainer theme={theme}>
<SettingGroup theme={theme}>
<SettingTitle>
{t('settings.mcp.title')}
<Tooltip title={t('settings.mcp.config_description')}>
<QuestionCircleOutlined style={{ marginLeft: 8, fontSize: 14 }} />
</Tooltip>
</SettingTitle>
<SettingDivider />
<Paragraph type="secondary" style={{ margin: '0 0 20px 0' }}>
{t('settings.mcp.config_description')}
</Paragraph>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Button type="primary" icon={<PlusOutlined />} onClick={showAddModal}>
{t('settings.mcp.addServer')}
</Button>
<Text type="secondary">
{mcpServers.length}{' '}
{mcpServers.length === 1 ? t('settings.mcp.serverSingular') : t('settings.mcp.serverPlural')}
</Text>
</div>
<Card bordered={false} style={{ background: theme === 'dark' ? '#1f1f1f' : '#fff' }}>
<Table
dataSource={mcpServers}
columns={columns}
rowKey="name"
pagination={false}
locale={{ emptyText: t('settings.mcp.noServers') }}
rowClassName={(record) => (!record.isActive ? 'inactive-row' : '')}
onRow={(record) => ({
style: !record.isActive ? inactiveRowStyle : {}
})}
/>
</Card>
<Modal
title={editingServer ? t('settings.mcp.editServer') : t('settings.mcp.addServer')}
open={isModalVisible}
onCancel={handleCancel}
onOk={handleSubmit}
confirmLoading={loading}
width={600}>
<Form form={form} layout="vertical">
<Form.Item
name="name"
label={t('settings.mcp.name')}
rules={[{ required: true, message: t('settings.mcp.nameRequired') }]}>
<Input disabled={!!editingServer} placeholder={t('common.name')} />
</Form.Item>
<Form.Item name="description" label={t('settings.mcp.description')}>
<TextArea rows={2} placeholder={t('common.description')} />
</Form.Item>
<Form.Item
name="command"
label={t('settings.mcp.command')}
rules={[{ required: true, message: t('settings.mcp.commandRequired') }]}>
<Input placeholder="python script.py" />
</Form.Item>
<Form.Item name="args" label={t('settings.mcp.args')} tooltip={t('settings.mcp.argsTooltip')}>
<TextArea rows={3} placeholder="{--param1}\n{--param2 value}" style={{ fontFamily: 'monospace' }} />
</Form.Item>
<Form.Item name="env" label={t('settings.mcp.env')} tooltip={t('settings.mcp.envTooltip')}>
<TextArea rows={3} placeholder="KEY1=value1\nKEY2=value2" style={{ fontFamily: 'monospace' }} />
</Form.Item>
<Form.Item name="isActive" label={t('settings.mcp.active')} valuePropName="checked" initialValue={true}>
<Switch />
</Form.Item>
</Form>
</Modal>
</SettingGroup>
</SettingContainer>
)
}
export default MCPSettings
@@ -1,5 +1,6 @@
import {
CloudOutlined,
CodeOutlined,
GlobalOutlined,
InfoCircleOutlined,
LayoutOutlined,
@@ -20,6 +21,7 @@ import AboutSettings from './AboutSettings'
import DataSettings from './DataSettings/DataSettings'
import DisplaySettings from './DisplaySettings/DisplaySettings'
import GeneralSettings from './GeneralSettings'
import MCPSettings from './MCPSettings'
import ProvidersList from './ProviderSettings'
import QuickAssistantSettings from './QuickAssistantSettings'
import ShortcutSettings from './ShortcutSettings'
@@ -60,6 +62,12 @@ const SettingsPage: FC = () => {
{t('settings.websearch.title')}
</MenuItem>
</MenuItemLink>
<MenuItemLink to="/settings/mcp">
<MenuItem className={isRoute('/settings/mcp')}>
<CodeOutlined />
{t('settings.mcp.title')}
</MenuItem>
</MenuItemLink>
<MenuItemLink to="/settings/general">
<MenuItem className={isRoute('/settings/general')}>
<SettingOutlined />
@@ -102,6 +110,7 @@ const SettingsPage: FC = () => {
<Route path="provider" element={<ProvidersList />} />
<Route path="model" element={<ModelSettings />} />
<Route path="web-search" element={<WebSearchSettings />} />
<Route path="mcp" element={<MCPSettings />} />
<Route path="general/*" element={<GeneralSettings />} />
<Route path="display" element={<DisplaySettings />} />
<Route path="data/*" element={<DataSettings />} />
+8 -2
View File
@@ -16,8 +16,14 @@ export default class AiProvider {
return this.sdk.fakeCompletions(params)
}
public async completions({ messages, assistant, onChunk, onFilterMessages }: CompletionsParams): Promise<void> {
return this.sdk.completions({ messages, assistant, onChunk, onFilterMessages })
public async completions({
messages,
assistant,
onChunk,
onFilterMessages,
mcpTools
}: CompletionsParams): Promise<void> {
return this.sdk.completions({ messages, assistant, onChunk, onFilterMessages, mcpTools })
}
public async translate(message: Message, assistant: Assistant, onResponse?: (text: string) => void): Promise<string> {
+182 -57
View File
@@ -11,14 +11,27 @@ import i18n from '@renderer/i18n'
import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/AssistantService'
import { EVENT_NAMES } from '@renderer/services/EventService'
import { filterContextMessages, filterUserRoleStartMessages } from '@renderer/services/MessagesService'
import { Assistant, FileTypes, GenerateImageParams, Message, Model, Provider, Suggestion } from '@renderer/types'
import {
Assistant,
FileTypes,
GenerateImageParams,
MCPTool,
Message,
Model,
Provider,
Suggestion
} from '@renderer/types'
import { removeSpecialCharacters } from '@renderer/utils'
import { takeRight } from 'lodash'
import OpenAI, { AzureOpenAI } from 'openai'
import {
ChatCompletionAssistantMessageParam,
ChatCompletionContentPart,
ChatCompletionCreateParamsNonStreaming,
ChatCompletionMessageParam
ChatCompletionMessageParam,
ChatCompletionMessageToolCall,
ChatCompletionTool,
ChatCompletionToolMessageParam
} from 'openai/resources'
import { CompletionsParams } from '.'
@@ -213,7 +226,37 @@ export default class OpenAIProvider extends BaseProvider {
return model.id.startsWith('o1')
}
async completions({ messages, assistant, onChunk, onFilterMessages }: CompletionsParams): Promise<void> {
private mcpToolsToOpenAITools(mcpTools: MCPTool[]): Array<ChatCompletionTool> {
return mcpTools.map((tool) => ({
type: 'function',
function: {
name: `mcp.${tool.serverName}.${tool.name}`,
description: tool.description,
parameters: {
type: 'object',
properties: tool.inputSchema
}
}
}))
}
private openAIToolsToMcpTool(tool: ChatCompletionMessageToolCall): MCPTool | undefined {
const parts = tool.function.name.split('.')
if (parts[0] !== 'mcp') {
console.log('Invalid tool name', tool.function.name)
return undefined
}
const serverName = parts[1]
const name = parts[2]
return {
serverName: serverName,
name: name,
inputSchema: JSON.parse(tool.function.arguments)
} as MCPTool
}
async completions({ messages, assistant, onChunk, onFilterMessages, mcpTools }: CompletionsParams): Promise<void> {
const defaultModel = getDefaultModel()
const model = assistant.model || defaultModel
const { contextCount, maxTokens, streamOutput } = getAssistantSettings(assistant)
@@ -285,17 +328,151 @@ export default class OpenAIProvider extends BaseProvider {
const { abortController, cleanup } = this.createAbortController(lastUserMessage?.id)
const { signal } = abortController
const tools = mcpTools ? this.mcpToolsToOpenAITools(mcpTools) : undefined
const reqMessages: ChatCompletionMessageParam[] = [systemMessage, ...userMessages].filter(
Boolean
) as ChatCompletionMessageParam[]
const processStream = async (stream: any) => {
if (!isSupportStreamOutput()) {
const time_completion_millsec = new Date().getTime() - start_time_millsec
return onChunk({
text: stream.choices[0].message?.content || '',
usage: stream.usage,
metrics: {
completion_tokens: stream.usage?.completion_tokens,
time_completion_millsec,
time_first_token_millsec: 0
}
})
}
const toolCalls: ChatCompletionMessageToolCall[] = []
for await (const chunk of stream) {
if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) {
break
}
const delta = chunk.choices[0]?.delta
if (delta?.reasoning_content || delta?.reasoning) {
hasReasoningContent = true
}
if (time_first_token_millsec == 0) {
time_first_token_millsec = new Date().getTime() - start_time_millsec
}
if (time_first_content_millsec == 0 && isReasoningJustDone(delta)) {
time_first_content_millsec = new Date().getTime()
}
const time_completion_millsec = new Date().getTime() - start_time_millsec
const time_thinking_millsec = time_first_content_millsec ? time_first_content_millsec - start_time_millsec : 0
// Extract citations from the raw response if available
const citations = (chunk as OpenAI.Chat.Completions.ChatCompletionChunk & { citations?: string[] })?.citations
if (delta?.tool_calls) {
const chunkToolCalls: OpenAI.Chat.Completions.ChatCompletionChunk.Choice.Delta.ToolCall[] = delta.tool_calls
if (chunk.choices[0].finish_reason !== 'tool_calls') {
if (toolCalls.length === 0) {
for (const toolCall of chunkToolCalls) {
toolCalls.push(toolCall as ChatCompletionMessageToolCall)
}
} else {
for (let i = 0; i < chunkToolCalls.length; i++) {
toolCalls[i].function.arguments += chunkToolCalls[i].function?.arguments || ''
}
}
continue
}
}
if (chunk.choices[0].finish_reason === 'tool_calls') {
console.log('start invoke tools', toolCalls)
reqMessages.push({
role: 'assistant',
tool_calls: toolCalls
} as ChatCompletionAssistantMessageParam)
for (const toolCall of toolCalls) {
const mcpTool = this.openAIToolsToMcpTool(toolCall)
console.log('mcpTool', JSON.stringify(mcpTool, null, 2))
if (!mcpTool) {
console.log('Invalid tool', toolCall)
continue
}
const toolCallResponse = await window.api.mcp.callTool({
client: mcpTool.serverName,
name: mcpTool.name,
args: mcpTool.inputSchema
})
console.log(`Tool ${mcpTool.serverName} - ${mcpTool.name} Call Response:`)
console.log(toolCallResponse)
reqMessages.push({
role: 'tool',
content: JSON.stringify(toolCallResponse, null, 2),
tool_call_id: toolCall.id
} as ChatCompletionToolMessageParam)
}
const newStream = await this.sdk.chat.completions
// @ts-ignore key is not typed
.create(
{
model: model.id,
messages: reqMessages,
temperature: this.getTemperature(assistant, model),
top_p: this.getTopP(assistant, model),
max_tokens: maxTokens,
keep_alive: this.keepAliveTime,
stream: isSupportStreamOutput(),
tools: tools,
...getOpenAIWebSearchParams(assistant, model),
...this.getReasoningEffort(assistant, model),
...this.getProviderSpecificParameters(assistant, model),
...this.getCustomParameters(assistant)
},
{
signal
}
)
.finally(cleanup)
await processStream(newStream)
}
onChunk({
text: delta?.content || '',
// @ts-ignore key is not typed
reasoning_content: delta?.reasoning_content || delta?.reasoning || '',
usage: chunk.usage,
metrics: {
completion_tokens: chunk.usage?.completion_tokens,
time_completion_millsec,
time_first_token_millsec,
time_thinking_millsec
},
citations
})
}
}
const stream = await this.sdk.chat.completions
// @ts-ignore key is not typed
.create(
{
model: model.id,
messages: [systemMessage, ...userMessages].filter(Boolean) as ChatCompletionMessageParam[],
messages: reqMessages,
temperature: this.getTemperature(assistant, model),
top_p: this.getTopP(assistant, model),
max_tokens: maxTokens,
keep_alive: this.keepAliveTime,
stream: isSupportStreamOutput(),
tools: tools,
...getOpenAIWebSearchParams(assistant, model),
...this.getReasoningEffort(assistant, model),
...this.getProviderSpecificParameters(assistant, model),
@@ -307,59 +484,7 @@ export default class OpenAIProvider extends BaseProvider {
)
.finally(cleanup)
if (!isSupportStreamOutput()) {
const time_completion_millsec = new Date().getTime() - start_time_millsec
return onChunk({
text: stream.choices[0].message?.content || '',
usage: stream.usage,
metrics: {
completion_tokens: stream.usage?.completion_tokens,
time_completion_millsec,
time_first_token_millsec: 0
}
})
}
// @ts-expect-error `stream` is not typed
for await (const chunk of stream) {
if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) {
break
}
const delta = chunk.choices[0]?.delta
if (delta?.reasoning_content || delta?.reasoning) {
hasReasoningContent = true
}
if (time_first_token_millsec == 0) {
time_first_token_millsec = new Date().getTime() - start_time_millsec
}
if (time_first_content_millsec == 0 && isReasoningJustDone(delta)) {
time_first_content_millsec = new Date().getTime()
}
const time_completion_millsec = new Date().getTime() - start_time_millsec
const time_thinking_millsec = time_first_content_millsec ? time_first_content_millsec - start_time_millsec : 0
// Extract citations from the raw response if available
const citations = (chunk as OpenAI.Chat.Completions.ChatCompletionChunk & { citations?: string[] })?.citations
onChunk({
text: delta?.content || '',
// @ts-ignore key is not typed
reasoning_content: delta?.reasoning_content || delta?.reasoning || '',
usage: chunk.usage,
metrics: {
completion_tokens: chunk.usage?.completion_tokens,
time_completion_millsec,
time_first_token_millsec,
time_thinking_millsec
},
citations
})
}
await processStream(stream)
}
async translate(message: Message, assistant: Assistant, onResponse?: (text: string) => void) {
+1
View File
@@ -15,4 +15,5 @@ interface CompletionsParams {
assistant: Assistant
onChunk: ({ text, reasoning_content, usage, metrics, search, citations }: ChunkCallbackData) => void
onFilterMessages: (messages: Message[]) => void
mcpTools?: MCPTool[]
}
+6 -1
View File
@@ -77,6 +77,10 @@ export async function fetchChatCompletion({
}
}
const allMCPTools = await window.api.mcp.listTools()
if (allMCPTools.length > 0) {
console.log('Available MCP tools:', allMCPTools)
}
await AI.completions({
messages: filterUsefulMessages(messages),
assistant,
@@ -104,7 +108,8 @@ export async function fetchChatCompletion({
}
onResponse({ ...message, status: 'pending' })
}
},
mcpTools: allMCPTools
})
message.status = 'success'
+3 -1
View File
@@ -7,6 +7,7 @@ import agents from './agents'
import assistants from './assistants'
import knowledge from './knowledge'
import llm from './llm'
import mcp from './mcp'
import migrate from './migrate'
import minapps from './minapps'
import paintings from './paintings'
@@ -25,7 +26,8 @@ const rootReducer = combineReducers({
shortcuts,
knowledge,
minapps,
websearch
websearch,
mcp
})
const persistedReducer = persistReducer(
+51
View File
@@ -0,0 +1,51 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { MCPConfig, MCPServer } from '@renderer/types'
const initialState: MCPConfig = {
servers: []
}
const mcpSlice = createSlice({
name: 'mcp',
initialState,
reducers: {
setMCPServers: (state, action: PayloadAction<MCPServer[]>) => {
state.servers = action.payload
},
addMCPServer: (state, action: PayloadAction<MCPServer>) => {
state.servers.push(action.payload)
},
updateMCPServer: (state, action: PayloadAction<MCPServer>) => {
const index = state.servers.findIndex((server) => server.name === action.payload.name)
if (index !== -1) {
state.servers[index] = action.payload
}
},
deleteMCPServer: (state, action: PayloadAction<string>) => {
state.servers = state.servers.filter((server) => server.name !== action.payload)
},
setMCPServerActive: (state, action: PayloadAction<{ name: string; isActive: boolean }>) => {
const index = state.servers.findIndex((server) => server.name === action.payload.name)
if (index !== -1) {
state.servers[index].isActive = action.payload.isActive
}
}
},
selectors: {
getActiveServers: (state) => {
return state.servers.filter((server) => server.isActive)
},
getAllServers: (state) => state.servers
}
})
export const { setMCPServers, addMCPServer, updateMCPServer, deleteMCPServer, setMCPServerActive } = mcpSlice.actions
// Export the generated selectors from the slice
export const { getActiveServers, getAllServers } = mcpSlice.selectors
// Type-safe selector for accessing this slice from the root state
export const selectMCP = (state: { mcp: MCPConfig }) => state.mcp
// Export the reducer as default export
export default mcpSlice.reducer
+39
View File
@@ -301,3 +301,42 @@ export type KnowledgeReference = {
type: KnowledgeItemType
file?: FileType
}
export type MCPArgType = 'string' | 'list' | 'number'
export type MCPEnvType = 'string' | 'number'
export type MCPArgParameter = { [key: string]: MCPArgType }
export type MCPEnvParameter = { [key: string]: MCPEnvType }
export interface MCPServerParameter {
name: string
type: MCPArgType | MCPEnvType
description: string
}
export interface MCPServer {
name: string
command: string
description?: string
args: string[]
env?: Record<string, string>
isActive: boolean
}
export interface MCPToolInputSchema {
type: string
title: string
description?: string
required?: string[]
properties: Record<string, object>
}
export interface MCPTool {
serverName: string
name: string
description?: string
inputSchema: MCPToolInputSchema
}
export interface MCPConfig {
servers: MCPServer[]
}