feat: add MCP server tools integration and UI components

This commit is contained in:
CaptainB 2025-03-21 17:06:01 +08:00 committed by 刘瑞斌
parent f9c4e96f97
commit 2d6ac806ff
18 changed files with 488 additions and 5 deletions

View File

@ -25,13 +25,14 @@ from .speech_to_text_step_node import BaseSpeechToTextNode
from .start_node import * from .start_node import *
from .text_to_speech_step_node.impl.base_text_to_speech_node import BaseTextToSpeechNode from .text_to_speech_step_node.impl.base_text_to_speech_node import BaseTextToSpeechNode
from .variable_assign_node import BaseVariableAssignNode from .variable_assign_node import BaseVariableAssignNode
from .mcp_node import BaseMcpNode
node_list = [BaseStartStepNode, BaseChatNode, BaseSearchDatasetNode, BaseQuestionNode, node_list = [BaseStartStepNode, BaseChatNode, BaseSearchDatasetNode, BaseQuestionNode,
BaseConditionNode, BaseReplyNode, BaseConditionNode, BaseReplyNode,
BaseFunctionNodeNode, BaseFunctionLibNodeNode, BaseRerankerNode, BaseApplicationNode, BaseFunctionNodeNode, BaseFunctionLibNodeNode, BaseRerankerNode, BaseApplicationNode,
BaseDocumentExtractNode, BaseDocumentExtractNode,
BaseImageUnderstandNode, BaseFormNode, BaseSpeechToTextNode, BaseTextToSpeechNode, BaseImageUnderstandNode, BaseFormNode, BaseSpeechToTextNode, BaseTextToSpeechNode,
BaseImageGenerateNode, BaseVariableAssignNode] BaseImageGenerateNode, BaseVariableAssignNode, BaseMcpNode]
def get_node(node_type): def get_node(node_type):

View File

@ -0,0 +1,3 @@
# coding=utf-8
from .impl import *

View File

@ -0,0 +1,35 @@
# coding=utf-8
from typing import Type
from rest_framework import serializers
from application.flow.i_step_node import INode, NodeResult
from common.util.field_message import ErrMessage
from django.utils.translation import gettext_lazy as _
class McpNodeSerializer(serializers.Serializer):
mcp_servers = serializers.JSONField(required=True,
error_messages=ErrMessage.char(_("Mcp servers")))
mcp_server = serializers.CharField(required=True,
error_messages=ErrMessage.char(_("Mcp server")))
mcp_tool = serializers.CharField(required=True, error_messages=ErrMessage.char(_("Mcp tool")))
tool_params = serializers.DictField(required=True,
error_messages=ErrMessage.char(_("Tool parameters")))
class IMcpNode(INode):
type = 'mcp-node'
def get_node_params_serializer_class(self) -> Type[serializers.Serializer]:
return McpNodeSerializer
def _run(self):
return self.execute(**self.node_params_serializer.data, **self.flow_params_serializer.data)
def execute(self, mcp_servers, mcp_server, mcp_tool, tool_params, **kwargs) -> NodeResult:
pass

View File

@ -0,0 +1,3 @@
# coding=utf-8
from .base_mcp_node import BaseMcpNode

View File

@ -0,0 +1,56 @@
# coding=utf-8
import asyncio
import json
from typing import List
from langchain_mcp_adapters.client import MultiServerMCPClient
from application.flow.i_step_node import NodeResult
from application.flow.step_node.mcp_node.i_mcp_node import IMcpNode
class BaseMcpNode(IMcpNode):
def save_context(self, details, workflow_manage):
self.context['result'] = details.get('result')
self.context['tool_params'] = details.get('tool_params')
self.context['mcp_tool'] = details.get('mcp_tool')
self.answer_text = details.get('result')
def execute(self, mcp_servers, mcp_server, mcp_tool, tool_params, **kwargs) -> NodeResult:
servers = json.loads(mcp_servers)
params = self.handle_variables(tool_params)
async def call_tool(s, session, t, a):
async with MultiServerMCPClient(s) as client:
s = await client.sessions[session].call_tool(t, a)
return s
res = asyncio.run(call_tool(servers, mcp_server, mcp_tool, params))
return NodeResult({'result': [content.text for content in res.content], 'tool_params': params, 'mcp_tool': mcp_tool}, {})
def handle_variables(self, tool_params):
# 处理参数中的变量
for k, v in tool_params.items():
if type(v) == str:
tool_params[k] = self.workflow_manage.generate_prompt(tool_params[k])
if type(v) == dict:
self.handle_variables(v)
return tool_params
def get_reference_content(self, fields: List[str]):
return str(self.workflow_manage.get_reference_field(
fields[0],
fields[1:]))
def get_details(self, index: int, **kwargs):
return {
'name': self.node.properties.get('stepName'),
"index": index,
'run_time': self.context.get('run_time'),
'status': self.status,
'err_message': self.err_message,
'type': self.node.type,
'mcp_tool': self.context.get('mcp_tool'),
'tool_params': self.context.get('tool_params'),
'result': self.context.get('result'),
}

View File

@ -6,6 +6,7 @@
@date2023/11/7 10:02 @date2023/11/7 10:02
@desc: @desc:
""" """
import asyncio
import datetime import datetime
import hashlib import hashlib
import json import json
@ -23,6 +24,8 @@ from django.db.models import QuerySet
from django.db.models.expressions import RawSQL from django.db.models.expressions import RawSQL
from django.http import HttpResponse from django.http import HttpResponse
from django.template import Template, Context from django.template import Template, Context
from langchain_mcp_adapters.client import MultiServerMCPClient
from mcp.client.sse import sse_client
from rest_framework import serializers, status from rest_framework import serializers, status
from rest_framework.utils.formatting import lazy_format from rest_framework.utils.formatting import lazy_format
@ -1305,3 +1308,28 @@ class ApplicationSerializer(serializers.Serializer):
application_api_key.save() application_api_key.save()
# 写入缓存 # 写入缓存
get_application_api_key(application_api_key.secret_key, False) get_application_api_key(application_api_key.secret_key, False)
class McpServers(serializers.Serializer):
mcp_servers = serializers.JSONField(required=True)
def get_mcp_servers(self, with_valid=True):
if with_valid:
self.is_valid(raise_exception=True)
servers = json.loads(self.data.get('mcp_servers'))
async def get_mcp_tools(servers):
async with MultiServerMCPClient(servers) as client:
return client.get_tools()
tools = []
for server in servers:
tools += [
{
'server': server,
'name': tool.name,
'description': tool.description,
'args_schema': tool.args_schema,
}
for tool in asyncio.run(get_mcp_tools({server: servers[server]}))]
return tools

View File

@ -9,6 +9,7 @@ urlpatterns = [
path('application/profile', views.Application.Profile.as_view(), name='application/profile'), path('application/profile', views.Application.Profile.as_view(), name='application/profile'),
path('application/embed', views.Application.Embed.as_view()), path('application/embed', views.Application.Embed.as_view()),
path('application/authentication', views.Application.Authentication.as_view()), path('application/authentication', views.Application.Authentication.as_view()),
path('application/mcp_servers', views.Application.McpServers.as_view()),
path('application/<str:application_id>/publish', views.Application.Publish.as_view()), path('application/<str:application_id>/publish', views.Application.Publish.as_view()),
path('application/<str:application_id>/edit_icon', views.Application.EditIcon.as_view()), path('application/<str:application_id>/edit_icon', views.Application.EditIcon.as_view()),
path('application/<str:application_id>/export', views.Application.Export.as_view()), path('application/<str:application_id>/export', views.Application.Export.as_view()),

View File

@ -700,3 +700,13 @@ class Application(APIView):
data={'application_id': application_id, 'user_id': request.user.id}).play_demo_text(request.data) data={'application_id': application_id, 'user_id': request.user.id}).play_demo_text(request.data)
return HttpResponse(byte_data, status=200, headers={'Content-Type': 'audio/mp3', return HttpResponse(byte_data, status=200, headers={'Content-Type': 'audio/mp3',
'Content-Disposition': 'attachment; filename="abc.mp3"'}) 'Content-Disposition': 'attachment; filename="abc.mp3"'})
class McpServers(APIView):
authentication_classes = [TokenAuth]
@action(methods=['GET'], detail=False)
@has_permissions(PermissionConstants.APPLICATION_READ, compare=CompareConstants.AND)
@log(menu='Application', operate="Get the MCP server tools")
def get(self, request: Request):
return result.success(ApplicationSerializer.McpServers(
data={'mcp_servers': request.query_params.get('mcp_servers')}).get_mcp_servers())

View File

@ -350,6 +350,13 @@ const getFunctionLib: (
return get(`${prefix}/${application_id}/function_lib/${function_lib_id}`, undefined, loading) return get(`${prefix}/${application_id}/function_lib/${function_lib_id}`, undefined, loading)
} }
const getMcpTools: (
data: any,
loading?: Ref<boolean>
) => Promise<Result<any>> = (data, loading) => {
return get(`${prefix}/mcp_servers`, data, loading)
}
const getApplicationById: ( const getApplicationById: (
application_id: String, application_id: String,
app_id: String, app_id: String,
@ -576,5 +583,6 @@ export default {
uploadFile, uploadFile,
exportApplication, exportApplication,
importApplication, importApplication,
getApplicationById getApplicationById,
getMcpTools
} }

View File

@ -639,6 +639,40 @@
</div> </div>
</div> </div>
</template> </template>
<!-- MCP 节点 -->
<template v-if="item.type === WorkflowType.McpNode">
<div class="card-never border-r-4">
<h5 class="p-8-12">
{{ $t('views.applicationWorkflow.nodes.mcpNode.tool') }}
</h5>
<div class="p-8-12 border-t-dashed lighter">
<div class="mb-8">
<span class="color-secondary"> {{ $t('views.applicationWorkflow.nodes.mcpNode.tool') }}: </span> {{ item.mcp_tool }}
</div>
</div>
</div>
<div class="card-never border-r-4">
<h5 class="p-8-12">
{{ $t('views.applicationWorkflow.nodes.mcpNode.toolParam') }}
</h5>
<div class="p-8-12 border-t-dashed lighter">
<div v-for="(value, name) in item.tool_params" :key="name" class="mb-8">
<span class="color-secondary">{{ name }}:</span> {{ value }}
</div>
</div>
</div>
<div class="card-never border-r-4">
<h5 class="p-8-12">
{{ $t('common.param.outputParam') }}
</h5>
<div class="p-8-12 border-t-dashed lighter">
<div v-for="(f, i) in item.result" :key="i" class="mb-8">
<span class="color-secondary">result:</span> {{ f }}
</div>
</div>
</div>
</template>
</template> </template>
<template v-else> <template v-else>
<div class="card-never border-r-4"> <div class="card-never border-r-4">

View File

@ -16,5 +16,6 @@ export enum WorkflowType {
FormNode = 'form-node', FormNode = 'form-node',
TextToSpeechNode = 'text-to-speech-node', TextToSpeechNode = 'text-to-speech-node',
SpeechToTextNode = 'speech-to-text-node', SpeechToTextNode = 'speech-to-text-node',
ImageGenerateNode = 'image-generate-node' ImageGenerateNode = 'image-generate-node',
McpNode = 'mcp-node',
} }

View File

@ -220,6 +220,14 @@ export default {
text: 'Update the value of the global variable', text: 'Update the value of the global variable',
assign: 'Set Value' assign: 'Set Value'
}, },
mcpNode: {
label: 'MCP Server',
text: 'Call MCP Tools',
getToolsSuccess: 'Get Tools Successfully',
getTool: 'Get Tools',
tool: 'Tool',
toolParam: 'Tool Params'
},
imageGenerateNode: { imageGenerateNode: {
label: 'Image Generation', label: 'Image Generation',
text: 'Generate images based on provided text content', text: 'Generate images based on provided text content',

View File

@ -220,6 +220,14 @@ export default {
text: '更新全局变量的值', text: '更新全局变量的值',
assign: '赋值' assign: '赋值'
}, },
mcpNode: {
label: 'MCP 节点',
text: '调用 MCP 工具',
getToolsSuccess: '获取工具成功',
getTool: '获取工具',
tool: '工具',
toolParam: '工具参数'
},
imageGenerateNode: { imageGenerateNode: {
label: '图片生成', label: '图片生成',
text: '根据提供的文本内容生成图片', text: '根据提供的文本内容生成图片',

View File

@ -220,6 +220,14 @@ export default {
text: '更新全域變數的值', text: '更新全域變數的值',
assign: '賦值' assign: '賦值'
}, },
mcpNode: {
label: 'MCP 節點',
text: '呼叫 MCP 工具',
getToolsSuccess: '獲取工具成功',
getTool: '獲取工具',
tool: '工具',
toolParam: '工具變數'
},
imageGenerateNode: { imageGenerateNode: {
label: '圖片生成', label: '圖片生成',
text: '根據提供的文本內容生成圖片', text: '根據提供的文本內容生成圖片',

View File

@ -263,6 +263,24 @@ export const variableAssignNode = {
} }
} }
export const mcpNode = {
type: WorkflowType.McpNode,
text: t('views.applicationWorkflow.nodes.mcpNode.text'),
label: t('views.applicationWorkflow.nodes.mcpNode.label'),
height: 252,
properties: {
stepName: t('views.applicationWorkflow.nodes.mcpNode.label'),
config: {
fields:[
{
label: t('common.result'),
value: 'result'
}
]
}
}
}
export const imageGenerateNode = { export const imageGenerateNode = {
type: WorkflowType.ImageGenerateNode, type: WorkflowType.ImageGenerateNode,
text: t('views.applicationWorkflow.nodes.imageGenerateNode.text'), text: t('views.applicationWorkflow.nodes.imageGenerateNode.text'),
@ -332,7 +350,8 @@ export const menuNodes = [
documentExtractNode, documentExtractNode,
speechToTextNode, speechToTextNode,
textToSpeechNode, textToSpeechNode,
variableAssignNode variableAssignNode,
mcpNode
] ]
/** /**
@ -426,7 +445,8 @@ export const nodeDict: any = {
[WorkflowType.TextToSpeechNode]: textToSpeechNode, [WorkflowType.TextToSpeechNode]: textToSpeechNode,
[WorkflowType.SpeechToTextNode]: speechToTextNode, [WorkflowType.SpeechToTextNode]: speechToTextNode,
[WorkflowType.ImageGenerateNode]: imageGenerateNode, [WorkflowType.ImageGenerateNode]: imageGenerateNode,
[WorkflowType.VariableAssignNode]: variableAssignNode [WorkflowType.VariableAssignNode]: variableAssignNode,
[WorkflowType.McpNode]: mcpNode,
} }
export function isWorkFlow(type: string | undefined) { export function isWorkFlow(type: string | undefined) {
return type === 'WORK_FLOW' return type === 'WORK_FLOW'

View File

@ -0,0 +1,6 @@
<template>
<AppAvatar shape="square" class="avatar-blue">
<img src="@/assets/icon_assigner.svg" style="width: 65%" alt="" />
</AppAvatar>
</template>
<script setup lang="ts"></script>

View File

@ -0,0 +1,14 @@
import McpNodeVue from './index.vue'
import { AppNode, AppNodeModel } from '@/workflow/common/app-node'
class McpNode extends AppNode {
constructor(props: any) {
super(props, McpNodeVue)
}
}
export default {
type: 'mcp-node',
model: AppNodeModel,
view: McpNode
}

View File

@ -0,0 +1,239 @@
<template>
<NodeContainer :nodeModel="nodeModel">
<h5 class="title-decoration-1 mb-8">{{ $t('views.applicationWorkflow.nodeSetting') }}</h5>
<div class="border-r-4 p-8-12 mb-8 layout-bg lighter">
<el-form
@submit.prevent
:model="form_data"
label-position="top"
require-asterisk-position="right"
label-width="auto"
ref="replyNodeFormRef"
hide-required-asterisk
>
<el-form-item label="MCP Server Config">
<MdEditorMagnify
@wheel="wheel"
title="MCP Server Config"
v-model="form_data.mcp_servers"
style="height: 150px"
@submitDialog="submitDialog"
/>
</el-form-item>
<el-form-item>
<template v-slot:label>
<div class="flex-between">
<span>{{ $t('views.applicationWorkflow.nodes.mcpNode.tool') }}</span>
<el-button type="primary" link @click="getTools()">
{{ $t('views.applicationWorkflow.nodes.mcpNode.getTool') }}
</el-button>
</div>
</template>
<el-select
v-model="form_data.mcp_tool"
@change="changeTool"
>
<el-option
v-for="item in form_data.mcp_tools"
:key="item.value"
:label="item.name"
:value="item.name"
>
<el-tooltip
class="box-item"
effect="dark"
:content="item.description"
placement="top-start"
>
<AppIcon iconName="app-warning" class="app-warning-icon"></AppIcon>
</el-tooltip>
<span>{{ item.name }}</span>
</el-option>
</el-select>
</el-form-item>
</el-form>
</div>
<h5 class="title-decoration-1 mb-8">
{{ $t('views.applicationWorkflow.nodes.mcpNode.toolParam') }}</h5>
<div class="border-r-4 p-8-12 mb-8 layout-bg lighter" v-if="form_data.tool_params[form_data.params_nested]">
<DynamicsForm
v-if="form_data.mcp_tool"
v-model="form_data.tool_params[form_data.params_nested]"
:model="form_data.tool_params[form_data.params_nested]"
label-position="top"
require-asterisk-position="right"
:render_data="form_data.tool_form_field"
ref="dynamicsFormRef"
>
</DynamicsForm>
</div>
<div class="border-r-4 p-8-12 mb-8 layout-bg lighter" v-else>
<DynamicsForm
v-if="form_data.mcp_tool"
v-model="form_data.tool_params"
:model="form_data.tool_params"
label-position="top"
require-asterisk-position="right"
:render_data="form_data.tool_form_field"
ref="dynamicsFormRef"
>
</DynamicsForm>
</div>
</NodeContainer>
</template>
<script setup lang="ts">
import { cloneDeep, set } from 'lodash'
import NodeContainer from '@/workflow/common/NodeContainer.vue'
import { computed, onMounted, ref } from 'vue'
import { isLastNode } from '@/workflow/common/data'
import applicationApi from '@/api/application'
import { t } from '@/locales'
import DynamicsForm from '@/components/dynamics-form/index.vue'
import { MsgSuccess } from '@/utils/message'
const props = defineProps<{ nodeModel: any }>()
const dynamicsFormRef = ref()
const wheel = (e: any) => {
if (e.ctrlKey === true) {
e.preventDefault()
return true
} else {
e.stopPropagation()
return true
}
}
const form = {
mcp_tool: '',
mcp_tools: [],
mcp_servers: '',
mcp_server: '',
tool_params: {},
tool_form_field: [],
params_nested: ''
}
function submitDialog(val: string) {
set(props.nodeModel.properties.node_data, 'mcp_servers', val)
}
function getTools() {
applicationApi.getMcpTools({ mcp_servers: form_data.value.mcp_servers }).then((res: any) => {
form_data.value.mcp_tools = res.data
MsgSuccess(t('views.applicationWorkflow.nodes.mcpNode.getToolsSuccess'))
})
}
function changeTool() {
form_data.value.mcp_server = form_data.value.mcp_tools.filter((item: any) => item.name === form_data.value.mcp_tool)[0].server
// console.log(form_data.value.mcp_server)
const args_schema = form_data.value.mcp_tools.filter((item: any) => item.name === form_data.value.mcp_tool)[0].args_schema
form_data.value.tool_form_field = []
for (const item in args_schema.properties) {
let params = args_schema.properties[item].properties
if (params) {
form_data.value.params_nested = item
for (const item2 in params) {
form_data.value.tool_form_field.push({
field: item2,
label: {
input_type: 'TooltipLabel',
label: item2,
attrs: { tooltip: params[item2].description },
props_info: {}
},
input_type: 'TextInput',
required: args_schema.properties[item].required?.indexOf(item2) !== -1,
default_value: '',
show_default_value: false,
props_info: {
rules: [
{
required: args_schema.properties[item].required?.indexOf(item2) !== -1,
message: t('dynamicsForm.tip.requiredMessage'),
trigger: 'blur'
}
]
}
})
}
} else {
form_data.value.params_nested = ''
form_data.value.tool_form_field.push({
field: item,
label: {
input_type: 'TooltipLabel',
label: item,
attrs: { tooltip: args_schema.properties[item].description },
props_info: {}
},
input_type: 'TextInput',
required: args_schema.required?.indexOf(item) !== -1,
default_value: '',
show_default_value: false,
props_info: {
rules: [
{
required: args_schema.required?.indexOf(item) !== -1,
message: t('dynamicsForm.tip.requiredMessage'),
trigger: 'blur'
}
]
}
})
}
}
//
if (form_data.value.params_nested) {
form_data.value.tool_params = { [form_data.value.params_nested]: {} }
dynamicsFormRef.value?.render(form_data.value.tool_form_field, form_data.value.tool_params[form_data.value.params_nested])
} else {
form_data.value.tool_params = {}
dynamicsFormRef.value?.render(form_data.value.tool_form_field, form_data.value.tool_params)
}
console.log(form_data.value.tool_params)
}
const form_data = computed({
get: () => {
if (props.nodeModel.properties.node_data) {
return props.nodeModel.properties.node_data
} else {
set(props.nodeModel.properties, 'node_data', form)
}
return props.nodeModel.properties.node_data
},
set: (value) => {
set(props.nodeModel.properties, 'node_data', value)
}
})
const replyNodeFormRef = ref()
const validate = async () => {
let ps = [
replyNodeFormRef.value?.validate(),
dynamicsFormRef.value?.validate()
]
return Promise.all(ps).catch((err: any) => {
return Promise.reject({ node: props.nodeModel, errMessage: err })
})
}
onMounted(() => {
if (typeof props.nodeModel.properties.node_data?.is_result === 'undefined') {
if (isLastNode(props.nodeModel)) {
set(props.nodeModel.properties.node_data, 'is_result', true)
}
}
set(props.nodeModel, 'validate', validate)
})
</script>
<style lang="scss" scoped></style>