Skip to content

Commit 3d6bf72

Browse files
authored
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
1 parent 2baa43d commit 3d6bf72

File tree

8 files changed

+173
-52
lines changed

8 files changed

+173
-52
lines changed

packages/components/nodes/tools/MCP/CustomMCP/CustomMCP.ts

Lines changed: 107 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,34 @@
11
import { Tool } from '@langchain/core/tools'
2-
import { INode, INodeData, INodeOptionsValue, INodeParams } from '../../../../src/Interface'
2+
import { ICommonObject, IDatabaseEntity, INode, INodeData, INodeOptionsValue, INodeParams } from '../../../../src/Interface'
33
import { MCPToolkit } from '../core'
4+
import { getVars, prepareSandboxVars } from '../../../../src/utils'
5+
import { DataSource } from 'typeorm'
46

57
const mcpServerConfig = `{
68
"command": "npx",
79
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed/files"]
810
}`
911

12+
const howToUseCode = `
13+
You can use variables in the MCP Server Config with double curly braces \`{{ }}\` and prefix \`$vars.<variableName>\`.
14+
15+
For example, you have a variable called "var1":
16+
\`\`\`json
17+
{
18+
"command": "docker",
19+
"args": [
20+
"run",
21+
"-i",
22+
"--rm",
23+
"-e", "API_TOKEN"
24+
],
25+
"env": {
26+
"API_TOKEN": "{{$vars.var1}}"
27+
}
28+
}
29+
\`\`\`
30+
`
31+
1032
class Custom_MCP implements INode {
1133
label: string
1234
name: string
@@ -23,7 +45,7 @@ class Custom_MCP implements INode {
2345
constructor() {
2446
this.label = 'Custom MCP'
2547
this.name = 'customMCP'
26-
this.version = 1.0
48+
this.version = 1.1
2749
this.type = 'Custom MCP Tool'
2850
this.icon = 'customMCP.png'
2951
this.category = 'Tools (MCP)'
@@ -35,6 +57,10 @@ class Custom_MCP implements INode {
3557
name: 'mcpServerConfig',
3658
type: 'code',
3759
hideCodeExecute: true,
60+
hint: {
61+
label: 'How to use',
62+
value: howToUseCode
63+
},
3864
placeholder: mcpServerConfig
3965
},
4066
{
@@ -50,9 +76,9 @@ class Custom_MCP implements INode {
5076

5177
//@ts-ignore
5278
loadMethods = {
53-
listActions: async (nodeData: INodeData): Promise<INodeOptionsValue[]> => {
79+
listActions: async (nodeData: INodeData, options: ICommonObject): Promise<INodeOptionsValue[]> => {
5480
try {
55-
const toolset = await this.getTools(nodeData)
81+
const toolset = await this.getTools(nodeData, options)
5682
toolset.sort((a: any, b: any) => a.name.localeCompare(b.name))
5783

5884
return toolset.map(({ name, ...rest }) => ({
@@ -72,8 +98,8 @@ class Custom_MCP implements INode {
7298
}
7399
}
74100

75-
async init(nodeData: INodeData): Promise<any> {
76-
const tools = await this.getTools(nodeData)
101+
async init(nodeData: INodeData, _: string, options: ICommonObject): Promise<any> {
102+
const tools = await this.getTools(nodeData, options)
77103

78104
const _mcpActions = nodeData.inputs?.mcpActions
79105
let mcpActions = []
@@ -88,19 +114,29 @@ class Custom_MCP implements INode {
88114
return tools.filter((tool: any) => mcpActions.includes(tool.name))
89115
}
90116

91-
async getTools(nodeData: INodeData): Promise<Tool[]> {
117+
async getTools(nodeData: INodeData, options: ICommonObject): Promise<Tool[]> {
92118
const mcpServerConfig = nodeData.inputs?.mcpServerConfig as string
93-
94119
if (!mcpServerConfig) {
95120
throw new Error('MCP Server Config is required')
96121
}
97122

123+
let sandbox: ICommonObject = {}
124+
125+
if (mcpServerConfig.includes('$vars')) {
126+
const appDataSource = options.appDataSource as DataSource
127+
const databaseEntities = options.databaseEntities as IDatabaseEntity
128+
129+
const variables = await getVars(appDataSource, databaseEntities, nodeData, options)
130+
sandbox['$vars'] = prepareSandboxVars(variables)
131+
}
132+
98133
try {
99134
let serverParams
100135
if (typeof mcpServerConfig === 'object') {
101-
serverParams = mcpServerConfig
136+
serverParams = substituteVariablesInObject(mcpServerConfig, sandbox)
102137
} else if (typeof mcpServerConfig === 'string') {
103-
const serverParamsString = convertToValidJSONString(mcpServerConfig)
138+
const substitutedString = substituteVariablesInString(mcpServerConfig, sandbox)
139+
const serverParamsString = convertToValidJSONString(substitutedString)
104140
serverParams = JSON.parse(serverParamsString)
105141
}
106142

@@ -123,6 +159,67 @@ class Custom_MCP implements INode {
123159
}
124160
}
125161

162+
function substituteVariablesInObject(obj: any, sandbox: any): any {
163+
if (typeof obj === 'string') {
164+
// Replace variables in string values
165+
return substituteVariablesInString(obj, sandbox)
166+
} else if (Array.isArray(obj)) {
167+
// Recursively process arrays
168+
return obj.map((item) => substituteVariablesInObject(item, sandbox))
169+
} else if (obj !== null && typeof obj === 'object') {
170+
// Recursively process object properties
171+
const result: any = {}
172+
for (const [key, value] of Object.entries(obj)) {
173+
result[key] = substituteVariablesInObject(value, sandbox)
174+
}
175+
return result
176+
}
177+
// Return primitive values as-is
178+
return obj
179+
}
180+
181+
function substituteVariablesInString(str: string, sandbox: any): string {
182+
// Use regex to find {{$variableName.property}} patterns and replace with sandbox values
183+
return str.replace(/\{\{\$([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)\}\}/g, (match, variablePath) => {
184+
try {
185+
// Split the path into parts (e.g., "vars.testvar1" -> ["vars", "testvar1"])
186+
const pathParts = variablePath.split('.')
187+
188+
// Start with the sandbox object
189+
let current = sandbox
190+
191+
// Navigate through the path
192+
for (const part of pathParts) {
193+
// For the first part, check if it exists with $ prefix
194+
if (current === sandbox) {
195+
const sandboxKey = `$${part}`
196+
if (Object.keys(current).includes(sandboxKey)) {
197+
current = current[sandboxKey]
198+
} else {
199+
// If the key doesn't exist, return the original match
200+
return match
201+
}
202+
} else {
203+
// For subsequent parts, access directly
204+
if (current && typeof current === 'object' && part in current) {
205+
current = current[part]
206+
} else {
207+
// If the property doesn't exist, return the original match
208+
return match
209+
}
210+
}
211+
}
212+
213+
// Return the resolved value, converting to string if necessary
214+
return typeof current === 'string' ? current : JSON.stringify(current)
215+
} catch (error) {
216+
// If any error occurs during resolution, return the original match
217+
console.warn(`Error resolving variable ${match}:`, error)
218+
return match
219+
}
220+
})
221+
}
222+
126223
function convertToValidJSONString(inputString: string) {
127224
try {
128225
const jsObject = Function('return ' + inputString)()

packages/ui/src/views/agentexecutions/ExecutionDetails.jsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -743,9 +743,9 @@ export const ExecutionDetails = ({ open, isPublic, execution, metadata, onClose,
743743
sx={{ pl: 1 }}
744744
icon={<IconExternalLink size={15} />}
745745
variant='outlined'
746-
label={metadata?.agentflow?.name || metadata?.agentflow?.id || 'Go to AgentFlow'}
746+
label={localMetadata?.agentflow?.name || localMetadata?.agentflow?.id || 'Go to AgentFlow'}
747747
className={'button'}
748-
onClick={() => window.open(`/v2/agentcanvas/${metadata?.agentflow?.id}`, '_blank')}
748+
onClick={() => window.open(`/v2/agentcanvas/${localMetadata?.agentflow?.id}`, '_blank')}
749749
/>
750750
)}
751751

packages/ui/src/views/agentexecutions/PublicExecutionDetails.jsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,16 @@ const PublicExecutionDetails = () => {
3838
const executionDetails =
3939
typeof execution.executionData === 'string' ? JSON.parse(execution.executionData) : execution.executionData
4040
setExecution(executionDetails)
41-
setSelectedMetadata(omit(execution, ['executionData']))
41+
const newMetadata = {
42+
...omit(execution, ['executionData']),
43+
agentflow: {
44+
...selectedMetadata.agentflow
45+
}
46+
}
47+
setSelectedMetadata(newMetadata)
4248
}
49+
50+
// eslint-disable-next-line react-hooks/exhaustive-deps
4351
}, [getExecutionByIdPublicApi.data])
4452

4553
useEffect(() => {

packages/ui/src/views/agentexecutions/index.jsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,8 +225,16 @@ const AgentExecutions = () => {
225225
const executionDetails =
226226
typeof execution.executionData === 'string' ? JSON.parse(execution.executionData) : execution.executionData
227227
setSelectedExecutionData(executionDetails)
228-
setSelectedMetadata(omit(execution, ['executionData']))
228+
const newMetadata = {
229+
...omit(execution, ['executionData']),
230+
agentflow: {
231+
...selectedMetadata.agentflow
232+
}
233+
}
234+
setSelectedMetadata(newMetadata)
229235
}
236+
237+
// eslint-disable-next-line react-hooks/exhaustive-deps
230238
}, [getExecutionByIdApi.data])
231239

232240
return (

packages/ui/src/views/agentflowsv2/AgentFlowNode.jsx

Lines changed: 16 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -421,23 +421,17 @@ const AgentFlowNode = ({ data }) => {
421421
return (
422422
<Box
423423
key={`tool-${configIndex}-${toolIndex}-${propIndex}`}
424+
component='img'
425+
src={`${baseURL}/api/v1/node-icon/${toolName}`}
426+
alt={toolName}
424427
sx={{
425-
backgroundColor: 'rgba(255, 255, 255, 0.2)',
428+
width: 20,
429+
height: 20,
426430
borderRadius: '50%',
427-
width: 24,
428-
height: 24,
429-
display: 'flex',
430-
justifyContent: 'center',
431-
alignItems: 'center',
432-
padding: '4px'
431+
backgroundColor: 'rgba(255, 255, 255, 0.2)',
432+
padding: 0.3
433433
}}
434-
>
435-
<img
436-
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
437-
src={`${baseURL}/api/v1/node-icon/${toolName}`}
438-
alt={toolName}
439-
/>
440-
</Box>
434+
/>
441435
)
442436
})
443437
} else {
@@ -447,23 +441,17 @@ const AgentFlowNode = ({ data }) => {
447441
return [
448442
<Box
449443
key={`tool-${configIndex}-${toolIndex}`}
444+
component='img'
445+
src={`${baseURL}/api/v1/node-icon/${toolName}`}
446+
alt={toolName}
450447
sx={{
451-
backgroundColor: 'rgba(255, 255, 255, 0.2)',
448+
width: 20,
449+
height: 20,
452450
borderRadius: '50%',
453-
width: 24,
454-
height: 24,
455-
display: 'flex',
456-
justifyContent: 'center',
457-
alignItems: 'center',
458-
padding: '4px'
451+
backgroundColor: 'rgba(255, 255, 255, 0.2)',
452+
padding: 0.3
459453
}}
460-
>
461-
<img
462-
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
463-
src={`${baseURL}/api/v1/node-icon/${toolName}`}
464-
alt={toolName}
465-
/>
466-
</Box>
454+
/>
467455
]
468456
}
469457
})}

packages/ui/src/views/canvas/NodeInputHandler.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1069,7 +1069,7 @@ const NodeInputHandler = ({
10691069
)}
10701070

10711071
{(inputParam.type === 'string' || inputParam.type === 'password' || inputParam.type === 'number') &&
1072-
(inputParam?.acceptVariable ? (
1072+
(inputParam?.acceptVariable && window.location.href.includes('v2/agentcanvas') ? (
10731073
<RichInput
10741074
key={data.inputs[inputParam.name]}
10751075
placeholder={inputParam.placeholder}

packages/ui/src/views/canvas/StickyNote.jsx

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { useContext, useState, memo } from 'react'
33
import { useSelector } from 'react-redux'
44

55
// material-ui
6-
import { useTheme } from '@mui/material/styles'
6+
import { useTheme, darken, lighten } from '@mui/material/styles'
77

88
// project imports
99
import NodeCardWrapper from '@/ui-component/cards/NodeCardWrapper'
@@ -18,6 +18,7 @@ import { flowContext } from '@/store/context/ReactFlowContext'
1818
const StickyNote = ({ data }) => {
1919
const theme = useTheme()
2020
const canvas = useSelector((state) => state.canvas)
21+
const customization = useSelector((state) => state.customization)
2122
const { deleteNode, duplicateNode } = useContext(flowContext)
2223
const [inputParam] = data.inputParams
2324

@@ -31,20 +32,31 @@ const StickyNote = ({ data }) => {
3132
setOpen(true)
3233
}
3334

35+
const defaultColor = '#FFE770' // fallback color if data.color is not present
36+
const nodeColor = data.color || defaultColor
37+
3438
const getBorderColor = () => {
3539
if (data.selected) return theme.palette.primary.main
36-
else if (theme?.customization?.isDarkMode) return theme.palette.grey[900] + 25
40+
else if (customization?.isDarkMode) return theme.palette.grey[700]
3741
else return theme.palette.grey[900] + 50
3842
}
3943

44+
const getBackgroundColor = () => {
45+
if (customization?.isDarkMode) {
46+
return data.selected ? darken(nodeColor, 0.7) : darken(nodeColor, 0.8)
47+
} else {
48+
return data.selected ? lighten(nodeColor, 0.1) : lighten(nodeColor, 0.2)
49+
}
50+
}
51+
4052
return (
4153
<>
4254
<NodeCardWrapper
4355
content={false}
4456
sx={{
4557
padding: 0,
4658
borderColor: getBorderColor(),
47-
backgroundColor: data.selected ? '#FFDC00' : '#FFE770'
59+
backgroundColor: getBackgroundColor()
4860
}}
4961
border={false}
5062
>
@@ -66,8 +78,12 @@ const StickyNote = ({ data }) => {
6678
onClick={() => {
6779
duplicateNode(data.id)
6880
}}
69-
sx={{ height: '35px', width: '35px', '&:hover': { color: theme?.palette.primary.main } }}
70-
color={theme?.customization?.isDarkMode ? theme.colors?.paper : 'inherit'}
81+
sx={{
82+
height: '35px',
83+
width: '35px',
84+
color: customization?.isDarkMode ? 'white' : 'inherit',
85+
'&:hover': { color: theme?.palette.primary.main }
86+
}}
7187
>
7288
<IconCopy />
7389
</IconButton>
@@ -76,8 +92,12 @@ const StickyNote = ({ data }) => {
7692
onClick={() => {
7793
deleteNode(data.id)
7894
}}
79-
sx={{ height: '35px', width: '35px', '&:hover': { color: 'red' } }}
80-
color={theme?.customization?.isDarkMode ? theme.colors?.paper : 'inherit'}
95+
sx={{
96+
height: '35px',
97+
width: '35px',
98+
color: customization?.isDarkMode ? 'white' : 'inherit',
99+
'&:hover': { color: theme?.palette.error.main }
100+
}}
81101
>
82102
<IconTrash />
83103
</IconButton>

0 commit comments

Comments
 (0)