Feature/Custom MCP vars (#4527)
* add input vars to custom mcp * add ability to specify vars in custom mcp, fix other ui issues * update setup org ui
This commit is contained in:
parent
2baa43d66f
commit
3d6bf72e73
|
|
@ -1,12 +1,34 @@
|
|||
import { Tool } from '@langchain/core/tools'
|
||||
import { INode, INodeData, INodeOptionsValue, INodeParams } from '../../../../src/Interface'
|
||||
import { ICommonObject, IDatabaseEntity, INode, INodeData, INodeOptionsValue, INodeParams } from '../../../../src/Interface'
|
||||
import { MCPToolkit } from '../core'
|
||||
import { getVars, prepareSandboxVars } from '../../../../src/utils'
|
||||
import { DataSource } from 'typeorm'
|
||||
|
||||
const mcpServerConfig = `{
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed/files"]
|
||||
}`
|
||||
|
||||
const howToUseCode = `
|
||||
You can use variables in the MCP Server Config with double curly braces \`{{ }}\` and prefix \`$vars.<variableName>\`.
|
||||
|
||||
For example, you have a variable called "var1":
|
||||
\`\`\`json
|
||||
{
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"run",
|
||||
"-i",
|
||||
"--rm",
|
||||
"-e", "API_TOKEN"
|
||||
],
|
||||
"env": {
|
||||
"API_TOKEN": "{{$vars.var1}}"
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
`
|
||||
|
||||
class Custom_MCP implements INode {
|
||||
label: string
|
||||
name: string
|
||||
|
|
@ -23,7 +45,7 @@ class Custom_MCP implements INode {
|
|||
constructor() {
|
||||
this.label = 'Custom MCP'
|
||||
this.name = 'customMCP'
|
||||
this.version = 1.0
|
||||
this.version = 1.1
|
||||
this.type = 'Custom MCP Tool'
|
||||
this.icon = 'customMCP.png'
|
||||
this.category = 'Tools (MCP)'
|
||||
|
|
@ -35,6 +57,10 @@ class Custom_MCP implements INode {
|
|||
name: 'mcpServerConfig',
|
||||
type: 'code',
|
||||
hideCodeExecute: true,
|
||||
hint: {
|
||||
label: 'How to use',
|
||||
value: howToUseCode
|
||||
},
|
||||
placeholder: mcpServerConfig
|
||||
},
|
||||
{
|
||||
|
|
@ -50,9 +76,9 @@ class Custom_MCP implements INode {
|
|||
|
||||
//@ts-ignore
|
||||
loadMethods = {
|
||||
listActions: async (nodeData: INodeData): Promise<INodeOptionsValue[]> => {
|
||||
listActions: async (nodeData: INodeData, options: ICommonObject): Promise<INodeOptionsValue[]> => {
|
||||
try {
|
||||
const toolset = await this.getTools(nodeData)
|
||||
const toolset = await this.getTools(nodeData, options)
|
||||
toolset.sort((a: any, b: any) => a.name.localeCompare(b.name))
|
||||
|
||||
return toolset.map(({ name, ...rest }) => ({
|
||||
|
|
@ -72,8 +98,8 @@ class Custom_MCP implements INode {
|
|||
}
|
||||
}
|
||||
|
||||
async init(nodeData: INodeData): Promise<any> {
|
||||
const tools = await this.getTools(nodeData)
|
||||
async init(nodeData: INodeData, _: string, options: ICommonObject): Promise<any> {
|
||||
const tools = await this.getTools(nodeData, options)
|
||||
|
||||
const _mcpActions = nodeData.inputs?.mcpActions
|
||||
let mcpActions = []
|
||||
|
|
@ -88,19 +114,29 @@ class Custom_MCP implements INode {
|
|||
return tools.filter((tool: any) => mcpActions.includes(tool.name))
|
||||
}
|
||||
|
||||
async getTools(nodeData: INodeData): Promise<Tool[]> {
|
||||
async getTools(nodeData: INodeData, options: ICommonObject): Promise<Tool[]> {
|
||||
const mcpServerConfig = nodeData.inputs?.mcpServerConfig as string
|
||||
|
||||
if (!mcpServerConfig) {
|
||||
throw new Error('MCP Server Config is required')
|
||||
}
|
||||
|
||||
let sandbox: ICommonObject = {}
|
||||
|
||||
if (mcpServerConfig.includes('$vars')) {
|
||||
const appDataSource = options.appDataSource as DataSource
|
||||
const databaseEntities = options.databaseEntities as IDatabaseEntity
|
||||
|
||||
const variables = await getVars(appDataSource, databaseEntities, nodeData, options)
|
||||
sandbox['$vars'] = prepareSandboxVars(variables)
|
||||
}
|
||||
|
||||
try {
|
||||
let serverParams
|
||||
if (typeof mcpServerConfig === 'object') {
|
||||
serverParams = mcpServerConfig
|
||||
serverParams = substituteVariablesInObject(mcpServerConfig, sandbox)
|
||||
} else if (typeof mcpServerConfig === 'string') {
|
||||
const serverParamsString = convertToValidJSONString(mcpServerConfig)
|
||||
const substitutedString = substituteVariablesInString(mcpServerConfig, sandbox)
|
||||
const serverParamsString = convertToValidJSONString(substitutedString)
|
||||
serverParams = JSON.parse(serverParamsString)
|
||||
}
|
||||
|
||||
|
|
@ -123,6 +159,67 @@ class Custom_MCP implements INode {
|
|||
}
|
||||
}
|
||||
|
||||
function substituteVariablesInObject(obj: any, sandbox: any): any {
|
||||
if (typeof obj === 'string') {
|
||||
// Replace variables in string values
|
||||
return substituteVariablesInString(obj, sandbox)
|
||||
} else if (Array.isArray(obj)) {
|
||||
// Recursively process arrays
|
||||
return obj.map((item) => substituteVariablesInObject(item, sandbox))
|
||||
} else if (obj !== null && typeof obj === 'object') {
|
||||
// Recursively process object properties
|
||||
const result: any = {}
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
result[key] = substituteVariablesInObject(value, sandbox)
|
||||
}
|
||||
return result
|
||||
}
|
||||
// Return primitive values as-is
|
||||
return obj
|
||||
}
|
||||
|
||||
function substituteVariablesInString(str: string, sandbox: any): string {
|
||||
// Use regex to find {{$variableName.property}} patterns and replace with sandbox values
|
||||
return str.replace(/\{\{\$([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)\}\}/g, (match, variablePath) => {
|
||||
try {
|
||||
// Split the path into parts (e.g., "vars.testvar1" -> ["vars", "testvar1"])
|
||||
const pathParts = variablePath.split('.')
|
||||
|
||||
// Start with the sandbox object
|
||||
let current = sandbox
|
||||
|
||||
// Navigate through the path
|
||||
for (const part of pathParts) {
|
||||
// For the first part, check if it exists with $ prefix
|
||||
if (current === sandbox) {
|
||||
const sandboxKey = `$${part}`
|
||||
if (Object.keys(current).includes(sandboxKey)) {
|
||||
current = current[sandboxKey]
|
||||
} else {
|
||||
// If the key doesn't exist, return the original match
|
||||
return match
|
||||
}
|
||||
} else {
|
||||
// For subsequent parts, access directly
|
||||
if (current && typeof current === 'object' && part in current) {
|
||||
current = current[part]
|
||||
} else {
|
||||
// If the property doesn't exist, return the original match
|
||||
return match
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return the resolved value, converting to string if necessary
|
||||
return typeof current === 'string' ? current : JSON.stringify(current)
|
||||
} catch (error) {
|
||||
// If any error occurs during resolution, return the original match
|
||||
console.warn(`Error resolving variable ${match}:`, error)
|
||||
return match
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function convertToValidJSONString(inputString: string) {
|
||||
try {
|
||||
const jsObject = Function('return ' + inputString)()
|
||||
|
|
|
|||
|
|
@ -743,9 +743,9 @@ export const ExecutionDetails = ({ open, isPublic, execution, metadata, onClose,
|
|||
sx={{ pl: 1 }}
|
||||
icon={<IconExternalLink size={15} />}
|
||||
variant='outlined'
|
||||
label={metadata?.agentflow?.name || metadata?.agentflow?.id || 'Go to AgentFlow'}
|
||||
label={localMetadata?.agentflow?.name || localMetadata?.agentflow?.id || 'Go to AgentFlow'}
|
||||
className={'button'}
|
||||
onClick={() => window.open(`/v2/agentcanvas/${metadata?.agentflow?.id}`, '_blank')}
|
||||
onClick={() => window.open(`/v2/agentcanvas/${localMetadata?.agentflow?.id}`, '_blank')}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,8 +38,16 @@ const PublicExecutionDetails = () => {
|
|||
const executionDetails =
|
||||
typeof execution.executionData === 'string' ? JSON.parse(execution.executionData) : execution.executionData
|
||||
setExecution(executionDetails)
|
||||
setSelectedMetadata(omit(execution, ['executionData']))
|
||||
const newMetadata = {
|
||||
...omit(execution, ['executionData']),
|
||||
agentflow: {
|
||||
...selectedMetadata.agentflow
|
||||
}
|
||||
}
|
||||
setSelectedMetadata(newMetadata)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [getExecutionByIdPublicApi.data])
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -225,8 +225,16 @@ const AgentExecutions = () => {
|
|||
const executionDetails =
|
||||
typeof execution.executionData === 'string' ? JSON.parse(execution.executionData) : execution.executionData
|
||||
setSelectedExecutionData(executionDetails)
|
||||
setSelectedMetadata(omit(execution, ['executionData']))
|
||||
const newMetadata = {
|
||||
...omit(execution, ['executionData']),
|
||||
agentflow: {
|
||||
...selectedMetadata.agentflow
|
||||
}
|
||||
}
|
||||
setSelectedMetadata(newMetadata)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [getExecutionByIdApi.data])
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -421,23 +421,17 @@ const AgentFlowNode = ({ data }) => {
|
|||
return (
|
||||
<Box
|
||||
key={`tool-${configIndex}-${toolIndex}-${propIndex}`}
|
||||
component='img'
|
||||
src={`${baseURL}/api/v1/node-icon/${toolName}`}
|
||||
alt={toolName}
|
||||
sx={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: '50%',
|
||||
width: 24,
|
||||
height: 24,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: '4px'
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
padding: 0.3
|
||||
}}
|
||||
>
|
||||
<img
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
src={`${baseURL}/api/v1/node-icon/${toolName}`}
|
||||
alt={toolName}
|
||||
/>
|
||||
</Box>
|
||||
/>
|
||||
)
|
||||
})
|
||||
} else {
|
||||
|
|
@ -447,23 +441,17 @@ const AgentFlowNode = ({ data }) => {
|
|||
return [
|
||||
<Box
|
||||
key={`tool-${configIndex}-${toolIndex}`}
|
||||
component='img'
|
||||
src={`${baseURL}/api/v1/node-icon/${toolName}`}
|
||||
alt={toolName}
|
||||
sx={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: '50%',
|
||||
width: 24,
|
||||
height: 24,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: '4px'
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
padding: 0.3
|
||||
}}
|
||||
>
|
||||
<img
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
src={`${baseURL}/api/v1/node-icon/${toolName}`}
|
||||
alt={toolName}
|
||||
/>
|
||||
</Box>
|
||||
/>
|
||||
]
|
||||
}
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -1069,7 +1069,7 @@ const NodeInputHandler = ({
|
|||
)}
|
||||
|
||||
{(inputParam.type === 'string' || inputParam.type === 'password' || inputParam.type === 'number') &&
|
||||
(inputParam?.acceptVariable ? (
|
||||
(inputParam?.acceptVariable && window.location.href.includes('v2/agentcanvas') ? (
|
||||
<RichInput
|
||||
key={data.inputs[inputParam.name]}
|
||||
placeholder={inputParam.placeholder}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useContext, useState, memo } from 'react'
|
|||
import { useSelector } from 'react-redux'
|
||||
|
||||
// material-ui
|
||||
import { useTheme } from '@mui/material/styles'
|
||||
import { useTheme, darken, lighten } from '@mui/material/styles'
|
||||
|
||||
// project imports
|
||||
import NodeCardWrapper from '@/ui-component/cards/NodeCardWrapper'
|
||||
|
|
@ -18,6 +18,7 @@ import { flowContext } from '@/store/context/ReactFlowContext'
|
|||
const StickyNote = ({ data }) => {
|
||||
const theme = useTheme()
|
||||
const canvas = useSelector((state) => state.canvas)
|
||||
const customization = useSelector((state) => state.customization)
|
||||
const { deleteNode, duplicateNode } = useContext(flowContext)
|
||||
const [inputParam] = data.inputParams
|
||||
|
||||
|
|
@ -31,12 +32,23 @@ const StickyNote = ({ data }) => {
|
|||
setOpen(true)
|
||||
}
|
||||
|
||||
const defaultColor = '#FFE770' // fallback color if data.color is not present
|
||||
const nodeColor = data.color || defaultColor
|
||||
|
||||
const getBorderColor = () => {
|
||||
if (data.selected) return theme.palette.primary.main
|
||||
else if (theme?.customization?.isDarkMode) return theme.palette.grey[900] + 25
|
||||
else if (customization?.isDarkMode) return theme.palette.grey[700]
|
||||
else return theme.palette.grey[900] + 50
|
||||
}
|
||||
|
||||
const getBackgroundColor = () => {
|
||||
if (customization?.isDarkMode) {
|
||||
return data.selected ? darken(nodeColor, 0.7) : darken(nodeColor, 0.8)
|
||||
} else {
|
||||
return data.selected ? lighten(nodeColor, 0.1) : lighten(nodeColor, 0.2)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<NodeCardWrapper
|
||||
|
|
@ -44,7 +56,7 @@ const StickyNote = ({ data }) => {
|
|||
sx={{
|
||||
padding: 0,
|
||||
borderColor: getBorderColor(),
|
||||
backgroundColor: data.selected ? '#FFDC00' : '#FFE770'
|
||||
backgroundColor: getBackgroundColor()
|
||||
}}
|
||||
border={false}
|
||||
>
|
||||
|
|
@ -66,8 +78,12 @@ const StickyNote = ({ data }) => {
|
|||
onClick={() => {
|
||||
duplicateNode(data.id)
|
||||
}}
|
||||
sx={{ height: '35px', width: '35px', '&:hover': { color: theme?.palette.primary.main } }}
|
||||
color={theme?.customization?.isDarkMode ? theme.colors?.paper : 'inherit'}
|
||||
sx={{
|
||||
height: '35px',
|
||||
width: '35px',
|
||||
color: customization?.isDarkMode ? 'white' : 'inherit',
|
||||
'&:hover': { color: theme?.palette.primary.main }
|
||||
}}
|
||||
>
|
||||
<IconCopy />
|
||||
</IconButton>
|
||||
|
|
@ -76,8 +92,12 @@ const StickyNote = ({ data }) => {
|
|||
onClick={() => {
|
||||
deleteNode(data.id)
|
||||
}}
|
||||
sx={{ height: '35px', width: '35px', '&:hover': { color: 'red' } }}
|
||||
color={theme?.customization?.isDarkMode ? theme.colors?.paper : 'inherit'}
|
||||
sx={{
|
||||
height: '35px',
|
||||
width: '35px',
|
||||
color: customization?.isDarkMode ? 'white' : 'inherit',
|
||||
'&:hover': { color: theme?.palette.error.main }
|
||||
}}
|
||||
>
|
||||
<IconTrash />
|
||||
</IconButton>
|
||||
|
|
|
|||
|
|
@ -287,7 +287,7 @@ const OrganizationSetupPage = () => {
|
|||
<>
|
||||
<Box>
|
||||
<div style={{ display: 'flex', flexDirection: 'row' }}>
|
||||
<Typography>
|
||||
<Typography sx={{ mb: 1 }}>
|
||||
Existing Username<span style={{ color: 'red' }}> *</span>
|
||||
</Typography>
|
||||
<div style={{ flexGrow: 1 }}></div>
|
||||
|
|
@ -304,7 +304,7 @@ const OrganizationSetupPage = () => {
|
|||
</Box>
|
||||
<Box>
|
||||
<div style={{ display: 'flex', flexDirection: 'row' }}>
|
||||
<Typography>
|
||||
<Typography sx={{ mb: 1 }}>
|
||||
Existing Password<span style={{ color: 'red' }}> *</span>
|
||||
</Typography>
|
||||
<div style={{ flexGrow: 1 }}></div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue