Skip to content

Commit 3d75977

Browse files
feat: implement the custom MCP Server in tool (#6205)
* feat: implement the custom MCP Server in tool - Implemented MCPServersTable for displaying MCP server data with status badges and tools count. - Created CustomMcpServerDialog for adding and editing custom MCP servers with validation and authorization features. - Integrated MCP server management into the Tools view with tabbed navigation for Custom Tools and Custom MCP Servers. - Added pagination and loading states for MCP servers. * tfix nodes.test.ts ESM load crash * feat(custom-mcp): harden security, scalability, and UI polish * enhance custom mcp server creation flow and UI updates * feat(mcp): add MCP server handshake timeout, enhance UI UX for editing custom mcp server --------- Co-authored-by: Henry <hzj94@hotmail.com>
1 parent a4b3f6f commit 3d75977

42 files changed

Lines changed: 4346 additions & 135 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CONTRIBUTING.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,8 @@ Flowise support different environment variables to configure your instance. You
162162
| MCP_CORS_ORIGINS | The allowed origins for MCP endpoint cross-origin calls. If unset, only non-browser (no Origin header) requests are allowed. Set to `*` to allow all origins. | String | |
163163
| IFRAME_ORIGINS | The allowed origins for iframe src embedding | String | |
164164
| FLOWISE_FILE_SIZE_LIMIT | Upload File Size Limit | String | 50mb |
165+
| CUSTOM_MCP_TOOLS_MAX_BYTES | Maximum byte size of the JSON tools payload stored per Custom MCP Server row (after stringify). Rejects oversized payloads returned by remote MCP servers. Set to `0` to disable the check. | Number | 524288 (512 KB) |
166+
| CUSTOM_MCP_AUTHORIZE_TIMEOUT_MS | Maximum time in milliseconds to wait for the MCP server handshake during authorize. Bounds the request so a slow/tarpit upstream cannot tie up the HTTP worker indefinitely. Minimum 1000. | Number | 15000 |
165167
| DEBUG | Print logs from components | Boolean | |
166168
| LOG_PATH | Location where log files are stored | String | `your-path/Flowise/logs` |
167169
| LOG_LEVEL | Different levels of logs | Enum String: `error`, `info`, `verbose`, `debug` | `info` |

docker/.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ PORT=3000
7979
# MCP_CORS_ORIGINS=*
8080
# IFRAME_ORIGINS=*
8181
# FLOWISE_FILE_SIZE_LIMIT=50mb
82+
# CUSTOM_MCP_TOOLS_MAX_BYTES=524288
83+
# CUSTOM_MCP_AUTHORIZE_TIMEOUT_MS=15000
8284
# SHOW_COMMUNITY_NODES=true
8385
# DISABLE_FLOWISE_TELEMETRY=true
8486
# DISABLED_NODES=bufferMemory,chatOpenAI (comma separated list of node names to disable)

docker/docker-compose-queue-prebuilt.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ services:
7979
- MCP_CORS_ORIGINS=${MCP_CORS_ORIGINS}
8080
- IFRAME_ORIGINS=${IFRAME_ORIGINS}
8181
- FLOWISE_FILE_SIZE_LIMIT=${FLOWISE_FILE_SIZE_LIMIT}
82+
- CUSTOM_MCP_TOOLS_MAX_BYTES=${CUSTOM_MCP_TOOLS_MAX_BYTES}
83+
- CUSTOM_MCP_AUTHORIZE_TIMEOUT_MS=${CUSTOM_MCP_AUTHORIZE_TIMEOUT_MS}
8284
- SHOW_COMMUNITY_NODES=${SHOW_COMMUNITY_NODES}
8385
- DISABLE_FLOWISE_TELEMETRY=${DISABLE_FLOWISE_TELEMETRY}
8486
- DISABLED_NODES=${DISABLED_NODES}
@@ -227,6 +229,8 @@ services:
227229
- CORS_ORIGINS=${CORS_ORIGINS}
228230
- IFRAME_ORIGINS=${IFRAME_ORIGINS}
229231
- FLOWISE_FILE_SIZE_LIMIT=${FLOWISE_FILE_SIZE_LIMIT}
232+
- CUSTOM_MCP_TOOLS_MAX_BYTES=${CUSTOM_MCP_TOOLS_MAX_BYTES}
233+
- CUSTOM_MCP_AUTHORIZE_TIMEOUT_MS=${CUSTOM_MCP_AUTHORIZE_TIMEOUT_MS}
230234
- SHOW_COMMUNITY_NODES=${SHOW_COMMUNITY_NODES}
231235
- DISABLE_FLOWISE_TELEMETRY=${DISABLE_FLOWISE_TELEMETRY}
232236
- DISABLED_NODES=${DISABLED_NODES}

docker/docker-compose.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ services:
6464
- MCP_CORS_ORIGINS=${MCP_CORS_ORIGINS}
6565
- IFRAME_ORIGINS=${IFRAME_ORIGINS}
6666
- FLOWISE_FILE_SIZE_LIMIT=${FLOWISE_FILE_SIZE_LIMIT}
67+
- CUSTOM_MCP_TOOLS_MAX_BYTES=${CUSTOM_MCP_TOOLS_MAX_BYTES}
68+
- CUSTOM_MCP_AUTHORIZE_TIMEOUT_MS=${CUSTOM_MCP_AUTHORIZE_TIMEOUT_MS}
6769
- SHOW_COMMUNITY_NODES=${SHOW_COMMUNITY_NODES}
6870
- DISABLE_FLOWISE_TELEMETRY=${DISABLE_FLOWISE_TELEMETRY}
6971
- DISABLED_NODES=${DISABLED_NODES}

docker/worker/.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ WORKER_PORT=5566
7878
# CORS_ORIGINS=*
7979
# IFRAME_ORIGINS=*
8080
# FLOWISE_FILE_SIZE_LIMIT=50mb
81+
# CUSTOM_MCP_TOOLS_MAX_BYTES=524288
82+
# CUSTOM_MCP_AUTHORIZE_TIMEOUT_MS=15000
8183
# SHOW_COMMUNITY_NODES=true
8284
# DISABLE_FLOWISE_TELEMETRY=true
8385
# DISABLED_NODES=bufferMemory,chatOpenAI (comma separated list of node names to disable)

docker/worker/docker-compose.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ services:
6363
- CORS_ORIGINS=${CORS_ORIGINS}
6464
- IFRAME_ORIGINS=${IFRAME_ORIGINS}
6565
- FLOWISE_FILE_SIZE_LIMIT=${FLOWISE_FILE_SIZE_LIMIT}
66+
- CUSTOM_MCP_TOOLS_MAX_BYTES=${CUSTOM_MCP_TOOLS_MAX_BYTES}
67+
- CUSTOM_MCP_AUTHORIZE_TIMEOUT_MS=${CUSTOM_MCP_AUTHORIZE_TIMEOUT_MS}
6668
- SHOW_COMMUNITY_NODES=${SHOW_COMMUNITY_NODES}
6769
- DISABLE_FLOWISE_TELEMETRY=${DISABLE_FLOWISE_TELEMETRY}
6870
- DISABLED_NODES=${DISABLED_NODES}
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import { Tool } from '@langchain/core/tools'
2+
import { ICommonObject, IDatabaseEntity, INode, INodeData, INodeOptionsValue, INodeParams } from '../../../../src/Interface'
3+
import { MCPToolkit } from '../core'
4+
import { decryptCredentialData } from '../../../../src/utils'
5+
import { DataSource } from 'typeorm'
6+
7+
class CustomMcpServerTool implements INode {
8+
label: string
9+
name: string
10+
version: number
11+
description: string
12+
type: string
13+
icon: string
14+
category: string
15+
baseClasses: string[]
16+
inputs: INodeParams[]
17+
18+
constructor() {
19+
this.label = 'Custom MCP Server'
20+
this.name = 'customMcpServerTool'
21+
this.version = 1.0
22+
this.type = 'Custom MCP Server Tool'
23+
this.icon = 'customMCP.png'
24+
this.category = 'Tools (MCP)'
25+
this.description = 'Use tools from authorized MCP servers configured in workspace'
26+
this.inputs = [
27+
{
28+
label: 'Custom MCP Server',
29+
name: 'mcpServerId',
30+
type: 'asyncOptions',
31+
loadMethod: 'listServers'
32+
},
33+
{
34+
label: 'Available Actions',
35+
name: 'mcpActions',
36+
type: 'asyncMultiOptions',
37+
loadMethod: 'listActions',
38+
refresh: true
39+
}
40+
]
41+
this.baseClasses = ['Tool']
42+
}
43+
44+
//@ts-ignore
45+
loadMethods = {
46+
listServers: async (_: INodeData, options: ICommonObject): Promise<INodeOptionsValue[]> => {
47+
try {
48+
const appDataSource = options.appDataSource as DataSource
49+
const databaseEntities = options.databaseEntities as IDatabaseEntity
50+
if (!appDataSource || !databaseEntities?.['CustomMcpServer']) {
51+
return []
52+
}
53+
54+
const workspaceId = (options.searchOptions as ICommonObject | undefined)?.workspaceId as string | undefined
55+
if (!workspaceId) return []
56+
57+
const mcpServers = await appDataSource.getRepository(databaseEntities['CustomMcpServer']).find({
58+
where: { workspaceId, status: 'AUTHORIZED' },
59+
order: { updatedDate: 'DESC' }
60+
})
61+
62+
return mcpServers.map((server: any) => {
63+
let maskedUrl: string
64+
try {
65+
const parsed = new URL(server.serverUrl)
66+
maskedUrl = parsed.pathname && parsed.pathname !== '/' ? `${parsed.origin}/************` : parsed.origin
67+
} catch {
68+
maskedUrl = '************'
69+
}
70+
return {
71+
label: server.name,
72+
name: server.id,
73+
description: maskedUrl
74+
}
75+
})
76+
} catch (error) {
77+
return []
78+
}
79+
},
80+
listActions: async (nodeData: INodeData, options: ICommonObject): Promise<INodeOptionsValue[]> => {
81+
try {
82+
const toolset = await this.getTools(nodeData, options)
83+
toolset.sort((a: any, b: any) => a.name.localeCompare(b.name))
84+
85+
return toolset.map(({ name, ...rest }) => ({
86+
label: name.toUpperCase(),
87+
name: name,
88+
description: rest.description || name
89+
}))
90+
} catch (error) {
91+
return [
92+
{
93+
label: 'No Available Actions',
94+
name: 'error',
95+
description: 'Select an authorized MCP server first, then refresh'
96+
}
97+
]
98+
}
99+
}
100+
}
101+
102+
async init(nodeData: INodeData, _: string, options: ICommonObject): Promise<any> {
103+
const tools = await this.getTools(nodeData, options)
104+
105+
const _mcpActions = nodeData.inputs?.mcpActions
106+
let mcpActions: string[] = []
107+
if (_mcpActions) {
108+
try {
109+
mcpActions = typeof _mcpActions === 'string' ? JSON.parse(_mcpActions) : _mcpActions
110+
} catch (error) {
111+
console.error('Error parsing mcp actions:', error)
112+
}
113+
}
114+
115+
return tools.filter((tool: any) => mcpActions.includes(tool.name))
116+
}
117+
118+
async getTools(nodeData: INodeData, options: ICommonObject): Promise<Tool[]> {
119+
const serverId = nodeData.inputs?.mcpServerId as string
120+
if (!serverId) {
121+
throw new Error('MCP Server is required')
122+
}
123+
124+
const appDataSource = options.appDataSource as DataSource
125+
const databaseEntities = options.databaseEntities as IDatabaseEntity
126+
if (!appDataSource || !databaseEntities?.['CustomMcpServer']) {
127+
throw new Error('Database not available')
128+
}
129+
130+
const workspaceId =
131+
(options.workspaceId as string | undefined) ??
132+
((options.searchOptions as ICommonObject | undefined)?.workspaceId as string | undefined)
133+
if (!workspaceId) {
134+
throw new Error('Workspace context is required to load MCP server')
135+
}
136+
137+
const serverRecord = await appDataSource.getRepository(databaseEntities['CustomMcpServer']).findOneBy({ id: serverId, workspaceId })
138+
if (!serverRecord) {
139+
throw new Error(`MCP server ${serverId} not found`)
140+
}
141+
if (serverRecord.status !== 'AUTHORIZED') {
142+
throw new Error(`MCP server "${serverRecord.name}" is not authorized. Please authorize it in the Tools page first.`)
143+
}
144+
145+
// Build headers from encrypted authConfig — only when authType explicitly requires them
146+
let headers: Record<string, string> = {}
147+
if (serverRecord.authType === 'CUSTOM_HEADERS' && serverRecord.authConfig) {
148+
try {
149+
const decrypted = await decryptCredentialData(serverRecord.authConfig)
150+
if (decrypted?.headers && typeof decrypted.headers === 'object') {
151+
headers = decrypted.headers as Record<string, string>
152+
}
153+
} catch {
154+
// authConfig decryption failed — proceed without headers
155+
}
156+
}
157+
158+
const serverParams: any = {
159+
url: serverRecord.serverUrl,
160+
...(Object.keys(headers).length > 0 ? { headers } : {})
161+
}
162+
163+
if (options.cachePool) {
164+
const cacheKey = `mcpServer_${serverId}`
165+
const cachedResult = await options.cachePool.getMCPCache(cacheKey)
166+
if (cachedResult) {
167+
return cachedResult.tools
168+
}
169+
}
170+
171+
const toolkit = new MCPToolkit(serverParams, 'sse')
172+
await toolkit.initialize()
173+
174+
const tools = toolkit.tools ?? []
175+
176+
if (options.cachePool) {
177+
const cacheKey = `mcpServer_${serverId}`
178+
await options.cachePool.addMCPCache(cacheKey, { toolkit, tools })
179+
}
180+
181+
return tools.map((tool: Tool) => {
182+
tool.name = this.formatToolName(tool.name)
183+
return tool
184+
}) as Tool[]
185+
}
186+
187+
/**
188+
* Formats the tool name to ensure it is a valid identifier by replacing spaces and special characters with underscores.
189+
* This is necessary because tool names may be used as identifiers in various contexts where special characters could cause issues.
190+
* For example, a tool named "Get User Info" would be formatted to "Get_User_Info".
191+
* This method can be enhanced further to handle edge cases as needed.
192+
*/
193+
private formatToolName = (name: string): string => name.trim().replace(/[^a-zA-Z0-9_-]/g, '_')
194+
}
195+
196+
module.exports = { nodeClass: CustomMcpServerTool }
4.04 KB
Loading
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { validateCustomHeaders } from './headerValidation'
2+
3+
describe('validateCustomHeaders', () => {
4+
it('accepts a typical auth header set', () => {
5+
expect(() =>
6+
validateCustomHeaders({
7+
Authorization: 'Bearer abc.def.ghi',
8+
'X-Api-Key': 'secret-value',
9+
'Content-Type': 'application/json'
10+
})
11+
).not.toThrow()
12+
})
13+
14+
it('rejects non-object input', () => {
15+
expect(() => validateCustomHeaders(null as any)).toThrow(/expected an object/)
16+
expect(() => validateCustomHeaders('x' as any)).toThrow(/expected an object/)
17+
})
18+
19+
it('rejects too many headers', () => {
20+
const headers: Record<string, string> = {}
21+
for (let i = 0; i < 26; i++) headers[`X-H${i}`] = 'v'
22+
expect(() => validateCustomHeaders(headers)).toThrow(/too many entries/)
23+
})
24+
25+
it('rejects empty key', () => {
26+
expect(() => validateCustomHeaders({ '': 'v' })).toThrow(/non-empty string/)
27+
})
28+
29+
it('rejects keys with illegal characters', () => {
30+
expect(() => validateCustomHeaders({ 'X Bad': 'v' })).toThrow(/illegal characters/)
31+
expect(() => validateCustomHeaders({ 'X:Bad': 'v' })).toThrow(/illegal characters/)
32+
expect(() => validateCustomHeaders({ 'X\tBad': 'v' })).toThrow(/illegal characters/)
33+
})
34+
35+
it('rejects oversized keys and values', () => {
36+
expect(() => validateCustomHeaders({ ['X-' + 'a'.repeat(200)]: 'v' })).toThrow(/key exceeds/)
37+
expect(() => validateCustomHeaders({ 'X-Ok': 'a'.repeat(2049) })).toThrow(/value exceeds/)
38+
})
39+
40+
it('rejects denied header names case-insensitively', () => {
41+
for (const name of ['Host', 'HOST', 'cookie', 'Set-Cookie', 'Transfer-Encoding', 'Proxy-Authorization']) {
42+
expect(() => validateCustomHeaders({ [name]: 'v' })).toThrow(/not allowed/)
43+
}
44+
})
45+
46+
it('rejects Proxy-*, X-Forwarded-*, Sec-* prefixes', () => {
47+
expect(() => validateCustomHeaders({ 'Proxy-Custom': 'v' })).toThrow(/not allowed/)
48+
expect(() => validateCustomHeaders({ 'X-Forwarded-For': '1.2.3.4' })).toThrow(/not allowed/)
49+
expect(() => validateCustomHeaders({ 'Sec-Fetch-Mode': 'cors' })).toThrow(/not allowed/)
50+
})
51+
52+
it('rejects CRLF injection in values', () => {
53+
expect(() => validateCustomHeaders({ 'X-Foo': 'val\r\nInjected: 1' })).toThrow(/control characters/)
54+
expect(() => validateCustomHeaders({ 'X-Foo': 'val\nInjected: 1' })).toThrow(/control characters/)
55+
expect(() => validateCustomHeaders({ 'X-Foo': 'val\rInjected: 1' })).toThrow(/control characters/)
56+
})
57+
58+
it('rejects other control characters but accepts tab', () => {
59+
expect(() => validateCustomHeaders({ 'X-Foo': 'bad\u0001' })).toThrow(/control characters/)
60+
expect(() => validateCustomHeaders({ 'X-Foo': 'tab\there' })).not.toThrow()
61+
})
62+
63+
it('rejects non-string values', () => {
64+
expect(() => validateCustomHeaders({ 'X-Foo': 123 as any })).toThrow(/must be a string/)
65+
})
66+
})
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
const RFC7230_TOKEN = /^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$/
2+
3+
const DENIED_HEADER_NAMES = new Set([
4+
'host',
5+
'content-length',
6+
'transfer-encoding',
7+
'connection',
8+
'upgrade',
9+
'cookie',
10+
'set-cookie',
11+
'proxy-authorization',
12+
'proxy-connection'
13+
])
14+
15+
const DENIED_HEADER_PREFIXES = ['proxy-', 'x-forwarded-', 'sec-']
16+
17+
const MAX_HEADERS = 25
18+
const MAX_KEY_LENGTH = 128
19+
const MAX_VALUE_LENGTH = 2048
20+
21+
/**
22+
* Validates a set of user-supplied HTTP headers intended for outbound requests.
23+
* Rejects malformed keys, CRLF/control-char injection in values, hop-by-hop and
24+
* sensitive header names, and oversized payloads. Throws a plain Error; callers
25+
* are responsible for mapping to their own error types.
26+
*/
27+
export function validateCustomHeaders(headers: Record<string, string>): void {
28+
if (!headers || typeof headers !== 'object') {
29+
throw new Error('Invalid headers: expected an object')
30+
}
31+
32+
const entries = Object.entries(headers)
33+
if (entries.length > MAX_HEADERS) {
34+
throw new Error(`Invalid headers: too many entries (max ${MAX_HEADERS})`)
35+
}
36+
37+
for (const [key, value] of entries) {
38+
if (typeof key !== 'string' || key.length === 0) {
39+
throw new Error('Invalid header: key must be a non-empty string')
40+
}
41+
if (key.length > MAX_KEY_LENGTH) {
42+
throw new Error(`Invalid header "${key}": key exceeds ${MAX_KEY_LENGTH} chars`)
43+
}
44+
if (!RFC7230_TOKEN.test(key)) {
45+
throw new Error(`Invalid header "${key}": key contains illegal characters`)
46+
}
47+
48+
const lower = key.toLowerCase()
49+
if (DENIED_HEADER_NAMES.has(lower) || DENIED_HEADER_PREFIXES.some((p) => lower.startsWith(p))) {
50+
throw new Error(`Invalid header "${key}": this header name is not allowed`)
51+
}
52+
53+
if (typeof value !== 'string') {
54+
throw new Error(`Invalid header "${key}": value must be a string`)
55+
}
56+
if (value.length > MAX_VALUE_LENGTH) {
57+
throw new Error(`Invalid header "${key}": value exceeds ${MAX_VALUE_LENGTH} chars`)
58+
}
59+
for (let i = 0; i < value.length; i++) {
60+
const code = value.charCodeAt(i)
61+
if (code === 0x0d || code === 0x0a || (code < 0x20 && code !== 0x09)) {
62+
throw new Error(`Invalid header "${key}": value contains illegal control characters`)
63+
}
64+
}
65+
}
66+
}

0 commit comments

Comments
 (0)