mirror of
https://github.com/FlowiseAI/Flowise.git
synced 2026-10-12 02:02:17 +00:00
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 <[email protected]>
This commit is contained in:
@@ -162,6 +162,8 @@ Flowise support different environment variables to configure your instance. You
|
||||
| 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 | |
|
||||
| IFRAME_ORIGINS | The allowed origins for iframe src embedding | String | |
|
||||
| FLOWISE_FILE_SIZE_LIMIT | Upload File Size Limit | String | 50mb |
|
||||
| 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) |
|
||||
| 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 |
|
||||
| DEBUG | Print logs from components | Boolean | |
|
||||
| LOG_PATH | Location where log files are stored | String | `your-path/Flowise/logs` |
|
||||
| LOG_LEVEL | Different levels of logs | Enum String: `error`, `info`, `verbose`, `debug` | `info` |
|
||||
|
||||
@@ -79,6 +79,8 @@ PORT=3000
|
||||
# MCP_CORS_ORIGINS=*
|
||||
# IFRAME_ORIGINS=*
|
||||
# FLOWISE_FILE_SIZE_LIMIT=50mb
|
||||
# CUSTOM_MCP_TOOLS_MAX_BYTES=524288
|
||||
# CUSTOM_MCP_AUTHORIZE_TIMEOUT_MS=15000
|
||||
# SHOW_COMMUNITY_NODES=true
|
||||
# DISABLE_FLOWISE_TELEMETRY=true
|
||||
# DISABLED_NODES=bufferMemory,chatOpenAI (comma separated list of node names to disable)
|
||||
|
||||
@@ -79,6 +79,8 @@ services:
|
||||
- MCP_CORS_ORIGINS=${MCP_CORS_ORIGINS}
|
||||
- IFRAME_ORIGINS=${IFRAME_ORIGINS}
|
||||
- FLOWISE_FILE_SIZE_LIMIT=${FLOWISE_FILE_SIZE_LIMIT}
|
||||
- CUSTOM_MCP_TOOLS_MAX_BYTES=${CUSTOM_MCP_TOOLS_MAX_BYTES}
|
||||
- CUSTOM_MCP_AUTHORIZE_TIMEOUT_MS=${CUSTOM_MCP_AUTHORIZE_TIMEOUT_MS}
|
||||
- SHOW_COMMUNITY_NODES=${SHOW_COMMUNITY_NODES}
|
||||
- DISABLE_FLOWISE_TELEMETRY=${DISABLE_FLOWISE_TELEMETRY}
|
||||
- DISABLED_NODES=${DISABLED_NODES}
|
||||
@@ -227,6 +229,8 @@ services:
|
||||
- CORS_ORIGINS=${CORS_ORIGINS}
|
||||
- IFRAME_ORIGINS=${IFRAME_ORIGINS}
|
||||
- FLOWISE_FILE_SIZE_LIMIT=${FLOWISE_FILE_SIZE_LIMIT}
|
||||
- CUSTOM_MCP_TOOLS_MAX_BYTES=${CUSTOM_MCP_TOOLS_MAX_BYTES}
|
||||
- CUSTOM_MCP_AUTHORIZE_TIMEOUT_MS=${CUSTOM_MCP_AUTHORIZE_TIMEOUT_MS}
|
||||
- SHOW_COMMUNITY_NODES=${SHOW_COMMUNITY_NODES}
|
||||
- DISABLE_FLOWISE_TELEMETRY=${DISABLE_FLOWISE_TELEMETRY}
|
||||
- DISABLED_NODES=${DISABLED_NODES}
|
||||
|
||||
@@ -64,6 +64,8 @@ services:
|
||||
- MCP_CORS_ORIGINS=${MCP_CORS_ORIGINS}
|
||||
- IFRAME_ORIGINS=${IFRAME_ORIGINS}
|
||||
- FLOWISE_FILE_SIZE_LIMIT=${FLOWISE_FILE_SIZE_LIMIT}
|
||||
- CUSTOM_MCP_TOOLS_MAX_BYTES=${CUSTOM_MCP_TOOLS_MAX_BYTES}
|
||||
- CUSTOM_MCP_AUTHORIZE_TIMEOUT_MS=${CUSTOM_MCP_AUTHORIZE_TIMEOUT_MS}
|
||||
- SHOW_COMMUNITY_NODES=${SHOW_COMMUNITY_NODES}
|
||||
- DISABLE_FLOWISE_TELEMETRY=${DISABLE_FLOWISE_TELEMETRY}
|
||||
- DISABLED_NODES=${DISABLED_NODES}
|
||||
|
||||
@@ -78,6 +78,8 @@ WORKER_PORT=5566
|
||||
# CORS_ORIGINS=*
|
||||
# IFRAME_ORIGINS=*
|
||||
# FLOWISE_FILE_SIZE_LIMIT=50mb
|
||||
# CUSTOM_MCP_TOOLS_MAX_BYTES=524288
|
||||
# CUSTOM_MCP_AUTHORIZE_TIMEOUT_MS=15000
|
||||
# SHOW_COMMUNITY_NODES=true
|
||||
# DISABLE_FLOWISE_TELEMETRY=true
|
||||
# DISABLED_NODES=bufferMemory,chatOpenAI (comma separated list of node names to disable)
|
||||
|
||||
@@ -63,6 +63,8 @@ services:
|
||||
- CORS_ORIGINS=${CORS_ORIGINS}
|
||||
- IFRAME_ORIGINS=${IFRAME_ORIGINS}
|
||||
- FLOWISE_FILE_SIZE_LIMIT=${FLOWISE_FILE_SIZE_LIMIT}
|
||||
- CUSTOM_MCP_TOOLS_MAX_BYTES=${CUSTOM_MCP_TOOLS_MAX_BYTES}
|
||||
- CUSTOM_MCP_AUTHORIZE_TIMEOUT_MS=${CUSTOM_MCP_AUTHORIZE_TIMEOUT_MS}
|
||||
- SHOW_COMMUNITY_NODES=${SHOW_COMMUNITY_NODES}
|
||||
- DISABLE_FLOWISE_TELEMETRY=${DISABLE_FLOWISE_TELEMETRY}
|
||||
- DISABLED_NODES=${DISABLED_NODES}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { Tool } from '@langchain/core/tools'
|
||||
import { ICommonObject, IDatabaseEntity, INode, INodeData, INodeOptionsValue, INodeParams } from '../../../../src/Interface'
|
||||
import { MCPToolkit } from '../core'
|
||||
import { decryptCredentialData } from '../../../../src/utils'
|
||||
import { DataSource } from 'typeorm'
|
||||
|
||||
class CustomMcpServerTool implements INode {
|
||||
label: string
|
||||
name: string
|
||||
version: number
|
||||
description: string
|
||||
type: string
|
||||
icon: string
|
||||
category: string
|
||||
baseClasses: string[]
|
||||
inputs: INodeParams[]
|
||||
|
||||
constructor() {
|
||||
this.label = 'Custom MCP Server'
|
||||
this.name = 'customMcpServerTool'
|
||||
this.version = 1.0
|
||||
this.type = 'Custom MCP Server Tool'
|
||||
this.icon = 'customMCP.png'
|
||||
this.category = 'Tools (MCP)'
|
||||
this.description = 'Use tools from authorized MCP servers configured in workspace'
|
||||
this.inputs = [
|
||||
{
|
||||
label: 'Custom MCP Server',
|
||||
name: 'mcpServerId',
|
||||
type: 'asyncOptions',
|
||||
loadMethod: 'listServers'
|
||||
},
|
||||
{
|
||||
label: 'Available Actions',
|
||||
name: 'mcpActions',
|
||||
type: 'asyncMultiOptions',
|
||||
loadMethod: 'listActions',
|
||||
refresh: true
|
||||
}
|
||||
]
|
||||
this.baseClasses = ['Tool']
|
||||
}
|
||||
|
||||
//@ts-ignore
|
||||
loadMethods = {
|
||||
listServers: async (_: INodeData, options: ICommonObject): Promise<INodeOptionsValue[]> => {
|
||||
try {
|
||||
const appDataSource = options.appDataSource as DataSource
|
||||
const databaseEntities = options.databaseEntities as IDatabaseEntity
|
||||
if (!appDataSource || !databaseEntities?.['CustomMcpServer']) {
|
||||
return []
|
||||
}
|
||||
|
||||
const workspaceId = (options.searchOptions as ICommonObject | undefined)?.workspaceId as string | undefined
|
||||
if (!workspaceId) return []
|
||||
|
||||
const mcpServers = await appDataSource.getRepository(databaseEntities['CustomMcpServer']).find({
|
||||
where: { workspaceId, status: 'AUTHORIZED' },
|
||||
order: { updatedDate: 'DESC' }
|
||||
})
|
||||
|
||||
return mcpServers.map((server: any) => {
|
||||
let maskedUrl: string
|
||||
try {
|
||||
const parsed = new URL(server.serverUrl)
|
||||
maskedUrl = parsed.pathname && parsed.pathname !== '/' ? `${parsed.origin}/************` : parsed.origin
|
||||
} catch {
|
||||
maskedUrl = '************'
|
||||
}
|
||||
return {
|
||||
label: server.name,
|
||||
name: server.id,
|
||||
description: maskedUrl
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
return []
|
||||
}
|
||||
},
|
||||
listActions: async (nodeData: INodeData, options: ICommonObject): Promise<INodeOptionsValue[]> => {
|
||||
try {
|
||||
const toolset = await this.getTools(nodeData, options)
|
||||
toolset.sort((a: any, b: any) => a.name.localeCompare(b.name))
|
||||
|
||||
return toolset.map(({ name, ...rest }) => ({
|
||||
label: name.toUpperCase(),
|
||||
name: name,
|
||||
description: rest.description || name
|
||||
}))
|
||||
} catch (error) {
|
||||
return [
|
||||
{
|
||||
label: 'No Available Actions',
|
||||
name: 'error',
|
||||
description: 'Select an authorized MCP server first, then refresh'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async init(nodeData: INodeData, _: string, options: ICommonObject): Promise<any> {
|
||||
const tools = await this.getTools(nodeData, options)
|
||||
|
||||
const _mcpActions = nodeData.inputs?.mcpActions
|
||||
let mcpActions: string[] = []
|
||||
if (_mcpActions) {
|
||||
try {
|
||||
mcpActions = typeof _mcpActions === 'string' ? JSON.parse(_mcpActions) : _mcpActions
|
||||
} catch (error) {
|
||||
console.error('Error parsing mcp actions:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return tools.filter((tool: any) => mcpActions.includes(tool.name))
|
||||
}
|
||||
|
||||
async getTools(nodeData: INodeData, options: ICommonObject): Promise<Tool[]> {
|
||||
const serverId = nodeData.inputs?.mcpServerId as string
|
||||
if (!serverId) {
|
||||
throw new Error('MCP Server is required')
|
||||
}
|
||||
|
||||
const appDataSource = options.appDataSource as DataSource
|
||||
const databaseEntities = options.databaseEntities as IDatabaseEntity
|
||||
if (!appDataSource || !databaseEntities?.['CustomMcpServer']) {
|
||||
throw new Error('Database not available')
|
||||
}
|
||||
|
||||
const workspaceId =
|
||||
(options.workspaceId as string | undefined) ??
|
||||
((options.searchOptions as ICommonObject | undefined)?.workspaceId as string | undefined)
|
||||
if (!workspaceId) {
|
||||
throw new Error('Workspace context is required to load MCP server')
|
||||
}
|
||||
|
||||
const serverRecord = await appDataSource.getRepository(databaseEntities['CustomMcpServer']).findOneBy({ id: serverId, workspaceId })
|
||||
if (!serverRecord) {
|
||||
throw new Error(`MCP server ${serverId} not found`)
|
||||
}
|
||||
if (serverRecord.status !== 'AUTHORIZED') {
|
||||
throw new Error(`MCP server "${serverRecord.name}" is not authorized. Please authorize it in the Tools page first.`)
|
||||
}
|
||||
|
||||
// Build headers from encrypted authConfig — only when authType explicitly requires them
|
||||
let headers: Record<string, string> = {}
|
||||
if (serverRecord.authType === 'CUSTOM_HEADERS' && serverRecord.authConfig) {
|
||||
try {
|
||||
const decrypted = await decryptCredentialData(serverRecord.authConfig)
|
||||
if (decrypted?.headers && typeof decrypted.headers === 'object') {
|
||||
headers = decrypted.headers as Record<string, string>
|
||||
}
|
||||
} catch {
|
||||
// authConfig decryption failed — proceed without headers
|
||||
}
|
||||
}
|
||||
|
||||
const serverParams: any = {
|
||||
url: serverRecord.serverUrl,
|
||||
...(Object.keys(headers).length > 0 ? { headers } : {})
|
||||
}
|
||||
|
||||
if (options.cachePool) {
|
||||
const cacheKey = `mcpServer_${serverId}`
|
||||
const cachedResult = await options.cachePool.getMCPCache(cacheKey)
|
||||
if (cachedResult) {
|
||||
return cachedResult.tools
|
||||
}
|
||||
}
|
||||
|
||||
const toolkit = new MCPToolkit(serverParams, 'sse')
|
||||
await toolkit.initialize()
|
||||
|
||||
const tools = toolkit.tools ?? []
|
||||
|
||||
if (options.cachePool) {
|
||||
const cacheKey = `mcpServer_${serverId}`
|
||||
await options.cachePool.addMCPCache(cacheKey, { toolkit, tools })
|
||||
}
|
||||
|
||||
return tools.map((tool: Tool) => {
|
||||
tool.name = this.formatToolName(tool.name)
|
||||
return tool
|
||||
}) as Tool[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the tool name to ensure it is a valid identifier by replacing spaces and special characters with underscores.
|
||||
* This is necessary because tool names may be used as identifiers in various contexts where special characters could cause issues.
|
||||
* For example, a tool named "Get User Info" would be formatted to "Get_User_Info".
|
||||
* This method can be enhanced further to handle edge cases as needed.
|
||||
*/
|
||||
private formatToolName = (name: string): string => name.trim().replace(/[^a-zA-Z0-9_-]/g, '_')
|
||||
}
|
||||
|
||||
module.exports = { nodeClass: CustomMcpServerTool }
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,66 @@
|
||||
import { validateCustomHeaders } from './headerValidation'
|
||||
|
||||
describe('validateCustomHeaders', () => {
|
||||
it('accepts a typical auth header set', () => {
|
||||
expect(() =>
|
||||
validateCustomHeaders({
|
||||
Authorization: 'Bearer abc.def.ghi',
|
||||
'X-Api-Key': 'secret-value',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects non-object input', () => {
|
||||
expect(() => validateCustomHeaders(null as any)).toThrow(/expected an object/)
|
||||
expect(() => validateCustomHeaders('x' as any)).toThrow(/expected an object/)
|
||||
})
|
||||
|
||||
it('rejects too many headers', () => {
|
||||
const headers: Record<string, string> = {}
|
||||
for (let i = 0; i < 26; i++) headers[`X-H${i}`] = 'v'
|
||||
expect(() => validateCustomHeaders(headers)).toThrow(/too many entries/)
|
||||
})
|
||||
|
||||
it('rejects empty key', () => {
|
||||
expect(() => validateCustomHeaders({ '': 'v' })).toThrow(/non-empty string/)
|
||||
})
|
||||
|
||||
it('rejects keys with illegal characters', () => {
|
||||
expect(() => validateCustomHeaders({ 'X Bad': 'v' })).toThrow(/illegal characters/)
|
||||
expect(() => validateCustomHeaders({ 'X:Bad': 'v' })).toThrow(/illegal characters/)
|
||||
expect(() => validateCustomHeaders({ 'X\tBad': 'v' })).toThrow(/illegal characters/)
|
||||
})
|
||||
|
||||
it('rejects oversized keys and values', () => {
|
||||
expect(() => validateCustomHeaders({ ['X-' + 'a'.repeat(200)]: 'v' })).toThrow(/key exceeds/)
|
||||
expect(() => validateCustomHeaders({ 'X-Ok': 'a'.repeat(2049) })).toThrow(/value exceeds/)
|
||||
})
|
||||
|
||||
it('rejects denied header names case-insensitively', () => {
|
||||
for (const name of ['Host', 'HOST', 'cookie', 'Set-Cookie', 'Transfer-Encoding', 'Proxy-Authorization']) {
|
||||
expect(() => validateCustomHeaders({ [name]: 'v' })).toThrow(/not allowed/)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects Proxy-*, X-Forwarded-*, Sec-* prefixes', () => {
|
||||
expect(() => validateCustomHeaders({ 'Proxy-Custom': 'v' })).toThrow(/not allowed/)
|
||||
expect(() => validateCustomHeaders({ 'X-Forwarded-For': '1.2.3.4' })).toThrow(/not allowed/)
|
||||
expect(() => validateCustomHeaders({ 'Sec-Fetch-Mode': 'cors' })).toThrow(/not allowed/)
|
||||
})
|
||||
|
||||
it('rejects CRLF injection in values', () => {
|
||||
expect(() => validateCustomHeaders({ 'X-Foo': 'val\r\nInjected: 1' })).toThrow(/control characters/)
|
||||
expect(() => validateCustomHeaders({ 'X-Foo': 'val\nInjected: 1' })).toThrow(/control characters/)
|
||||
expect(() => validateCustomHeaders({ 'X-Foo': 'val\rInjected: 1' })).toThrow(/control characters/)
|
||||
})
|
||||
|
||||
it('rejects other control characters but accepts tab', () => {
|
||||
expect(() => validateCustomHeaders({ 'X-Foo': 'bad\u0001' })).toThrow(/control characters/)
|
||||
expect(() => validateCustomHeaders({ 'X-Foo': 'tab\there' })).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects non-string values', () => {
|
||||
expect(() => validateCustomHeaders({ 'X-Foo': 123 as any })).toThrow(/must be a string/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
const RFC7230_TOKEN = /^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$/
|
||||
|
||||
const DENIED_HEADER_NAMES = new Set([
|
||||
'host',
|
||||
'content-length',
|
||||
'transfer-encoding',
|
||||
'connection',
|
||||
'upgrade',
|
||||
'cookie',
|
||||
'set-cookie',
|
||||
'proxy-authorization',
|
||||
'proxy-connection'
|
||||
])
|
||||
|
||||
const DENIED_HEADER_PREFIXES = ['proxy-', 'x-forwarded-', 'sec-']
|
||||
|
||||
const MAX_HEADERS = 25
|
||||
const MAX_KEY_LENGTH = 128
|
||||
const MAX_VALUE_LENGTH = 2048
|
||||
|
||||
/**
|
||||
* Validates a set of user-supplied HTTP headers intended for outbound requests.
|
||||
* Rejects malformed keys, CRLF/control-char injection in values, hop-by-hop and
|
||||
* sensitive header names, and oversized payloads. Throws a plain Error; callers
|
||||
* are responsible for mapping to their own error types.
|
||||
*/
|
||||
export function validateCustomHeaders(headers: Record<string, string>): void {
|
||||
if (!headers || typeof headers !== 'object') {
|
||||
throw new Error('Invalid headers: expected an object')
|
||||
}
|
||||
|
||||
const entries = Object.entries(headers)
|
||||
if (entries.length > MAX_HEADERS) {
|
||||
throw new Error(`Invalid headers: too many entries (max ${MAX_HEADERS})`)
|
||||
}
|
||||
|
||||
for (const [key, value] of entries) {
|
||||
if (typeof key !== 'string' || key.length === 0) {
|
||||
throw new Error('Invalid header: key must be a non-empty string')
|
||||
}
|
||||
if (key.length > MAX_KEY_LENGTH) {
|
||||
throw new Error(`Invalid header "${key}": key exceeds ${MAX_KEY_LENGTH} chars`)
|
||||
}
|
||||
if (!RFC7230_TOKEN.test(key)) {
|
||||
throw new Error(`Invalid header "${key}": key contains illegal characters`)
|
||||
}
|
||||
|
||||
const lower = key.toLowerCase()
|
||||
if (DENIED_HEADER_NAMES.has(lower) || DENIED_HEADER_PREFIXES.some((p) => lower.startsWith(p))) {
|
||||
throw new Error(`Invalid header "${key}": this header name is not allowed`)
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`Invalid header "${key}": value must be a string`)
|
||||
}
|
||||
if (value.length > MAX_VALUE_LENGTH) {
|
||||
throw new Error(`Invalid header "${key}": value exceeds ${MAX_VALUE_LENGTH} chars`)
|
||||
}
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const code = value.charCodeAt(i)
|
||||
if (code === 0x0d || code === 0x0a || (code < 0x20 && code !== 0x09)) {
|
||||
throw new Error(`Invalid header "${key}": value contains illegal control characters`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,4 +16,6 @@ export * from './followUpPrompts'
|
||||
export * from './validator'
|
||||
export * from './agentflowv2Generator'
|
||||
export * from './httpSecurity'
|
||||
export * from './headerValidation'
|
||||
export * from './pythonCodeValidator'
|
||||
export { MCPToolkit } from '../nodes/tools/MCP/core'
|
||||
|
||||
@@ -611,7 +611,7 @@ const getEncryptionKey = async (): Promise<string> => {
|
||||
* @param {IComponentCredentials} componentCredentials
|
||||
* @returns {Promise<ICommonObject>}
|
||||
*/
|
||||
const decryptCredentialData = async (encryptedData: string): Promise<ICommonObject> => {
|
||||
export const decryptCredentialData = async (encryptedData: string): Promise<ICommonObject> => {
|
||||
let decryptedDataStr: string
|
||||
|
||||
if (USE_AWS_SECRETS_MANAGER && secretsManagerClient) {
|
||||
|
||||
@@ -78,6 +78,8 @@ PORT=3000
|
||||
# MCP_CORS_ORIGINS=*
|
||||
# IFRAME_ORIGINS=*
|
||||
# FLOWISE_FILE_SIZE_LIMIT=50mb
|
||||
# CUSTOM_MCP_TOOLS_MAX_BYTES=524288
|
||||
# CUSTOM_MCP_AUTHORIZE_TIMEOUT_MS=15000
|
||||
# SHOW_COMMUNITY_NODES=true
|
||||
# DISABLE_FLOWISE_TELEMETRY=true
|
||||
# DISABLED_NODES=bufferMemory,chatOpenAI (comma separated list of node names to disable)
|
||||
|
||||
@@ -21,5 +21,7 @@ export enum FLOWISE_METRIC_COUNTERS {
|
||||
CHATFLOW_PREDICTION_EXTERNAL = 'chatflow_prediction_external',
|
||||
|
||||
AGENTFLOW_PREDICTION_INTERNAL = 'agentflow_prediction_internal',
|
||||
AGENTFLOW_PREDICTION_EXTERNAL = 'agentflow_prediction_external'
|
||||
AGENTFLOW_PREDICTION_EXTERNAL = 'agentflow_prediction_external',
|
||||
|
||||
CUSTOM_MCP_SERVER_CREATED = 'custom_mcp_server_created'
|
||||
}
|
||||
|
||||
@@ -183,6 +183,37 @@ export interface IExecution {
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
export enum CustomMcpServerStatus {
|
||||
PENDING = 'PENDING',
|
||||
AUTHORIZED = 'AUTHORIZED',
|
||||
ERROR = 'ERROR'
|
||||
}
|
||||
|
||||
export enum CustomMcpServerAuthType {
|
||||
NONE = 'NONE',
|
||||
CUSTOM_HEADERS = 'CUSTOM_HEADERS'
|
||||
}
|
||||
|
||||
export interface ICustomMcpServer {
|
||||
id: string
|
||||
name: string
|
||||
serverUrl: string
|
||||
iconSrc?: string
|
||||
color?: string
|
||||
authType: string
|
||||
authConfig?: string
|
||||
tools?: string
|
||||
toolCount: number
|
||||
status: CustomMcpServerStatus | string
|
||||
createdDate: Date
|
||||
updatedDate: Date
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
export interface ICustomMcpServerResponse extends Omit<ICustomMcpServer, 'authConfig'> {
|
||||
authConfig?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface IComponentNodes {
|
||||
[key: string]: INode
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ export abstract class BaseCommand extends Command {
|
||||
static flags = {
|
||||
// General Settings
|
||||
FLOWISE_FILE_SIZE_LIMIT: Flags.string(),
|
||||
CUSTOM_MCP_TOOLS_MAX_BYTES: Flags.string(),
|
||||
CUSTOM_MCP_AUTHORIZE_TIMEOUT_MS: Flags.string(),
|
||||
PORT: Flags.string(),
|
||||
CORS_ORIGINS: Flags.string(),
|
||||
MCP_CORS_ORIGINS: Flags.string(),
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import { StatusCodes } from 'http-status-codes'
|
||||
|
||||
jest.mock('../../services/custom-mcp-servers', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
createCustomMcpServer: jest.fn(),
|
||||
getAllCustomMcpServers: jest.fn(),
|
||||
getCustomMcpServerById: jest.fn(),
|
||||
updateCustomMcpServer: jest.fn(),
|
||||
deleteCustomMcpServer: jest.fn(),
|
||||
authorizeCustomMcpServer: jest.fn(),
|
||||
getDiscoveredTools: jest.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
jest.mock('../../utils/pagination', () => ({
|
||||
getPageAndLimitParams: jest.fn()
|
||||
}))
|
||||
|
||||
import customMcpServersController from './index'
|
||||
import customMcpServersService from '../../services/custom-mcp-servers'
|
||||
import { getPageAndLimitParams } from '../../utils/pagination'
|
||||
|
||||
const mockService = customMcpServersService as jest.Mocked<typeof customMcpServersService>
|
||||
const mockGetPageAndLimitParams = getPageAndLimitParams as jest.Mock
|
||||
|
||||
const makeReq = (overrides: Partial<Request> = {}): Request =>
|
||||
({
|
||||
body: undefined,
|
||||
params: {},
|
||||
query: {},
|
||||
user: {
|
||||
activeOrganizationId: 'org-1',
|
||||
activeWorkspaceId: 'ws-1'
|
||||
},
|
||||
...overrides
|
||||
} as unknown as Request)
|
||||
|
||||
const makeRes = () => {
|
||||
const res = { json: jest.fn() } as unknown as Response
|
||||
return res
|
||||
}
|
||||
|
||||
const makeNext = (): NextFunction => jest.fn()
|
||||
|
||||
describe('customMcpServersController', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('createCustomMcpServer', () => {
|
||||
it('should return error when body is not provided', async () => {
|
||||
const req = makeReq({ body: undefined })
|
||||
const next = makeNext()
|
||||
|
||||
await customMcpServersController.createCustomMcpServer(req, makeRes(), next)
|
||||
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
statusCode: StatusCodes.PRECONDITION_FAILED
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should return error when organization is not found', async () => {
|
||||
const req = makeReq({
|
||||
body: { name: 'test' },
|
||||
user: { activeOrganizationId: undefined, activeWorkspaceId: 'ws-1' } as any
|
||||
})
|
||||
const next = makeNext()
|
||||
|
||||
await customMcpServersController.createCustomMcpServer(req, makeRes(), next)
|
||||
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
statusCode: StatusCodes.NOT_FOUND
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should return error when workspace is not found', async () => {
|
||||
const req = makeReq({
|
||||
body: { name: 'test' },
|
||||
user: { activeOrganizationId: 'org-1', activeWorkspaceId: undefined } as any
|
||||
})
|
||||
const next = makeNext()
|
||||
|
||||
await customMcpServersController.createCustomMcpServer(req, makeRes(), next)
|
||||
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
statusCode: StatusCodes.NOT_FOUND
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should only pass allowlisted fields to service', async () => {
|
||||
const body = {
|
||||
name: 'My Server',
|
||||
serverUrl: 'https://example.com',
|
||||
iconSrc: 'icon.png',
|
||||
color: '#fff',
|
||||
authType: 'NONE',
|
||||
authConfig: { headers: {} },
|
||||
id: 'should-be-stripped',
|
||||
workspaceId: 'should-be-overridden',
|
||||
createdDate: 'should-be-stripped'
|
||||
}
|
||||
const req = makeReq({ body })
|
||||
const res = makeRes()
|
||||
mockService.createCustomMcpServer.mockResolvedValue({ id: 'new-1' })
|
||||
|
||||
await customMcpServersController.createCustomMcpServer(req, res, makeNext())
|
||||
|
||||
expect(mockService.createCustomMcpServer).toHaveBeenCalledWith(
|
||||
{
|
||||
name: 'My Server',
|
||||
serverUrl: 'https://example.com',
|
||||
iconSrc: 'icon.png',
|
||||
color: '#fff',
|
||||
authType: 'NONE',
|
||||
authConfig: { headers: {} },
|
||||
workspaceId: 'ws-1'
|
||||
},
|
||||
'org-1'
|
||||
)
|
||||
expect(res.json).toHaveBeenCalledWith({ id: 'new-1' })
|
||||
})
|
||||
|
||||
it('should set workspaceId from authenticated user', async () => {
|
||||
const req = makeReq({ body: { name: 'test' } })
|
||||
const res = makeRes()
|
||||
mockService.createCustomMcpServer.mockResolvedValue({ id: 'new-1' })
|
||||
|
||||
await customMcpServersController.createCustomMcpServer(req, res, makeNext())
|
||||
|
||||
expect(mockService.createCustomMcpServer).toHaveBeenCalledWith(expect.objectContaining({ workspaceId: 'ws-1' }), 'org-1')
|
||||
})
|
||||
|
||||
it('should call next on service error', async () => {
|
||||
const req = makeReq({ body: { name: 'test' } })
|
||||
const next = makeNext()
|
||||
const error = new Error('db failure')
|
||||
mockService.createCustomMcpServer.mockRejectedValue(error)
|
||||
|
||||
await customMcpServersController.createCustomMcpServer(req, makeRes(), next)
|
||||
|
||||
expect(next).toHaveBeenCalledWith(error)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAllCustomMcpServers', () => {
|
||||
it('should pass workspace and pagination params to service', async () => {
|
||||
const req = makeReq()
|
||||
const res = makeRes()
|
||||
mockGetPageAndLimitParams.mockReturnValue({ page: 2, limit: 10 })
|
||||
mockService.getAllCustomMcpServers.mockResolvedValue({ data: [], total: 0 })
|
||||
|
||||
await customMcpServersController.getAllCustomMcpServers(req, res, makeNext())
|
||||
|
||||
expect(mockService.getAllCustomMcpServers).toHaveBeenCalledWith('ws-1', 2, 10)
|
||||
expect(res.json).toHaveBeenCalledWith({ data: [], total: 0 })
|
||||
})
|
||||
|
||||
it('should substitute defaults when pagination is absent (-1/-1)', async () => {
|
||||
const req = makeReq()
|
||||
mockGetPageAndLimitParams.mockReturnValue({ page: -1, limit: -1 })
|
||||
mockService.getAllCustomMcpServers.mockResolvedValue({ data: [], total: 0 })
|
||||
|
||||
await customMcpServersController.getAllCustomMcpServers(req, makeRes(), makeNext())
|
||||
|
||||
expect(mockService.getAllCustomMcpServers).toHaveBeenCalledWith('ws-1', 1, 50)
|
||||
})
|
||||
|
||||
it('should clamp oversized limit to the ceiling', async () => {
|
||||
const req = makeReq()
|
||||
mockGetPageAndLimitParams.mockReturnValue({ page: 1, limit: 100000 })
|
||||
mockService.getAllCustomMcpServers.mockResolvedValue({ data: [], total: 0 })
|
||||
|
||||
await customMcpServersController.getAllCustomMcpServers(req, makeRes(), makeNext())
|
||||
|
||||
expect(mockService.getAllCustomMcpServers).toHaveBeenCalledWith('ws-1', 1, 500)
|
||||
})
|
||||
|
||||
it('should call next on service error', async () => {
|
||||
const req = makeReq()
|
||||
const next = makeNext()
|
||||
mockGetPageAndLimitParams.mockReturnValue({ page: 1, limit: 10 })
|
||||
mockService.getAllCustomMcpServers.mockRejectedValue(new Error('fail'))
|
||||
|
||||
await customMcpServersController.getAllCustomMcpServers(req, makeRes(), next)
|
||||
|
||||
expect(next).toHaveBeenCalledWith(expect.any(Error))
|
||||
})
|
||||
})
|
||||
|
||||
describe('authType enum validation', () => {
|
||||
it('rejects create with an unknown authType', async () => {
|
||||
const req = makeReq({ body: { name: 'T', serverUrl: 'https://x.com', authType: 'HAXX' } })
|
||||
const next = makeNext()
|
||||
await customMcpServersController.createCustomMcpServer(req, makeRes(), next)
|
||||
expect(next).toHaveBeenCalledWith(expect.objectContaining({ statusCode: StatusCodes.BAD_REQUEST }))
|
||||
expect(mockService.createCustomMcpServer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects update with an unknown authType', async () => {
|
||||
const req = makeReq({ params: { id: 'mcp-1' } as any, body: { authType: 'HAXX' } })
|
||||
const next = makeNext()
|
||||
await customMcpServersController.updateCustomMcpServer(req, makeRes(), next)
|
||||
expect(next).toHaveBeenCalledWith(expect.objectContaining({ statusCode: StatusCodes.BAD_REQUEST }))
|
||||
expect(mockService.updateCustomMcpServer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts create with a valid authType (NONE)', async () => {
|
||||
const req = makeReq({ body: { name: 'T', serverUrl: 'https://x.com', authType: 'NONE' } })
|
||||
mockService.createCustomMcpServer.mockResolvedValue({} as any)
|
||||
await customMcpServersController.createCustomMcpServer(req, makeRes(), makeNext())
|
||||
expect(mockService.createCustomMcpServer).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCustomMcpServerById', () => {
|
||||
it('should return error when id is not provided', async () => {
|
||||
const req = makeReq({ params: {} as any })
|
||||
const next = makeNext()
|
||||
|
||||
await customMcpServersController.getCustomMcpServerById(req, makeRes(), next)
|
||||
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
statusCode: StatusCodes.PRECONDITION_FAILED
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should return error when workspace is not found', async () => {
|
||||
const req = makeReq({
|
||||
params: { id: 'mcp-1' } as any,
|
||||
user: { activeWorkspaceId: undefined } as any
|
||||
})
|
||||
const next = makeNext()
|
||||
|
||||
await customMcpServersController.getCustomMcpServerById(req, makeRes(), next)
|
||||
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
statusCode: StatusCodes.NOT_FOUND
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should call service with id and workspaceId', async () => {
|
||||
const req = makeReq({ params: { id: 'mcp-1' } as any })
|
||||
const res = makeRes()
|
||||
const mockResponse = { id: 'mcp-1', name: 'Test' }
|
||||
mockService.getCustomMcpServerById.mockResolvedValue(mockResponse as any)
|
||||
|
||||
await customMcpServersController.getCustomMcpServerById(req, res, makeNext())
|
||||
|
||||
expect(mockService.getCustomMcpServerById).toHaveBeenCalledWith('mcp-1', 'ws-1')
|
||||
expect(res.json).toHaveBeenCalledWith(mockResponse)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateCustomMcpServer', () => {
|
||||
it('should return error when id is not provided', async () => {
|
||||
const req = makeReq({ params: {} as any, body: { name: 'updated' } })
|
||||
const next = makeNext()
|
||||
|
||||
await customMcpServersController.updateCustomMcpServer(req, makeRes(), next)
|
||||
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
statusCode: StatusCodes.PRECONDITION_FAILED
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should return error when body is not provided', async () => {
|
||||
const req = makeReq({ params: { id: 'mcp-1' } as any, body: undefined })
|
||||
const next = makeNext()
|
||||
|
||||
await customMcpServersController.updateCustomMcpServer(req, makeRes(), next)
|
||||
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
statusCode: StatusCodes.PRECONDITION_FAILED
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should return error when workspace is not found', async () => {
|
||||
const req = makeReq({
|
||||
params: { id: 'mcp-1' } as any,
|
||||
body: { name: 'updated' },
|
||||
user: { activeWorkspaceId: undefined } as any
|
||||
})
|
||||
const next = makeNext()
|
||||
|
||||
await customMcpServersController.updateCustomMcpServer(req, makeRes(), next)
|
||||
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
statusCode: StatusCodes.NOT_FOUND
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should only pass allowlisted fields to service', async () => {
|
||||
const body = {
|
||||
name: 'Updated',
|
||||
serverUrl: 'https://new-url.com',
|
||||
iconSrc: 'new-icon.png',
|
||||
color: '#000',
|
||||
authType: 'CUSTOM_HEADERS',
|
||||
authConfig: { headers: { 'X-Key': 'val' } },
|
||||
id: 'should-be-stripped',
|
||||
workspaceId: 'should-be-stripped',
|
||||
status: 'should-be-stripped'
|
||||
}
|
||||
const req = makeReq({ params: { id: 'mcp-1' } as any, body })
|
||||
const res = makeRes()
|
||||
mockService.updateCustomMcpServer.mockResolvedValue({ id: 'mcp-1' })
|
||||
|
||||
await customMcpServersController.updateCustomMcpServer(req, res, makeNext())
|
||||
|
||||
expect(mockService.updateCustomMcpServer).toHaveBeenCalledWith(
|
||||
'mcp-1',
|
||||
{
|
||||
name: 'Updated',
|
||||
serverUrl: 'https://new-url.com',
|
||||
iconSrc: 'new-icon.png',
|
||||
color: '#000',
|
||||
authType: 'CUSTOM_HEADERS',
|
||||
authConfig: { headers: { 'X-Key': 'val' } }
|
||||
},
|
||||
'ws-1'
|
||||
)
|
||||
expect(res.json).toHaveBeenCalledWith({ id: 'mcp-1' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteCustomMcpServer', () => {
|
||||
it('should return error when id is not provided', async () => {
|
||||
const req = makeReq({ params: {} as any })
|
||||
const next = makeNext()
|
||||
|
||||
await customMcpServersController.deleteCustomMcpServer(req, makeRes(), next)
|
||||
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
statusCode: StatusCodes.PRECONDITION_FAILED
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should return error when workspace is not found', async () => {
|
||||
const req = makeReq({
|
||||
params: { id: 'mcp-1' } as any,
|
||||
user: { activeWorkspaceId: undefined } as any
|
||||
})
|
||||
const next = makeNext()
|
||||
|
||||
await customMcpServersController.deleteCustomMcpServer(req, makeRes(), next)
|
||||
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
statusCode: StatusCodes.NOT_FOUND
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should call service with id and workspaceId', async () => {
|
||||
const req = makeReq({ params: { id: 'mcp-1' } as any })
|
||||
const res = makeRes()
|
||||
mockService.deleteCustomMcpServer.mockResolvedValue({ affected: 1 })
|
||||
|
||||
await customMcpServersController.deleteCustomMcpServer(req, res, makeNext())
|
||||
|
||||
expect(mockService.deleteCustomMcpServer).toHaveBeenCalledWith('mcp-1', 'ws-1')
|
||||
expect(res.json).toHaveBeenCalledWith({ affected: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('authorizeCustomMcpServer', () => {
|
||||
it('should return error when id is not provided', async () => {
|
||||
const req = makeReq({ params: {} as any })
|
||||
const next = makeNext()
|
||||
|
||||
await customMcpServersController.authorizeCustomMcpServer(req, makeRes(), next)
|
||||
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
statusCode: StatusCodes.PRECONDITION_FAILED
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should return error when workspace is not found', async () => {
|
||||
const req = makeReq({
|
||||
params: { id: 'mcp-1' } as any,
|
||||
user: { activeWorkspaceId: undefined } as any
|
||||
})
|
||||
const next = makeNext()
|
||||
|
||||
await customMcpServersController.authorizeCustomMcpServer(req, makeRes(), next)
|
||||
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
statusCode: StatusCodes.NOT_FOUND
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should call service with id and workspaceId', async () => {
|
||||
const req = makeReq({ params: { id: 'mcp-1' } as any })
|
||||
const res = makeRes()
|
||||
mockService.authorizeCustomMcpServer.mockResolvedValue({ id: 'mcp-1', status: 'AUTHORIZED' })
|
||||
|
||||
await customMcpServersController.authorizeCustomMcpServer(req, res, makeNext())
|
||||
|
||||
expect(mockService.authorizeCustomMcpServer).toHaveBeenCalledWith('mcp-1', 'ws-1')
|
||||
expect(res.json).toHaveBeenCalledWith({ id: 'mcp-1', status: 'AUTHORIZED' })
|
||||
})
|
||||
|
||||
it('should call next on service error', async () => {
|
||||
const req = makeReq({ params: { id: 'mcp-1' } as any })
|
||||
const next = makeNext()
|
||||
mockService.authorizeCustomMcpServer.mockRejectedValue(new Error('connection failed'))
|
||||
|
||||
await customMcpServersController.authorizeCustomMcpServer(req, makeRes(), next)
|
||||
|
||||
expect(next).toHaveBeenCalledWith(expect.any(Error))
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDiscoveredTools', () => {
|
||||
it('should return error when id is not provided', async () => {
|
||||
const req = makeReq({ params: {} as any })
|
||||
const next = makeNext()
|
||||
|
||||
await customMcpServersController.getDiscoveredTools(req, makeRes(), next)
|
||||
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
statusCode: StatusCodes.PRECONDITION_FAILED
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should return error when workspace is not found', async () => {
|
||||
const req = makeReq({
|
||||
params: { id: 'mcp-1' } as any,
|
||||
user: { activeWorkspaceId: undefined } as any
|
||||
})
|
||||
const next = makeNext()
|
||||
|
||||
await customMcpServersController.getDiscoveredTools(req, makeRes(), next)
|
||||
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
statusCode: StatusCodes.NOT_FOUND
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should call service with id and workspaceId', async () => {
|
||||
const req = makeReq({ params: { id: 'mcp-1' } as any })
|
||||
const res = makeRes()
|
||||
const tools = [
|
||||
{ name: 'tool1', description: 'description1', inputSchema: null },
|
||||
{ name: 'tool2', description: 'description2', inputSchema: null }
|
||||
]
|
||||
mockService.getDiscoveredTools.mockResolvedValue(tools)
|
||||
|
||||
await customMcpServersController.getDiscoveredTools(req, res, makeNext())
|
||||
|
||||
expect(mockService.getDiscoveredTools).toHaveBeenCalledWith('mcp-1', 'ws-1')
|
||||
expect(res.json).toHaveBeenCalledWith(tools)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,218 @@
|
||||
import { NextFunction, Request, Response } from 'express'
|
||||
import { StatusCodes } from 'http-status-codes'
|
||||
import { InternalFlowiseError } from '../../errors/internalFlowiseError'
|
||||
import { CustomMcpServerAuthType } from '../../Interface'
|
||||
import customMcpServersService from '../../services/custom-mcp-servers'
|
||||
import { getPageAndLimitParams } from '../../utils/pagination'
|
||||
|
||||
const MAX_PAGE_LIMIT = 500
|
||||
const DEFAULT_PAGE = 1
|
||||
const DEFAULT_LIMIT = 50
|
||||
|
||||
const assertValidAuthType = (authType: unknown, endpoint: string): void => {
|
||||
if (authType === undefined) return
|
||||
const allowed = Object.values(CustomMcpServerAuthType) as string[]
|
||||
if (typeof authType !== 'string' || !allowed.includes(authType)) {
|
||||
throw new InternalFlowiseError(
|
||||
StatusCodes.BAD_REQUEST,
|
||||
`Error: customMcpServersController.${endpoint} - invalid authType "${String(authType)}"`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const createCustomMcpServer = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
if (!req.body) {
|
||||
throw new InternalFlowiseError(
|
||||
StatusCodes.PRECONDITION_FAILED,
|
||||
`Error: customMcpServersController.createCustomMcpServer - body not provided!`
|
||||
)
|
||||
}
|
||||
const orgId = req.user?.activeOrganizationId
|
||||
if (!orgId) {
|
||||
throw new InternalFlowiseError(
|
||||
StatusCodes.NOT_FOUND,
|
||||
`Error: customMcpServersController.createCustomMcpServer - organization not found!`
|
||||
)
|
||||
}
|
||||
const workspaceId = req.user?.activeWorkspaceId
|
||||
if (!workspaceId) {
|
||||
throw new InternalFlowiseError(
|
||||
StatusCodes.NOT_FOUND,
|
||||
`Error: customMcpServersController.createCustomMcpServer - workspace not found!`
|
||||
)
|
||||
}
|
||||
const body = req.body
|
||||
assertValidAuthType(body.authType, 'createCustomMcpServer')
|
||||
// Explicit allowlist — id/workspaceId/timestamps must not be overrideable by client
|
||||
const mcpBody: Record<string, unknown> = {}
|
||||
if (body.name !== undefined) mcpBody.name = body.name
|
||||
if (body.serverUrl !== undefined) mcpBody.serverUrl = body.serverUrl
|
||||
if (body.iconSrc !== undefined) mcpBody.iconSrc = body.iconSrc
|
||||
if (body.color !== undefined) mcpBody.color = body.color
|
||||
if (body.authType !== undefined) mcpBody.authType = body.authType
|
||||
if (body.authConfig !== undefined) mcpBody.authConfig = body.authConfig
|
||||
mcpBody.workspaceId = workspaceId
|
||||
|
||||
const apiResponse = await customMcpServersService.createCustomMcpServer(mcpBody, orgId)
|
||||
return res.json(apiResponse)
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
const getAllCustomMcpServers = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const workspaceId = req.user?.activeWorkspaceId
|
||||
if (!workspaceId) {
|
||||
throw new InternalFlowiseError(
|
||||
StatusCodes.NOT_FOUND,
|
||||
`Error: customMcpServersController.getAllCustomMcpServers - workspace not found!`
|
||||
)
|
||||
}
|
||||
const raw = getPageAndLimitParams(req)
|
||||
const page = raw.page > 0 ? raw.page : DEFAULT_PAGE
|
||||
const limit = raw.limit > 0 ? Math.min(raw.limit, MAX_PAGE_LIMIT) : DEFAULT_LIMIT
|
||||
const apiResponse = await customMcpServersService.getAllCustomMcpServers(workspaceId, page, limit)
|
||||
return res.json(apiResponse)
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
const getCustomMcpServerById = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
if (typeof req.params === 'undefined' || !req.params.id) {
|
||||
throw new InternalFlowiseError(
|
||||
StatusCodes.PRECONDITION_FAILED,
|
||||
`Error: customMcpServersController.getCustomMcpServerById - id not provided!`
|
||||
)
|
||||
}
|
||||
const workspaceId = req.user?.activeWorkspaceId
|
||||
if (!workspaceId) {
|
||||
throw new InternalFlowiseError(
|
||||
StatusCodes.NOT_FOUND,
|
||||
`Error: customMcpServersController.getCustomMcpServerById - workspace not found!`
|
||||
)
|
||||
}
|
||||
const apiResponse = await customMcpServersService.getCustomMcpServerById(req.params.id, workspaceId)
|
||||
return res.json(apiResponse)
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
const updateCustomMcpServer = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
if (typeof req.params === 'undefined' || !req.params.id) {
|
||||
throw new InternalFlowiseError(
|
||||
StatusCodes.PRECONDITION_FAILED,
|
||||
`Error: customMcpServersController.updateCustomMcpServer - id not provided!`
|
||||
)
|
||||
}
|
||||
if (!req.body) {
|
||||
throw new InternalFlowiseError(
|
||||
StatusCodes.PRECONDITION_FAILED,
|
||||
`Error: customMcpServersController.updateCustomMcpServer - body not provided!`
|
||||
)
|
||||
}
|
||||
const workspaceId = req.user?.activeWorkspaceId
|
||||
if (!workspaceId) {
|
||||
throw new InternalFlowiseError(
|
||||
StatusCodes.NOT_FOUND,
|
||||
`Error: customMcpServersController.updateCustomMcpServer - workspace not found!`
|
||||
)
|
||||
}
|
||||
const body = req.body
|
||||
assertValidAuthType(body.authType, 'updateCustomMcpServer')
|
||||
// Explicit allowlist
|
||||
const mcpBody: Record<string, unknown> = {}
|
||||
if (body.name !== undefined) mcpBody.name = body.name
|
||||
if (body.serverUrl !== undefined) mcpBody.serverUrl = body.serverUrl
|
||||
if (body.iconSrc !== undefined) mcpBody.iconSrc = body.iconSrc
|
||||
if (body.color !== undefined) mcpBody.color = body.color
|
||||
if (body.authType !== undefined) mcpBody.authType = body.authType
|
||||
if (body.authConfig !== undefined) mcpBody.authConfig = body.authConfig
|
||||
|
||||
const apiResponse = await customMcpServersService.updateCustomMcpServer(req.params.id, mcpBody, workspaceId)
|
||||
return res.json(apiResponse)
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteCustomMcpServer = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
if (typeof req.params === 'undefined' || !req.params.id) {
|
||||
throw new InternalFlowiseError(
|
||||
StatusCodes.PRECONDITION_FAILED,
|
||||
`Error: customMcpServersController.deleteCustomMcpServer - id not provided!`
|
||||
)
|
||||
}
|
||||
const workspaceId = req.user?.activeWorkspaceId
|
||||
if (!workspaceId) {
|
||||
throw new InternalFlowiseError(
|
||||
StatusCodes.NOT_FOUND,
|
||||
`Error: customMcpServersController.deleteCustomMcpServer - workspace not found!`
|
||||
)
|
||||
}
|
||||
const apiResponse = await customMcpServersService.deleteCustomMcpServer(req.params.id, workspaceId)
|
||||
return res.json(apiResponse)
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
const authorizeCustomMcpServer = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
if (typeof req.params === 'undefined' || !req.params.id) {
|
||||
throw new InternalFlowiseError(
|
||||
StatusCodes.PRECONDITION_FAILED,
|
||||
`Error: customMcpServersController.authorizeCustomMcpServer - id not provided!`
|
||||
)
|
||||
}
|
||||
const workspaceId = req.user?.activeWorkspaceId
|
||||
if (!workspaceId) {
|
||||
throw new InternalFlowiseError(
|
||||
StatusCodes.NOT_FOUND,
|
||||
`Error: customMcpServersController.authorizeCustomMcpServer - workspace not found!`
|
||||
)
|
||||
}
|
||||
const apiResponse = await customMcpServersService.authorizeCustomMcpServer(req.params.id, workspaceId)
|
||||
return res.json(apiResponse)
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
const getDiscoveredTools = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
if (typeof req.params === 'undefined' || !req.params.id) {
|
||||
throw new InternalFlowiseError(
|
||||
StatusCodes.PRECONDITION_FAILED,
|
||||
`Error: customMcpServersController.getDiscoveredTools - id not provided!`
|
||||
)
|
||||
}
|
||||
const workspaceId = req.user?.activeWorkspaceId
|
||||
if (!workspaceId) {
|
||||
throw new InternalFlowiseError(
|
||||
StatusCodes.NOT_FOUND,
|
||||
`Error: customMcpServersController.getDiscoveredTools - workspace not found!`
|
||||
)
|
||||
}
|
||||
const apiResponse = await customMcpServersService.getDiscoveredTools(req.params.id, workspaceId)
|
||||
return res.json(apiResponse)
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
createCustomMcpServer,
|
||||
getAllCustomMcpServers,
|
||||
getCustomMcpServerById,
|
||||
updateCustomMcpServer,
|
||||
deleteCustomMcpServer,
|
||||
authorizeCustomMcpServer,
|
||||
getDiscoveredTools
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/* eslint-disable */
|
||||
import { Entity, Column, CreateDateColumn, UpdateDateColumn, PrimaryGeneratedColumn } from 'typeorm'
|
||||
import { ICustomMcpServer } from '../../Interface'
|
||||
|
||||
@Entity()
|
||||
export class CustomMcpServer implements ICustomMcpServer {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string
|
||||
|
||||
@Column()
|
||||
name: string
|
||||
|
||||
@Column({ type: 'text' })
|
||||
serverUrl: string
|
||||
|
||||
@Column({ nullable: true })
|
||||
iconSrc?: string
|
||||
|
||||
@Column({ nullable: true })
|
||||
color?: string
|
||||
|
||||
@Column({ default: 'NONE' })
|
||||
authType: string
|
||||
|
||||
@Column({ nullable: true, type: 'text' })
|
||||
authConfig?: string
|
||||
|
||||
@Column({ nullable: true, type: 'text', select: false })
|
||||
tools?: string
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
toolCount: number
|
||||
|
||||
@Column({ default: 'PENDING' })
|
||||
status: string
|
||||
|
||||
@Column({ type: 'timestamp' })
|
||||
@CreateDateColumn()
|
||||
createdDate: Date
|
||||
|
||||
@Column({ type: 'timestamp' })
|
||||
@UpdateDateColumn()
|
||||
updatedDate: Date
|
||||
|
||||
@Column({ nullable: false, type: 'text' })
|
||||
workspaceId: string
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { Evaluator } from './Evaluator'
|
||||
import { ApiKey } from './ApiKey'
|
||||
import { CustomTemplate } from './CustomTemplate'
|
||||
import { Execution } from './Execution'
|
||||
import { CustomMcpServer } from './CustomMcpServer'
|
||||
import { LoginActivity, WorkspaceShared, WorkspaceUsers } from '../../enterprise/database/entities/EnterpriseEntities'
|
||||
import { User } from '../../enterprise/database/entities/user.entity'
|
||||
import { Organization } from '../../enterprise/database/entities/organization.entity'
|
||||
@@ -51,6 +52,7 @@ export const entities = {
|
||||
WorkspaceShared,
|
||||
CustomTemplate,
|
||||
Execution,
|
||||
CustomMcpServer,
|
||||
Organization,
|
||||
Role,
|
||||
OrganizationUser,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm'
|
||||
|
||||
export class AddCustomMcpServer1766000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE IF NOT EXISTS \`custom_mcp_server\` (
|
||||
\`id\` varchar(36) NOT NULL,
|
||||
\`name\` varchar(255) NOT NULL,
|
||||
\`serverUrl\` text NOT NULL,
|
||||
\`iconSrc\` varchar(255),
|
||||
\`color\` varchar(255),
|
||||
\`authType\` varchar(255) NOT NULL DEFAULT 'NONE',
|
||||
\`authConfig\` text,
|
||||
\`tools\` text,
|
||||
\`toolCount\` int NOT NULL DEFAULT 0,
|
||||
\`status\` varchar(255) NOT NULL DEFAULT 'PENDING',
|
||||
\`createdDate\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
\`updatedDate\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||
\`workspaceId\` varchar(36) NOT NULL,
|
||||
PRIMARY KEY (\`id\`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;`
|
||||
)
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX \`IDX_custom_mcp_workspace_updated\` ON \`custom_mcp_server\` (\`workspaceId\`, \`updatedDate\`);`
|
||||
)
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX \`IDX_custom_mcp_workspace_updated\` ON \`custom_mcp_server\``)
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS \`custom_mcp_server\``)
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ import { AddChatFlowNameIndex1759424809984 } from './1759424809984-AddChatFlowNa
|
||||
import { FixDocumentStoreFileChunkLongText1765000000000 } from './1765000000000-FixDocumentStoreFileChunkLongText'
|
||||
import { AddApiKeyPermission1765360298674 } from './1765360298674-AddApiKeyPermission'
|
||||
import { AddReasonContentToChatMessage1764759496768 } from './1764759496768-AddReasonContentToChatMessage'
|
||||
import { AddCustomMcpServer1766000000000 } from './1766000000000-AddCustomMcpServer'
|
||||
import { AddMcpServerConfigToChatFlow1767000000000 } from './1767000000000-AddMcpServerConfigToChatFlow'
|
||||
|
||||
import { AddAuthTables1720230151482 } from '../../../enterprise/database/migrations/mariadb/1720230151482-AddAuthTables'
|
||||
@@ -114,5 +115,6 @@ export const mariadbMigrations = [
|
||||
FixDocumentStoreFileChunkLongText1765000000000,
|
||||
AddApiKeyPermission1765360298674,
|
||||
AddReasonContentToChatMessage1764759496768,
|
||||
AddCustomMcpServer1766000000000,
|
||||
AddMcpServerConfigToChatFlow1767000000000
|
||||
]
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm'
|
||||
|
||||
export class AddCustomMcpServer1766000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE IF NOT EXISTS \`custom_mcp_server\` (
|
||||
\`id\` varchar(36) NOT NULL,
|
||||
\`name\` varchar(255) NOT NULL,
|
||||
\`serverUrl\` text NOT NULL,
|
||||
\`iconSrc\` varchar(255),
|
||||
\`color\` varchar(255),
|
||||
\`authType\` varchar(255) NOT NULL DEFAULT 'NONE',
|
||||
\`authConfig\` text,
|
||||
\`tools\` text,
|
||||
\`toolCount\` int NOT NULL DEFAULT 0,
|
||||
\`status\` varchar(255) NOT NULL DEFAULT 'PENDING',
|
||||
\`createdDate\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
\`updatedDate\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||
\`workspaceId\` varchar(36) NOT NULL,
|
||||
PRIMARY KEY (\`id\`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;`
|
||||
)
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX \`IDX_custom_mcp_workspace_updated\` ON \`custom_mcp_server\` (\`workspaceId\`, \`updatedDate\`);`
|
||||
)
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX \`IDX_custom_mcp_workspace_updated\` ON \`custom_mcp_server\``)
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS \`custom_mcp_server\``)
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ import { AddChatFlowNameIndex1759424828558 } from './1759424828558-AddChatFlowNa
|
||||
import { FixDocumentStoreFileChunkLongText1765000000000 } from './1765000000000-FixDocumentStoreFileChunkLongText'
|
||||
import { AddApiKeyPermission1765360298674 } from './1765360298674-AddApiKeyPermission'
|
||||
import { AddReasonContentToChatMessage1764759496768 } from './1764759496768-AddReasonContentToChatMessage'
|
||||
import { AddCustomMcpServer1766000000000 } from './1766000000000-AddCustomMcpServer'
|
||||
import { AddMcpServerConfigToChatFlow1767000000000 } from './1767000000000-AddMcpServerConfigToChatFlow'
|
||||
|
||||
import { AddAuthTables1720230151482 } from '../../../enterprise/database/migrations/mysql/1720230151482-AddAuthTables'
|
||||
@@ -116,5 +117,6 @@ export const mysqlMigrations = [
|
||||
FixDocumentStoreFileChunkLongText1765000000000,
|
||||
AddApiKeyPermission1765360298674,
|
||||
AddReasonContentToChatMessage1764759496768,
|
||||
AddCustomMcpServer1766000000000,
|
||||
AddMcpServerConfigToChatFlow1767000000000
|
||||
]
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm'
|
||||
|
||||
export class AddCustomMcpServer1766000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE IF NOT EXISTS custom_mcp_server (
|
||||
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"name" varchar NOT NULL,
|
||||
"serverUrl" text NOT NULL,
|
||||
"iconSrc" varchar,
|
||||
"color" varchar,
|
||||
"authType" varchar NOT NULL DEFAULT 'NONE',
|
||||
"authConfig" text,
|
||||
"tools" text,
|
||||
"toolCount" integer NOT NULL DEFAULT 0,
|
||||
"status" varchar NOT NULL DEFAULT 'PENDING',
|
||||
"createdDate" timestamp NOT NULL DEFAULT now(),
|
||||
"updatedDate" timestamp NOT NULL DEFAULT now(),
|
||||
"workspaceId" text NOT NULL,
|
||||
CONSTRAINT "PK_custom_mcp_server_id" PRIMARY KEY (id)
|
||||
);`
|
||||
)
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_custom_mcp_workspace_updated" ON custom_mcp_server ("workspaceId", "updatedDate");`
|
||||
)
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_custom_mcp_workspace_updated"`)
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS custom_mcp_server`)
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ import { AddTextToSpeechToChatFlow1759419194331 } from './1759419194331-AddTextT
|
||||
import { AddChatFlowNameIndex1759424903973 } from './1759424903973-AddChatFlowNameIndex'
|
||||
import { AddApiKeyPermission1765360298674 } from './1765360298674-AddApiKeyPermission'
|
||||
import { AddReasonContentToChatMessage1764759496768 } from './1764759496768-AddReasonContentToChatMessage'
|
||||
import { AddCustomMcpServer1766000000000 } from './1766000000000-AddCustomMcpServer'
|
||||
import { AddMcpServerConfigToChatFlow1767000000000 } from './1767000000000-AddMcpServerConfigToChatFlow'
|
||||
|
||||
import { AddAuthTables1720230151482 } from '../../../enterprise/database/migrations/postgres/1720230151482-AddAuthTables'
|
||||
@@ -112,5 +113,6 @@ export const postgresMigrations = [
|
||||
AddChatFlowNameIndex1759424903973,
|
||||
AddApiKeyPermission1765360298674,
|
||||
AddReasonContentToChatMessage1764759496768,
|
||||
AddCustomMcpServer1766000000000,
|
||||
AddMcpServerConfigToChatFlow1767000000000
|
||||
]
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm'
|
||||
|
||||
export class AddCustomMcpServer1766000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE IF NOT EXISTS "custom_mcp_server" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "serverUrl" text NOT NULL, "iconSrc" varchar, "color" varchar, "authType" varchar NOT NULL DEFAULT ('NONE'), "authConfig" text, "tools" text, "toolCount" integer NOT NULL DEFAULT (0), "status" varchar NOT NULL DEFAULT ('PENDING'), "createdDate" datetime NOT NULL DEFAULT (datetime('now')), "updatedDate" datetime NOT NULL DEFAULT (datetime('now')), "workspaceId" text NOT NULL);`
|
||||
)
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_custom_mcp_workspace_updated" ON "custom_mcp_server" ("workspaceId", "updatedDate");`
|
||||
)
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_custom_mcp_workspace_updated"`)
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "custom_mcp_server"`)
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import { AddTextToSpeechToChatFlow1759419136055 } from './1759419136055-AddTextT
|
||||
import { AddChatFlowNameIndex1759424923093 } from './1759424923093-AddChatFlowNameIndex'
|
||||
import { AddApiKeyPermission1765360298674 } from './1765360298674-AddApiKeyPermission'
|
||||
import { AddReasonContentToChatMessage1764759496768 } from './1764759496768-AddReasonContentToChatMessage'
|
||||
import { AddCustomMcpServer1766000000000 } from './1766000000000-AddCustomMcpServer'
|
||||
import { AddMcpServerConfigToChatFlow1767000000000 } from './1767000000000-AddMcpServerConfigToChatFlow'
|
||||
|
||||
import { AddAuthTables1720230151482 } from '../../../enterprise/database/migrations/sqlite/1720230151482-AddAuthTables'
|
||||
@@ -108,5 +109,6 @@ export const sqliteMigrations = [
|
||||
AddChatFlowNameIndex1759424923093,
|
||||
AddApiKeyPermission1765360298674,
|
||||
AddReasonContentToChatMessage1764759496768,
|
||||
AddCustomMcpServer1766000000000,
|
||||
AddMcpServerConfigToChatFlow1767000000000
|
||||
]
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import express from 'express'
|
||||
import customMcpServersController from '../../controllers/custom-mcp-servers'
|
||||
import { checkAnyPermission, checkPermission } from '../../enterprise/rbac/PermissionCheck'
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
// CREATE
|
||||
router.post('/', checkPermission('tools:create'), customMcpServersController.createCustomMcpServer)
|
||||
|
||||
// READ
|
||||
router.get('/', checkPermission('tools:view'), customMcpServersController.getAllCustomMcpServers)
|
||||
router.get('/:id', checkPermission('tools:view'), customMcpServersController.getCustomMcpServerById)
|
||||
router.get('/:id/tools', checkPermission('tools:view'), customMcpServersController.getDiscoveredTools)
|
||||
|
||||
// UPDATE
|
||||
router.put('/:id', checkAnyPermission('tools:update,tools:create'), customMcpServersController.updateCustomMcpServer)
|
||||
|
||||
// AUTHORIZE (connect to server & discover tools)
|
||||
router.post('/:id/authorize', checkAnyPermission('tools:update,tools:create'), customMcpServersController.authorizeCustomMcpServer)
|
||||
|
||||
// DELETE
|
||||
router.delete('/:id', checkPermission('tools:delete'), customMcpServersController.deleteCustomMcpServer)
|
||||
|
||||
export default router
|
||||
@@ -25,6 +25,7 @@ import leadsRouter from './leads'
|
||||
import loadPromptRouter from './load-prompts'
|
||||
import logsRouter from './log'
|
||||
import marketplacesRouter from './marketplaces'
|
||||
import customMcpServersRouter from './custom-mcp-servers'
|
||||
import nodeConfigRouter from './node-configs'
|
||||
import nodeCustomFunctionRouter from './node-custom-functions'
|
||||
import nodeIconRouter from './node-icons'
|
||||
@@ -126,6 +127,7 @@ router.use('/executions', executionsRouter)
|
||||
router.use('/validation', validationRouter)
|
||||
router.use('/agentflowv2-generator', agentflowv2GeneratorRouter)
|
||||
router.use('/text-to-speech', textToSpeechRouter)
|
||||
router.use('/custom-mcp-servers', customMcpServersRouter)
|
||||
router.use('/mcp-server', mcpServerRouter)
|
||||
router.use('/mcp', mcpEndpointRouter)
|
||||
|
||||
|
||||
@@ -0,0 +1,770 @@
|
||||
import { StatusCodes } from 'http-status-codes'
|
||||
import { InternalFlowiseError } from '../../errors/internalFlowiseError'
|
||||
import { FLOWISE_COUNTER_STATUS, FLOWISE_METRIC_COUNTERS } from '../../Interface.Metrics'
|
||||
|
||||
// typeorm is not directly resolvable under pnpm strict hoisting; provide a
|
||||
// virtual mock so entity decorators are no-ops in this test context.
|
||||
jest.mock(
|
||||
'typeorm',
|
||||
() => {
|
||||
const noop = () => (_target: any, _key?: any) => {}
|
||||
return {
|
||||
Entity: () => noop(),
|
||||
Column: () => noop(),
|
||||
PrimaryGeneratedColumn: () => noop(),
|
||||
CreateDateColumn: () => noop(),
|
||||
UpdateDateColumn: () => noop(),
|
||||
ManyToOne: () => noop(),
|
||||
OneToMany: () => noop(),
|
||||
JoinColumn: () => noop(),
|
||||
Index: () => noop(),
|
||||
Unique: () => noop()
|
||||
}
|
||||
},
|
||||
{ virtual: true }
|
||||
)
|
||||
|
||||
// Import after the virtual mock is registered
|
||||
import { CustomMcpServerStatus } from '../../Interface'
|
||||
|
||||
// ── Mocks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockSave = jest.fn()
|
||||
const mockCreate = jest.fn()
|
||||
const mockDelete = jest.fn()
|
||||
const mockFindOneBy = jest.fn()
|
||||
const mockMerge = jest.fn()
|
||||
const mockGetManyAndCount = jest.fn()
|
||||
const mockGetOne = jest.fn()
|
||||
const mockQueryBuilder = {
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: mockGetManyAndCount,
|
||||
getOne: mockGetOne
|
||||
}
|
||||
const mockGetRepository = jest.fn().mockReturnValue({
|
||||
save: mockSave,
|
||||
create: mockCreate,
|
||||
delete: mockDelete,
|
||||
findOneBy: mockFindOneBy,
|
||||
merge: mockMerge,
|
||||
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder)
|
||||
})
|
||||
|
||||
const mockSendTelemetry = jest.fn()
|
||||
const mockIncrementCounter = jest.fn()
|
||||
|
||||
jest.mock('../../utils/getRunningExpressApp', () => ({
|
||||
getRunningExpressApp: jest.fn(() => ({
|
||||
AppDataSource: { getRepository: mockGetRepository },
|
||||
telemetry: { sendTelemetry: mockSendTelemetry },
|
||||
metricsProvider: { incrementCounter: mockIncrementCounter }
|
||||
}))
|
||||
}))
|
||||
|
||||
jest.mock('../../utils', () => ({
|
||||
encryptCredentialData: jest.fn((data: any) => Promise.resolve(`encrypted:${JSON.stringify(data)}`)),
|
||||
decryptCredentialData: jest.fn((data: string) => {
|
||||
if (data.startsWith('encrypted:')) return Promise.resolve(JSON.parse(data.slice('encrypted:'.length)))
|
||||
return Promise.resolve(data)
|
||||
}),
|
||||
getAppVersion: jest.fn(() => Promise.resolve('1.0.0'))
|
||||
}))
|
||||
|
||||
const mockInitialize = jest.fn()
|
||||
const mockClose = jest.fn()
|
||||
const mockCheckDenyList = jest.fn().mockResolvedValue(undefined)
|
||||
const mockValidateCustomHeaders = jest.fn()
|
||||
const mockToolkitImpl = jest.fn().mockImplementation(() => ({
|
||||
initialize: mockInitialize,
|
||||
_tools: { tools: [{ name: 'tool1' }] },
|
||||
client: { close: mockClose }
|
||||
}))
|
||||
jest.mock('flowise-components', () => ({
|
||||
MCPToolkit: mockToolkitImpl,
|
||||
checkDenyList: (url: string) => mockCheckDenyList(url),
|
||||
isValidURL: (url: string) => {
|
||||
try {
|
||||
new URL(url)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
validateCustomHeaders: (headers: Record<string, string>) => mockValidateCustomHeaders(headers)
|
||||
}))
|
||||
|
||||
jest.mock('../../utils/logger', () => ({
|
||||
__esModule: true,
|
||||
default: { warn: jest.fn(), info: jest.fn(), error: jest.fn(), debug: jest.fn() }
|
||||
}))
|
||||
|
||||
import customMcpServersService from './index'
|
||||
import { encryptCredentialData, decryptCredentialData } from '../../utils'
|
||||
import { MCPToolkit } from 'flowise-components'
|
||||
|
||||
const mockEncrypt = encryptCredentialData as jest.Mock
|
||||
const mockDecrypt = decryptCredentialData as jest.Mock
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeRecord = (overrides: Record<string, any> = {}) => ({
|
||||
id: 'mcp-1',
|
||||
name: 'Test Server',
|
||||
serverUrl: 'https://api.example.com/mcp/token123',
|
||||
iconSrc: null,
|
||||
color: null,
|
||||
authType: 'NONE',
|
||||
authConfig: null,
|
||||
tools: null,
|
||||
toolCount: 0,
|
||||
status: 'PENDING',
|
||||
enabled: true,
|
||||
createdDate: new Date('2025-01-01'),
|
||||
updatedDate: new Date('2025-01-01'),
|
||||
workspaceId: 'ws-1',
|
||||
...overrides
|
||||
})
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('customMcpServersService', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockCreate.mockImplementation((entity: any) => entity)
|
||||
})
|
||||
|
||||
// ── createCustomMcpServer ───────────────────────────────────────────
|
||||
|
||||
describe('createCustomMcpServer', () => {
|
||||
it('should create a record and return db response', async () => {
|
||||
const saved = makeRecord()
|
||||
mockSave.mockResolvedValue(saved)
|
||||
|
||||
const result = await customMcpServersService.createCustomMcpServer(
|
||||
{ name: 'Test Server', serverUrl: 'https://api.example.com' },
|
||||
'org-1'
|
||||
)
|
||||
|
||||
expect(mockSave).toHaveBeenCalled()
|
||||
expect(mockSendTelemetry).toHaveBeenCalledWith(
|
||||
'custom_mcp_server_created',
|
||||
expect.objectContaining({ version: '1.0.0', toolId: 'mcp-1', toolName: 'Test Server' }),
|
||||
'org-1'
|
||||
)
|
||||
expect(mockIncrementCounter).toHaveBeenCalledWith(FLOWISE_METRIC_COUNTERS.CUSTOM_MCP_SERVER_CREATED, {
|
||||
status: FLOWISE_COUNTER_STATUS.SUCCESS
|
||||
})
|
||||
// createCustomMcpServer returns a sanitized response: serverUrl is masked and authConfig is stripped
|
||||
const { authConfig: _authConfig, ...savedWithoutAuthConfig } = saved
|
||||
expect(result).toEqual({ ...savedWithoutAuthConfig, serverUrl: 'https://api.example.com/************' })
|
||||
})
|
||||
|
||||
it('should encrypt authConfig when it is an object', async () => {
|
||||
const saved = makeRecord()
|
||||
mockSave.mockResolvedValue(saved)
|
||||
|
||||
await customMcpServersService.createCustomMcpServer(
|
||||
{ name: 'Test', serverUrl: 'https://example.com', authConfig: { headers: { 'X-Key': 'secret' } } },
|
||||
'org-1'
|
||||
)
|
||||
|
||||
expect(mockEncrypt).toHaveBeenCalledWith({ headers: { 'X-Key': 'secret' } })
|
||||
})
|
||||
|
||||
it('should not encrypt authConfig when it is not an object', async () => {
|
||||
const saved = makeRecord()
|
||||
mockSave.mockResolvedValue(saved)
|
||||
|
||||
await customMcpServersService.createCustomMcpServer(
|
||||
{ name: 'Test', serverUrl: 'https://example.com', authConfig: null },
|
||||
'org-1'
|
||||
)
|
||||
|
||||
expect(mockEncrypt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should validate serverUrl and throw for invalid URL', async () => {
|
||||
await expect(customMcpServersService.createCustomMcpServer({ name: 'Bad', serverUrl: 'not-a-url' }, 'org-1')).rejects.toThrow(
|
||||
InternalFlowiseError
|
||||
)
|
||||
|
||||
await expect(customMcpServersService.createCustomMcpServer({ name: 'Bad', serverUrl: 'not-a-url' }, 'org-1')).rejects.toThrow(
|
||||
'not a valid URL'
|
||||
)
|
||||
})
|
||||
|
||||
it('should validate serverUrl and throw for non-http protocol', async () => {
|
||||
await expect(
|
||||
customMcpServersService.createCustomMcpServer({ name: 'Bad', serverUrl: 'ftp://example.com' }, 'org-1')
|
||||
).rejects.toThrow('only http and https are allowed')
|
||||
})
|
||||
|
||||
it('should re-throw InternalFlowiseError as-is', async () => {
|
||||
const specificError = new InternalFlowiseError(StatusCodes.BAD_REQUEST, 'custom error')
|
||||
mockSave.mockRejectedValue(specificError)
|
||||
|
||||
await expect(
|
||||
customMcpServersService.createCustomMcpServer({ name: 'Test', serverUrl: 'https://example.com' }, 'org-1')
|
||||
).rejects.toThrow(specificError)
|
||||
})
|
||||
|
||||
it('should wrap unknown errors in InternalFlowiseError', async () => {
|
||||
mockSave.mockRejectedValue(new Error('db crash'))
|
||||
|
||||
await expect(
|
||||
customMcpServersService.createCustomMcpServer({ name: 'Test', serverUrl: 'https://example.com' }, 'org-1')
|
||||
).rejects.toThrow(InternalFlowiseError)
|
||||
})
|
||||
})
|
||||
|
||||
// ── getAllCustomMcpServers ───────────────────────────────────────────
|
||||
|
||||
describe('getAllCustomMcpServers', () => {
|
||||
it('should return sanitized array when no pagination', async () => {
|
||||
const records = [
|
||||
makeRecord({ id: '1', serverUrl: 'https://api.example.com/mcp/token1', authConfig: 'encrypted-data' }),
|
||||
makeRecord({ id: '2', serverUrl: 'https://other.com', authConfig: null })
|
||||
]
|
||||
mockGetManyAndCount.mockResolvedValue([records, 2])
|
||||
|
||||
const result = await customMcpServersService.getAllCustomMcpServers('ws-1')
|
||||
|
||||
expect(result).toBeInstanceOf(Array)
|
||||
const arr = result as any[]
|
||||
expect(arr).toHaveLength(2)
|
||||
// serverUrl should be masked
|
||||
expect(arr[0].serverUrl).toContain('************')
|
||||
expect(arr[0].serverUrl).not.toContain('token1')
|
||||
// authConfig should be stripped
|
||||
expect(arr[0]).not.toHaveProperty('authConfig')
|
||||
})
|
||||
|
||||
it('should return paginated result when page and limit are positive', async () => {
|
||||
const records = [makeRecord()]
|
||||
mockGetManyAndCount.mockResolvedValue([records, 10])
|
||||
|
||||
const result = await customMcpServersService.getAllCustomMcpServers('ws-1', 2, 5)
|
||||
|
||||
expect(mockQueryBuilder.skip).toHaveBeenCalledWith(5) // (2-1)*5
|
||||
expect(mockQueryBuilder.take).toHaveBeenCalledWith(5)
|
||||
expect(result).toHaveProperty('data')
|
||||
expect(result).toHaveProperty('total', 10)
|
||||
})
|
||||
|
||||
it('should filter by workspaceId when provided', async () => {
|
||||
mockGetManyAndCount.mockResolvedValue([[], 0])
|
||||
|
||||
await customMcpServersService.getAllCustomMcpServers('ws-1')
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('custom_mcp_server.workspaceId = :workspaceId', {
|
||||
workspaceId: 'ws-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('should mask serverUrl with path segments', async () => {
|
||||
const records = [makeRecord({ serverUrl: 'https://api.example.com/mcp/secret-token' })]
|
||||
mockGetManyAndCount.mockResolvedValue([records, 1])
|
||||
|
||||
const result = (await customMcpServersService.getAllCustomMcpServers('ws-1')) as any[]
|
||||
|
||||
expect(result[0].serverUrl).toBe('https://api.example.com/************')
|
||||
})
|
||||
|
||||
it('should return origin only for root-path URLs', async () => {
|
||||
const records = [makeRecord({ serverUrl: 'https://api.example.com' })]
|
||||
mockGetManyAndCount.mockResolvedValue([records, 1])
|
||||
|
||||
const result = (await customMcpServersService.getAllCustomMcpServers('ws-1')) as any[]
|
||||
|
||||
expect(result[0].serverUrl).toBe('https://api.example.com')
|
||||
})
|
||||
|
||||
it('should return redacted value for invalid URLs', async () => {
|
||||
const records = [makeRecord({ serverUrl: 'not-a-url' })]
|
||||
mockGetManyAndCount.mockResolvedValue([records, 1])
|
||||
|
||||
const result = (await customMcpServersService.getAllCustomMcpServers('ws-1')) as any[]
|
||||
|
||||
expect(result[0].serverUrl).toBe('************')
|
||||
})
|
||||
|
||||
it('should wrap errors in InternalFlowiseError', async () => {
|
||||
mockGetManyAndCount.mockRejectedValue(new Error('query failed'))
|
||||
|
||||
await expect(customMcpServersService.getAllCustomMcpServers('ws-1')).rejects.toThrow(InternalFlowiseError)
|
||||
})
|
||||
})
|
||||
|
||||
// ── getCustomMcpServerById ──────────────────────────────────────────
|
||||
|
||||
describe('getCustomMcpServerById', () => {
|
||||
it('should return masked response without authConfig when no authConfig exists', async () => {
|
||||
mockGetOne.mockResolvedValue(makeRecord({ authConfig: null }))
|
||||
|
||||
const result = await customMcpServersService.getCustomMcpServerById('mcp-1', 'ws-1')
|
||||
|
||||
expect(mockQueryBuilder.addSelect).toHaveBeenCalledWith('custom_mcp_server.tools')
|
||||
expect(result.serverUrl).toContain('************')
|
||||
expect(result.authConfig).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return masked headers when authConfig has headers', async () => {
|
||||
const encrypted = 'encrypted:' + JSON.stringify({ headers: { 'X-Api-Key': 'real-secret', Authorization: 'Bearer tok' } })
|
||||
mockGetOne.mockResolvedValue(makeRecord({ authConfig: encrypted }))
|
||||
|
||||
const result = await customMcpServersService.getCustomMcpServerById('mcp-1', 'ws-1')
|
||||
|
||||
expect(result.authConfig).toBeDefined()
|
||||
expect(result.authConfig!.headers['X-Api-Key']).toBe('************')
|
||||
expect(result.authConfig!.headers['Authorization']).toBe('************')
|
||||
})
|
||||
|
||||
it('should return empty authConfig when decryption fails', async () => {
|
||||
mockGetOne.mockResolvedValue(makeRecord({ authConfig: 'bad-encrypted-data' }))
|
||||
mockDecrypt.mockRejectedValueOnce(new Error('decrypt fail'))
|
||||
|
||||
const result = await customMcpServersService.getCustomMcpServerById('mcp-1', 'ws-1')
|
||||
|
||||
expect(result.authConfig).toEqual({})
|
||||
})
|
||||
|
||||
it('should return empty authConfig when decrypted value is not an object', async () => {
|
||||
mockGetOne.mockResolvedValue(makeRecord({ authConfig: 'some-data' }))
|
||||
mockDecrypt.mockResolvedValueOnce(null)
|
||||
|
||||
const result = await customMcpServersService.getCustomMcpServerById('mcp-1', 'ws-1')
|
||||
|
||||
expect(result.authConfig).toEqual({})
|
||||
})
|
||||
|
||||
it('should throw NOT_FOUND when record does not exist', async () => {
|
||||
mockGetOne.mockResolvedValue(null)
|
||||
|
||||
await expect(customMcpServersService.getCustomMcpServerById('mcp-1', 'ws-1')).rejects.toThrow(InternalFlowiseError)
|
||||
await expect(customMcpServersService.getCustomMcpServerById('mcp-1', 'ws-1')).rejects.toThrow('not found')
|
||||
})
|
||||
|
||||
it('should wrap unknown errors in InternalFlowiseError', async () => {
|
||||
mockGetOne.mockRejectedValue(new Error('db error'))
|
||||
|
||||
await expect(customMcpServersService.getCustomMcpServerById('mcp-1', 'ws-1')).rejects.toThrow(InternalFlowiseError)
|
||||
})
|
||||
})
|
||||
|
||||
// ── updateCustomMcpServer ───────────────────────────────────────────
|
||||
|
||||
describe('updateCustomMcpServer', () => {
|
||||
it('should update and return the record', async () => {
|
||||
const existing = makeRecord()
|
||||
mockFindOneBy.mockResolvedValue(existing)
|
||||
mockSave.mockResolvedValue({ ...existing, name: 'Updated' })
|
||||
|
||||
const result = await customMcpServersService.updateCustomMcpServer('mcp-1', { name: 'Updated' }, 'ws-1')
|
||||
|
||||
expect(mockFindOneBy).toHaveBeenCalledWith({ id: 'mcp-1', workspaceId: 'ws-1' })
|
||||
expect(mockMerge).toHaveBeenCalled()
|
||||
expect(mockSave).toHaveBeenCalled()
|
||||
expect(result.name).toBe('Updated')
|
||||
})
|
||||
|
||||
it('should throw NOT_FOUND when record does not exist', async () => {
|
||||
mockFindOneBy.mockResolvedValue(null)
|
||||
|
||||
await expect(customMcpServersService.updateCustomMcpServer('mcp-1', { name: 'X' }, 'ws-1')).rejects.toThrow('not found')
|
||||
})
|
||||
|
||||
it('should preserve real serverUrl when client sends masked value', async () => {
|
||||
const existing = makeRecord({ serverUrl: 'https://real.example.com/secret' })
|
||||
mockFindOneBy.mockResolvedValue(existing)
|
||||
mockSave.mockImplementation((r: any) => Promise.resolve(r))
|
||||
|
||||
const body = { serverUrl: 'https://real.example.com/************' }
|
||||
await customMcpServersService.updateCustomMcpServer('mcp-1', body, 'ws-1')
|
||||
|
||||
// The body.serverUrl should have been replaced with the real one
|
||||
expect(body.serverUrl).toBe('https://real.example.com/secret')
|
||||
})
|
||||
|
||||
it('should validate new serverUrl when client sends a non-masked value', async () => {
|
||||
const existing = makeRecord()
|
||||
mockFindOneBy.mockResolvedValue(existing)
|
||||
|
||||
await expect(customMcpServersService.updateCustomMcpServer('mcp-1', { serverUrl: 'ftp://bad.com' }, 'ws-1')).rejects.toThrow(
|
||||
'only http and https are allowed'
|
||||
)
|
||||
})
|
||||
|
||||
it('should reject URL that partially contains the mask placeholder', async () => {
|
||||
// e.g. user edited the URL but left `************` somewhere in it —
|
||||
// the backend used to silently revert; we now reject explicitly.
|
||||
const existing = makeRecord({ serverUrl: 'https://real.example.com/secret' })
|
||||
mockFindOneBy.mockResolvedValue(existing)
|
||||
|
||||
await expect(
|
||||
customMcpServersService.updateCustomMcpServer(
|
||||
'mcp-1',
|
||||
{ serverUrl: 'https://typo-host.com/************' }, // new host but mask still present
|
||||
'ws-1'
|
||||
)
|
||||
).rejects.toThrow(/masked placeholder/)
|
||||
})
|
||||
|
||||
it('should encrypt authConfig on update', async () => {
|
||||
const existing = makeRecord()
|
||||
mockFindOneBy.mockResolvedValue(existing)
|
||||
mockSave.mockImplementation((r: any) => Promise.resolve(r))
|
||||
|
||||
await customMcpServersService.updateCustomMcpServer('mcp-1', { authConfig: { headers: { 'X-Key': 'new' } } }, 'ws-1')
|
||||
|
||||
expect(mockEncrypt).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should merge redacted headers with existing encrypted values', async () => {
|
||||
const encryptedConfig = 'encrypted:' + JSON.stringify({ headers: { 'X-Key': 'real-secret' } })
|
||||
const existing = makeRecord({ authConfig: encryptedConfig })
|
||||
mockFindOneBy.mockResolvedValue(existing)
|
||||
mockSave.mockImplementation((r: any) => Promise.resolve(r))
|
||||
|
||||
const body = { authConfig: { headers: { 'X-Key': '************' } } }
|
||||
await customMcpServersService.updateCustomMcpServer('mcp-1', body, 'ws-1')
|
||||
|
||||
// encryptCredentialData should have received the real secret, not the redacted value
|
||||
expect(mockEncrypt).toHaveBeenCalledWith(expect.objectContaining({ headers: { 'X-Key': 'real-secret' } }))
|
||||
})
|
||||
|
||||
it('should reject header value that partially contains the mask placeholder', async () => {
|
||||
const encryptedConfig = 'encrypted:' + JSON.stringify({ headers: { 'X-Key': 'real-secret' } })
|
||||
const existing = makeRecord({ authConfig: encryptedConfig })
|
||||
mockFindOneBy.mockResolvedValue(existing)
|
||||
mockSave.mockImplementation((r: any) => Promise.resolve(r))
|
||||
|
||||
// User edited the value but left `************` in — not exactly the
|
||||
// placeholder, so we reject rather than persisting the literal string.
|
||||
const body = { authType: 'CUSTOM_HEADERS', authConfig: { headers: { 'X-Key': 'prefix-************' } } }
|
||||
await expect(customMcpServersService.updateCustomMcpServer('mcp-1', body, 'ws-1')).rejects.toThrow(/masked placeholder/)
|
||||
})
|
||||
|
||||
it('should use incoming header value when not redacted', async () => {
|
||||
const encryptedConfig = 'encrypted:' + JSON.stringify({ headers: { 'X-Key': 'old-secret' } })
|
||||
const existing = makeRecord({ authConfig: encryptedConfig })
|
||||
mockFindOneBy.mockResolvedValue(existing)
|
||||
mockSave.mockImplementation((r: any) => Promise.resolve(r))
|
||||
|
||||
const body = { authConfig: { headers: { 'X-Key': 'brand-new-secret' } } }
|
||||
await customMcpServersService.updateCustomMcpServer('mcp-1', body, 'ws-1')
|
||||
|
||||
expect(mockEncrypt).toHaveBeenCalledWith(expect.objectContaining({ headers: { 'X-Key': 'brand-new-secret' } }))
|
||||
})
|
||||
|
||||
it('should force workspaceId on saved record (defense-in-depth)', async () => {
|
||||
const existing = makeRecord()
|
||||
mockFindOneBy.mockResolvedValue(existing)
|
||||
mockSave.mockImplementation((r: any) => Promise.resolve(r))
|
||||
|
||||
await customMcpServersService.updateCustomMcpServer('mcp-1', { name: 'Updated' }, 'ws-1')
|
||||
|
||||
expect(existing.workspaceId).toBe('ws-1')
|
||||
})
|
||||
})
|
||||
|
||||
// ── deleteCustomMcpServer ───────────────────────────────────────────
|
||||
|
||||
describe('deleteCustomMcpServer', () => {
|
||||
it('should delete by id and workspaceId', async () => {
|
||||
mockDelete.mockResolvedValue({ affected: 1 })
|
||||
|
||||
const result = await customMcpServersService.deleteCustomMcpServer('mcp-1', 'ws-1')
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith({ id: 'mcp-1', workspaceId: 'ws-1' })
|
||||
expect(result).toEqual({ affected: 1 })
|
||||
})
|
||||
|
||||
it('should wrap errors in InternalFlowiseError', async () => {
|
||||
mockDelete.mockRejectedValue(new Error('db error'))
|
||||
|
||||
await expect(customMcpServersService.deleteCustomMcpServer('mcp-1', 'ws-1')).rejects.toThrow(InternalFlowiseError)
|
||||
})
|
||||
})
|
||||
|
||||
// ── authorizeCustomMcpServer ────────────────────────────────────────
|
||||
|
||||
describe('authorizeCustomMcpServer', () => {
|
||||
it('should throw NOT_FOUND when record does not exist', async () => {
|
||||
mockFindOneBy.mockResolvedValue(null)
|
||||
|
||||
await expect(customMcpServersService.authorizeCustomMcpServer('mcp-1', 'ws-1')).rejects.toThrow('not found')
|
||||
})
|
||||
|
||||
it('should connect, discover tools, set AUTHORIZED status, and return record', async () => {
|
||||
const record = makeRecord({ serverUrl: 'https://example.com/mcp' })
|
||||
mockFindOneBy.mockResolvedValue(record)
|
||||
mockInitialize.mockResolvedValue(undefined)
|
||||
mockSave.mockImplementation((r: any) => Promise.resolve(r))
|
||||
|
||||
const result = await customMcpServersService.authorizeCustomMcpServer('mcp-1', 'ws-1')
|
||||
|
||||
expect(MCPToolkit).toHaveBeenCalledWith({ url: 'https://example.com/mcp' }, 'sse')
|
||||
expect(mockInitialize).toHaveBeenCalled()
|
||||
expect(result.status).toBe(CustomMcpServerStatus.AUTHORIZED)
|
||||
expect(result.tools).toBeDefined()
|
||||
expect(mockClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should include decrypted headers in server params when authType is CUSTOM_HEADERS', async () => {
|
||||
const encryptedConfig = 'encrypted:' + JSON.stringify({ headers: { 'X-Api-Key': 'my-secret' } })
|
||||
const record = makeRecord({ serverUrl: 'https://example.com', authType: 'CUSTOM_HEADERS', authConfig: encryptedConfig })
|
||||
mockFindOneBy.mockResolvedValue(record)
|
||||
mockInitialize.mockResolvedValue(undefined)
|
||||
mockSave.mockImplementation((r: any) => Promise.resolve(r))
|
||||
|
||||
await customMcpServersService.authorizeCustomMcpServer('mcp-1', 'ws-1')
|
||||
|
||||
expect(MCPToolkit).toHaveBeenCalledWith({ url: 'https://example.com', headers: { 'X-Api-Key': 'my-secret' } }, 'sse')
|
||||
})
|
||||
|
||||
it('should not include headers when authType is NONE even if authConfig is present', async () => {
|
||||
const encryptedConfig = 'encrypted:' + JSON.stringify({ headers: { 'X-Api-Key': 'stale-secret' } })
|
||||
const record = makeRecord({ serverUrl: 'https://example.com', authType: 'NONE', authConfig: encryptedConfig })
|
||||
mockFindOneBy.mockResolvedValue(record)
|
||||
mockInitialize.mockResolvedValue(undefined)
|
||||
mockSave.mockImplementation((r: any) => Promise.resolve(r))
|
||||
|
||||
await customMcpServersService.authorizeCustomMcpServer('mcp-1', 'ws-1')
|
||||
|
||||
expect(MCPToolkit).toHaveBeenCalledWith({ url: 'https://example.com' }, 'sse')
|
||||
expect(mockDecrypt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not include headers when authConfig has no headers', async () => {
|
||||
const encryptedConfig = 'encrypted:' + JSON.stringify({ other: 'data' })
|
||||
const record = makeRecord({ serverUrl: 'https://example.com', authConfig: encryptedConfig })
|
||||
mockFindOneBy.mockResolvedValue(record)
|
||||
mockInitialize.mockResolvedValue(undefined)
|
||||
mockSave.mockImplementation((r: any) => Promise.resolve(r))
|
||||
|
||||
await customMcpServersService.authorizeCustomMcpServer('mcp-1', 'ws-1')
|
||||
|
||||
expect(MCPToolkit).toHaveBeenCalledWith({ url: 'https://example.com' }, 'sse')
|
||||
})
|
||||
|
||||
it('should set ERROR status and throw when connection fails', async () => {
|
||||
const record = makeRecord({ serverUrl: 'https://example.com' })
|
||||
mockFindOneBy.mockResolvedValue(record)
|
||||
mockInitialize.mockRejectedValue(new Error('connection refused'))
|
||||
mockSave.mockImplementation((r: any) => Promise.resolve(r))
|
||||
|
||||
await expect(customMcpServersService.authorizeCustomMcpServer('mcp-1', 'ws-1')).rejects.toThrow(
|
||||
'Failed to connect to Custom MCP server'
|
||||
)
|
||||
expect(record.status).toBe(CustomMcpServerStatus.ERROR)
|
||||
expect(mockSave).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should close toolkit client even on failure', async () => {
|
||||
const record = makeRecord({ serverUrl: 'https://example.com' })
|
||||
mockFindOneBy.mockResolvedValue(record)
|
||||
mockInitialize.mockRejectedValue(new Error('fail'))
|
||||
mockSave.mockImplementation((r: any) => Promise.resolve(r))
|
||||
|
||||
await expect(customMcpServersService.authorizeCustomMcpServer('mcp-1', 'ws-1')).rejects.toThrow()
|
||||
|
||||
expect(mockClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ── getDiscoveredTools ──────────────────────────────────────────────
|
||||
|
||||
describe('getDiscoveredTools', () => {
|
||||
it('should throw NOT_FOUND when record does not exist', async () => {
|
||||
mockGetOne.mockResolvedValue(null)
|
||||
|
||||
await expect(customMcpServersService.getDiscoveredTools('mcp-1', 'ws-1')).rejects.toThrow('not found')
|
||||
})
|
||||
|
||||
it('should return empty array when tools is null', async () => {
|
||||
mockGetOne.mockResolvedValue(makeRecord({ tools: null }))
|
||||
|
||||
const result = await customMcpServersService.getDiscoveredTools('mcp-1', 'ws-1')
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should return parsed tools array', async () => {
|
||||
const tools = { tools: [{ name: 'tool1' }, { name: 'tool2' }] }
|
||||
mockGetOne.mockResolvedValue(makeRecord({ tools: JSON.stringify(tools) }))
|
||||
|
||||
const result = await customMcpServersService.getDiscoveredTools('mcp-1', 'ws-1')
|
||||
|
||||
expect(result).toEqual([{ name: 'tool1' }, { name: 'tool2' }])
|
||||
})
|
||||
|
||||
it('should return empty array when tools JSON is malformed', async () => {
|
||||
mockGetOne.mockResolvedValue(makeRecord({ tools: 'not-valid-json' }))
|
||||
|
||||
const result = await customMcpServersService.getDiscoveredTools('mcp-1', 'ws-1')
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should return empty array when parsed tools has no tools key', async () => {
|
||||
mockGetOne.mockResolvedValue(makeRecord({ tools: JSON.stringify({ other: 'data' }) }))
|
||||
|
||||
const result = await customMcpServersService.getDiscoveredTools('mcp-1', 'ws-1')
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// ── Hardening additions ─────────────────────────────────────────────
|
||||
|
||||
describe('SSRF denylist enforcement', () => {
|
||||
beforeEach(() => {
|
||||
mockCheckDenyList.mockReset().mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('rejects create when checkDenyList throws (private IP / loopback / IMDS)', async () => {
|
||||
mockCheckDenyList.mockRejectedValueOnce(new Error('denied'))
|
||||
await expect(
|
||||
customMcpServersService.createCustomMcpServer({ name: 'T', serverUrl: 'http://169.254.169.254/' }, 'org-1')
|
||||
).rejects.toThrow('Server URL is not allowed by policy')
|
||||
})
|
||||
|
||||
it('rejects update when a new non-masked URL is denied', async () => {
|
||||
mockFindOneBy.mockResolvedValue(makeRecord())
|
||||
mockCheckDenyList.mockRejectedValueOnce(new Error('denied'))
|
||||
await expect(
|
||||
customMcpServersService.updateCustomMcpServer('mcp-1', { serverUrl: 'http://127.0.0.1:6379' }, 'ws-1')
|
||||
).rejects.toThrow('Server URL is not allowed by policy')
|
||||
})
|
||||
|
||||
it('does not call checkDenyList when update sends a masked URL', async () => {
|
||||
mockFindOneBy.mockResolvedValue(makeRecord({ serverUrl: 'https://real.example.com/secret' }))
|
||||
mockSave.mockImplementation((r: any) => Promise.resolve(r))
|
||||
await customMcpServersService.updateCustomMcpServer('mcp-1', { serverUrl: 'https://real.example.com/************' }, 'ws-1')
|
||||
expect(mockCheckDenyList).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('header validation enforcement', () => {
|
||||
beforeEach(() => {
|
||||
mockValidateCustomHeaders.mockReset()
|
||||
})
|
||||
|
||||
it('runs validateCustomHeaders on create when authType is CUSTOM_HEADERS', async () => {
|
||||
mockSave.mockResolvedValue(makeRecord())
|
||||
await customMcpServersService.createCustomMcpServer(
|
||||
{
|
||||
name: 'T',
|
||||
serverUrl: 'https://example.com',
|
||||
authType: 'CUSTOM_HEADERS',
|
||||
authConfig: { headers: { 'X-Api-Key': 'v' } }
|
||||
},
|
||||
'org-1'
|
||||
)
|
||||
expect(mockValidateCustomHeaders).toHaveBeenCalledWith({ 'X-Api-Key': 'v' })
|
||||
})
|
||||
|
||||
it('maps validator error to BAD_REQUEST', async () => {
|
||||
mockValidateCustomHeaders.mockImplementationOnce(() => {
|
||||
throw new Error('Invalid header "Host": not allowed')
|
||||
})
|
||||
await expect(
|
||||
customMcpServersService.createCustomMcpServer(
|
||||
{
|
||||
name: 'T',
|
||||
serverUrl: 'https://example.com',
|
||||
authType: 'CUSTOM_HEADERS',
|
||||
authConfig: { headers: { Host: 'evil' } }
|
||||
},
|
||||
'org-1'
|
||||
)
|
||||
).rejects.toThrow('Invalid header "Host": not allowed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tools payload cap', () => {
|
||||
const ORIGINAL_ENV = process.env.CUSTOM_MCP_TOOLS_MAX_BYTES
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_ENV === undefined) delete process.env.CUSTOM_MCP_TOOLS_MAX_BYTES
|
||||
else process.env.CUSTOM_MCP_TOOLS_MAX_BYTES = ORIGINAL_ENV
|
||||
})
|
||||
|
||||
it('rejects authorize when serialized tools exceed the limit and marks row ERROR', async () => {
|
||||
process.env.CUSTOM_MCP_TOOLS_MAX_BYTES = '50'
|
||||
const record = makeRecord({ serverUrl: 'https://example.com' })
|
||||
mockFindOneBy.mockResolvedValue(record)
|
||||
mockInitialize.mockResolvedValue(undefined)
|
||||
mockSave.mockImplementation((r: any) => Promise.resolve(r))
|
||||
// Big tool list — well over 50 bytes after stringify
|
||||
const bigTools = {
|
||||
tools: Array.from({ length: 20 }, (_, i) => ({ name: `tool_${i}`, description: 'x'.repeat(40) }))
|
||||
}
|
||||
mockToolkitImpl.mockImplementationOnce(() => ({
|
||||
initialize: mockInitialize,
|
||||
_tools: bigTools,
|
||||
client: { close: mockClose }
|
||||
}))
|
||||
|
||||
await expect(customMcpServersService.authorizeCustomMcpServer('mcp-1', 'ws-1')).rejects.toThrow(
|
||||
/tools payload larger than the allowed limit/
|
||||
)
|
||||
expect(record.status).toBe(CustomMcpServerStatus.ERROR)
|
||||
})
|
||||
|
||||
it('allows any payload when the cap is disabled via env=0', async () => {
|
||||
process.env.CUSTOM_MCP_TOOLS_MAX_BYTES = '0'
|
||||
const record = makeRecord({ serverUrl: 'https://example.com' })
|
||||
mockFindOneBy.mockResolvedValue(record)
|
||||
mockInitialize.mockResolvedValue(undefined)
|
||||
mockSave.mockImplementation((r: any) => Promise.resolve(r))
|
||||
|
||||
const result = await customMcpServersService.authorizeCustomMcpServer('mcp-1', 'ws-1')
|
||||
expect(result.status).toBe(CustomMcpServerStatus.AUTHORIZED)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toolCount population', () => {
|
||||
it('sets toolCount to the number of tools discovered', async () => {
|
||||
const record = makeRecord({ serverUrl: 'https://example.com' })
|
||||
mockFindOneBy.mockResolvedValue(record)
|
||||
mockInitialize.mockResolvedValue(undefined)
|
||||
mockSave.mockImplementation((r: any) => Promise.resolve(r))
|
||||
mockToolkitImpl.mockImplementationOnce(() => ({
|
||||
initialize: mockInitialize,
|
||||
_tools: { tools: [{ name: 'a' }, { name: 'b' }, { name: 'c' }] },
|
||||
client: { close: mockClose }
|
||||
}))
|
||||
|
||||
const result = await customMcpServersService.authorizeCustomMcpServer('mcp-1', 'ws-1')
|
||||
expect(result.toolCount).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('authorize handshake timeout', () => {
|
||||
const ORIGINAL_ENV = process.env.CUSTOM_MCP_AUTHORIZE_TIMEOUT_MS
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_ENV === undefined) delete process.env.CUSTOM_MCP_AUTHORIZE_TIMEOUT_MS
|
||||
else process.env.CUSTOM_MCP_AUTHORIZE_TIMEOUT_MS = ORIGINAL_ENV
|
||||
})
|
||||
|
||||
it('rejects and marks ERROR when initialize() hangs past the timeout', async () => {
|
||||
// Override (env min-clamp normally prevents sub-1s, but we bypass the
|
||||
// min check by asserting the timeout behavior with a long hang.)
|
||||
process.env.CUSTOM_MCP_AUTHORIZE_TIMEOUT_MS = '1000'
|
||||
const record = makeRecord({ serverUrl: 'https://example.com' })
|
||||
mockFindOneBy.mockResolvedValue(record)
|
||||
// initialize() never resolves
|
||||
mockInitialize.mockReturnValue(new Promise(() => {}))
|
||||
mockSave.mockImplementation((r: any) => Promise.resolve(r))
|
||||
|
||||
await expect(customMcpServersService.authorizeCustomMcpServer('mcp-1', 'ws-1')).rejects.toThrow(/exceeded 1000ms/)
|
||||
expect(record.status).toBe(CustomMcpServerStatus.ERROR)
|
||||
expect(mockClose).toHaveBeenCalled() // cleanup still runs
|
||||
}, 10_000)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,424 @@
|
||||
import { StatusCodes } from 'http-status-codes'
|
||||
import { CustomMcpServer } from '../../database/entities/CustomMcpServer'
|
||||
import { CustomMcpServerAuthType, CustomMcpServerStatus, ICustomMcpServerResponse } from '../../Interface'
|
||||
import { InternalFlowiseError } from '../../errors/internalFlowiseError'
|
||||
import { getErrorMessage } from '../../errors/utils'
|
||||
import { getRunningExpressApp } from '../../utils/getRunningExpressApp'
|
||||
import { encryptCredentialData, decryptCredentialData } from '../../utils'
|
||||
import { MCPToolkit, checkDenyList, isValidURL, validateCustomHeaders } from 'flowise-components'
|
||||
import { getAppVersion } from '../../utils'
|
||||
import logger from '../../utils/logger'
|
||||
import { FLOWISE_COUNTER_STATUS, FLOWISE_METRIC_COUNTERS } from '../../Interface.Metrics'
|
||||
|
||||
const REDACTED_VALUE = '************'
|
||||
const DEFAULT_TOOLS_MAX_BYTES = 512 * 1024
|
||||
const DEFAULT_AUTHORIZE_TIMEOUT_MS = 15_000
|
||||
const MIN_AUTHORIZE_TIMEOUT_MS = 1_000
|
||||
|
||||
const getToolsMaxBytes = (): number => {
|
||||
const raw = process.env.CUSTOM_MCP_TOOLS_MAX_BYTES
|
||||
if (raw === undefined || raw === '') return DEFAULT_TOOLS_MAX_BYTES
|
||||
const parsed = Number(raw)
|
||||
if (!Number.isFinite(parsed)) return DEFAULT_TOOLS_MAX_BYTES
|
||||
return parsed
|
||||
}
|
||||
|
||||
const getAuthorizeTimeoutMs = (): number => {
|
||||
const raw = process.env.CUSTOM_MCP_AUTHORIZE_TIMEOUT_MS
|
||||
if (raw === undefined || raw === '') return DEFAULT_AUTHORIZE_TIMEOUT_MS
|
||||
const parsed = Number(raw)
|
||||
if (!Number.isFinite(parsed) || parsed < MIN_AUTHORIZE_TIMEOUT_MS) return DEFAULT_AUTHORIZE_TIMEOUT_MS
|
||||
return parsed
|
||||
}
|
||||
|
||||
const withTimeout = <T>(promise: Promise<T>, ms: number, message: string): Promise<T> => {
|
||||
let timer: NodeJS.Timeout
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(message)), ms)
|
||||
})
|
||||
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)) as Promise<T>
|
||||
}
|
||||
|
||||
const toBadRequest = async (fn: () => Promise<void> | void, fallbackMessage: string): Promise<void> => {
|
||||
try {
|
||||
await fn()
|
||||
} catch (err) {
|
||||
throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, fallbackMessage)
|
||||
}
|
||||
}
|
||||
|
||||
const assertSafeServerUrl = async (url: string): Promise<void> => {
|
||||
if (!isValidURL(url)) {
|
||||
throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, `Invalid Server URL: "${url}" is not a valid URL`)
|
||||
}
|
||||
const parsed = new URL(url)
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new InternalFlowiseError(
|
||||
StatusCodes.BAD_REQUEST,
|
||||
`Invalid Server URL: only http and https are allowed, got "${parsed.protocol.replace(':', '')}"`
|
||||
)
|
||||
}
|
||||
// Runs the shared HTTP deny-list check (RFC1918, loopback, link-local, IMDS, ...)
|
||||
// with opt-out via HTTP_SECURITY_CHECK=false and allowlist via HTTP_DENY_LIST env.
|
||||
await toBadRequest(() => checkDenyList(url), 'Server URL is not allowed by policy')
|
||||
}
|
||||
|
||||
const assertValidHeaders = (headers: unknown): void => {
|
||||
if (!headers || typeof headers !== 'object') return
|
||||
try {
|
||||
validateCustomHeaders(headers as Record<string, string>)
|
||||
} catch (err) {
|
||||
throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, getErrorMessage(err))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns only the origin + '/**' to avoid leaking token-bearing path segments
|
||||
* e.g. https://api.test-server.com/mcp/server/w5pqFCYcsp6TAzaJ → https://api.test-server.com/********
|
||||
*/
|
||||
const maskServerUrl = (url: string): string => {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
if (parsed.pathname && parsed.pathname !== '/') {
|
||||
return `${parsed.origin}/${REDACTED_VALUE}`
|
||||
}
|
||||
return parsed.origin
|
||||
} catch {
|
||||
return REDACTED_VALUE
|
||||
}
|
||||
}
|
||||
|
||||
const sanitizeCustomMcpServer = ({ authConfig: _authConfig, ...rest }: CustomMcpServer) => ({
|
||||
...rest,
|
||||
serverUrl: maskServerUrl(rest.serverUrl)
|
||||
})
|
||||
|
||||
const createCustomMcpServer = async (requestBody: any, orgId: string): Promise<any> => {
|
||||
try {
|
||||
const appServer = getRunningExpressApp()
|
||||
const newRecord = new CustomMcpServer()
|
||||
if (requestBody.serverUrl) await assertSafeServerUrl(requestBody.serverUrl)
|
||||
|
||||
// Encrypt authConfig if present
|
||||
if (requestBody.authConfig && typeof requestBody.authConfig === 'object') {
|
||||
if (requestBody.authType === CustomMcpServerAuthType.CUSTOM_HEADERS) {
|
||||
assertValidHeaders((requestBody.authConfig as Record<string, any>).headers)
|
||||
}
|
||||
requestBody.authConfig = await encryptCredentialData(requestBody.authConfig)
|
||||
} else {
|
||||
requestBody.authConfig = null // explicitly set to null to avoid saving non-decrypted values or empty objects/strings in the database
|
||||
}
|
||||
Object.assign(newRecord, requestBody)
|
||||
|
||||
const record = appServer.AppDataSource.getRepository(CustomMcpServer).create(newRecord)
|
||||
const dbResponse = await appServer.AppDataSource.getRepository(CustomMcpServer).save(record)
|
||||
await appServer.telemetry.sendTelemetry(
|
||||
'custom_mcp_server_created',
|
||||
{
|
||||
version: await getAppVersion(),
|
||||
toolId: dbResponse.id,
|
||||
toolName: dbResponse.name
|
||||
},
|
||||
orgId
|
||||
)
|
||||
appServer.metricsProvider?.incrementCounter(FLOWISE_METRIC_COUNTERS.CUSTOM_MCP_SERVER_CREATED, {
|
||||
status: FLOWISE_COUNTER_STATUS.SUCCESS
|
||||
})
|
||||
return sanitizeCustomMcpServer(dbResponse)
|
||||
} catch (error) {
|
||||
if (error instanceof InternalFlowiseError) throw error
|
||||
throw new InternalFlowiseError(
|
||||
StatusCodes.INTERNAL_SERVER_ERROR,
|
||||
`Error: customMcpServersService.createCustomMcpServer - ${getErrorMessage(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const getAllCustomMcpServers = async (workspaceId: string, page: number = -1, limit: number = -1) => {
|
||||
try {
|
||||
const appServer = getRunningExpressApp()
|
||||
const queryBuilder = appServer.AppDataSource.getRepository(CustomMcpServer)
|
||||
.createQueryBuilder('custom_mcp_server')
|
||||
.orderBy('custom_mcp_server.updatedDate', 'DESC')
|
||||
|
||||
queryBuilder.andWhere('custom_mcp_server.workspaceId = :workspaceId', { workspaceId })
|
||||
if (page > 0 && limit > 0) {
|
||||
queryBuilder.skip((page - 1) * limit)
|
||||
queryBuilder.take(limit)
|
||||
}
|
||||
|
||||
const [data, total] = await queryBuilder.getManyAndCount()
|
||||
|
||||
const sanitized = data.map(sanitizeCustomMcpServer)
|
||||
|
||||
if (page > 0 && limit > 0) {
|
||||
return { data: sanitized, total }
|
||||
} else {
|
||||
return sanitized
|
||||
}
|
||||
} catch (error) {
|
||||
throw new InternalFlowiseError(
|
||||
StatusCodes.INTERNAL_SERVER_ERROR,
|
||||
`Error: customMcpServersService.getAllCustomMcpServers - ${getErrorMessage(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const getCustomMcpServerById = async (id: string, workspaceId: string): Promise<ICustomMcpServerResponse> => {
|
||||
try {
|
||||
const appServer = getRunningExpressApp()
|
||||
// Explicitly select `tools` — it is `select: false` on the entity so list queries stay cheap.
|
||||
const dbResponse = await appServer.AppDataSource.getRepository(CustomMcpServer)
|
||||
.createQueryBuilder('custom_mcp_server')
|
||||
.addSelect('custom_mcp_server.tools')
|
||||
.where('custom_mcp_server.id = :id', { id })
|
||||
.andWhere('custom_mcp_server.workspaceId = :workspaceId', { workspaceId })
|
||||
.getOne()
|
||||
if (!dbResponse) {
|
||||
throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `Custom MCP server ${id} not found`)
|
||||
}
|
||||
const result: ICustomMcpServerResponse = { ...dbResponse, authConfig: undefined, serverUrl: maskServerUrl(dbResponse.serverUrl) }
|
||||
if (dbResponse.authConfig) {
|
||||
try {
|
||||
const decrypted = await decryptCredentialData(dbResponse.authConfig)
|
||||
if (decrypted && typeof decrypted === 'object') {
|
||||
// Mask sensitive header values — only expose keys
|
||||
const masked = { ...decrypted } as Record<string, any>
|
||||
if (masked.headers && typeof masked.headers === 'object') {
|
||||
const redactedHeaders: Record<string, string> = {}
|
||||
for (const key of Object.keys(masked.headers)) {
|
||||
redactedHeaders[key] = REDACTED_VALUE
|
||||
}
|
||||
masked.headers = redactedHeaders
|
||||
}
|
||||
result.authConfig = masked
|
||||
} else {
|
||||
result.authConfig = {}
|
||||
}
|
||||
} catch {
|
||||
result.authConfig = {}
|
||||
}
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
if (error instanceof InternalFlowiseError) throw error
|
||||
throw new InternalFlowiseError(
|
||||
StatusCodes.INTERNAL_SERVER_ERROR,
|
||||
`Error: customMcpServersService.getCustomMcpServerById - ${getErrorMessage(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const updateCustomMcpServer = async (id: string, requestBody: any, workspaceId: string): Promise<any> => {
|
||||
try {
|
||||
const appServer = getRunningExpressApp()
|
||||
const record = await appServer.AppDataSource.getRepository(CustomMcpServer).findOneBy({
|
||||
id,
|
||||
workspaceId
|
||||
})
|
||||
if (!record) {
|
||||
throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `Custom MCP server ${id} not found`)
|
||||
}
|
||||
|
||||
if (requestBody.serverUrl === maskServerUrl(record.serverUrl)) {
|
||||
requestBody.serverUrl = record.serverUrl
|
||||
} else if (requestBody.serverUrl && requestBody.serverUrl.includes(REDACTED_VALUE)) {
|
||||
throw new InternalFlowiseError(
|
||||
StatusCodes.BAD_REQUEST,
|
||||
'Server URL still contains the masked placeholder. Send the full URL, or omit serverUrl from the request to keep the existing value.'
|
||||
)
|
||||
} else if (requestBody.serverUrl) {
|
||||
await assertSafeServerUrl(requestBody.serverUrl)
|
||||
}
|
||||
|
||||
// Merge authConfig: clear it when switching to no authentication; otherwise preserve
|
||||
// existing encrypted header values when client sends redacted placeholders
|
||||
if (requestBody.authType === CustomMcpServerAuthType.NONE) {
|
||||
requestBody.authConfig = null
|
||||
} else if (requestBody.authConfig && typeof requestBody.authConfig === 'object') {
|
||||
if (requestBody.authConfig.headers && typeof requestBody.authConfig.headers === 'object' && record.authConfig) {
|
||||
try {
|
||||
const existingDecrypted = await decryptCredentialData(record.authConfig)
|
||||
if (existingDecrypted?.headers && typeof existingDecrypted.headers === 'object') {
|
||||
const mergedHeaders: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(requestBody.authConfig.headers as Record<string, string>)) {
|
||||
// Keep existing value if client sent the redacted placeholder
|
||||
if (value === REDACTED_VALUE && key in (existingDecrypted.headers as Record<string, string>)) {
|
||||
mergedHeaders[key] = (existingDecrypted.headers as Record<string, string>)[key]
|
||||
} else if (typeof value === 'string' && value !== REDACTED_VALUE && value.includes(REDACTED_VALUE)) {
|
||||
throw new InternalFlowiseError(
|
||||
StatusCodes.BAD_REQUEST,
|
||||
`Header "${key}" value still contains the masked placeholder. Send the full value, or pass "${REDACTED_VALUE}" to keep the existing value.`
|
||||
)
|
||||
} else {
|
||||
mergedHeaders[key] = value
|
||||
}
|
||||
}
|
||||
requestBody.authConfig = { ...requestBody.authConfig, headers: mergedHeaders }
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof InternalFlowiseError) throw err
|
||||
}
|
||||
}
|
||||
if (requestBody.authType === CustomMcpServerAuthType.CUSTOM_HEADERS) {
|
||||
assertValidHeaders((requestBody.authConfig as Record<string, any>).headers)
|
||||
}
|
||||
requestBody.authConfig = await encryptCredentialData(requestBody.authConfig)
|
||||
}
|
||||
|
||||
const updateRecord = new CustomMcpServer()
|
||||
Object.assign(updateRecord, requestBody)
|
||||
appServer.AppDataSource.getRepository(CustomMcpServer).merge(record, updateRecord)
|
||||
record.workspaceId = workspaceId // defense-in-depth
|
||||
const dbResponse = await appServer.AppDataSource.getRepository(CustomMcpServer).save(record)
|
||||
return sanitizeCustomMcpServer(dbResponse)
|
||||
} catch (error) {
|
||||
if (error instanceof InternalFlowiseError) throw error
|
||||
throw new InternalFlowiseError(
|
||||
StatusCodes.INTERNAL_SERVER_ERROR,
|
||||
`Error: customMcpServersService.updateCustomMcpServer - ${getErrorMessage(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteCustomMcpServer = async (id: string, workspaceId: string): Promise<any> => {
|
||||
try {
|
||||
const appServer = getRunningExpressApp()
|
||||
const dbResponse = await appServer.AppDataSource.getRepository(CustomMcpServer).delete({
|
||||
id,
|
||||
workspaceId
|
||||
})
|
||||
return dbResponse
|
||||
} catch (error) {
|
||||
throw new InternalFlowiseError(
|
||||
StatusCodes.INTERNAL_SERVER_ERROR,
|
||||
`Error: customMcpServersService.deleteCustomMcpServer - ${getErrorMessage(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const authorizeCustomMcpServer = async (id: string, workspaceId: string): Promise<any> => {
|
||||
try {
|
||||
const appServer = getRunningExpressApp()
|
||||
const repo = appServer.AppDataSource.getRepository(CustomMcpServer)
|
||||
const record = await repo.findOneBy({ id, workspaceId })
|
||||
if (!record) {
|
||||
throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `Custom MCP server ${id} not found`)
|
||||
}
|
||||
|
||||
// Build headers from decrypted authConfig — only when authType explicitly requires them
|
||||
let headers: Record<string, string> = {}
|
||||
if (record.authType === CustomMcpServerAuthType.CUSTOM_HEADERS && record.authConfig) {
|
||||
try {
|
||||
const decrypted = await decryptCredentialData(record.authConfig)
|
||||
if (decrypted && typeof decrypted === 'object') {
|
||||
// Support CUSTOM_HEADERS format: { headers: { key: value } }
|
||||
if (decrypted.headers && typeof decrypted.headers === 'object') {
|
||||
headers = decrypted.headers as Record<string, string>
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// authConfig decryption failed — proceed without headers
|
||||
}
|
||||
}
|
||||
|
||||
const serverParams: any = {
|
||||
url: record.serverUrl,
|
||||
...(Object.keys(headers).length > 0 ? { headers } : {})
|
||||
}
|
||||
|
||||
let toolkit: MCPToolkit | null = null
|
||||
try {
|
||||
toolkit = new MCPToolkit(serverParams, 'sse')
|
||||
const timeoutMs = getAuthorizeTimeoutMs()
|
||||
await withTimeout(toolkit.initialize(), timeoutMs, `MCP server handshake exceeded ${timeoutMs}ms`)
|
||||
|
||||
const discoveredTools = toolkit._tools || []
|
||||
const toolsJson = JSON.stringify(discoveredTools)
|
||||
|
||||
const maxBytes = getToolsMaxBytes()
|
||||
if (maxBytes > 0 && Buffer.byteLength(toolsJson, 'utf8') > maxBytes) {
|
||||
record.status = CustomMcpServerStatus.ERROR
|
||||
await repo.save(record)
|
||||
throw new InternalFlowiseError(
|
||||
StatusCodes.BAD_REQUEST,
|
||||
`MCP server returned a tools payload larger than the allowed limit (${maxBytes} bytes). Set CUSTOM_MCP_TOOLS_MAX_BYTES to override.`
|
||||
)
|
||||
}
|
||||
|
||||
const toolsArray = Array.isArray((discoveredTools as any)?.tools) ? (discoveredTools as any).tools : []
|
||||
record.tools = toolsJson
|
||||
record.toolCount = toolsArray.length
|
||||
record.status = CustomMcpServerStatus.AUTHORIZED
|
||||
await repo.save(record)
|
||||
|
||||
logger.debug(`[CustomMcpServerService]: Authorized Custom MCP server ${id}, discovered ${toolsArray.length} tools`)
|
||||
|
||||
// Ensure tools is present in the response even if `select:false` stripped it from the saved entity.
|
||||
return { ...sanitizeCustomMcpServer(record), tools: toolsJson }
|
||||
} catch (connectError) {
|
||||
// InternalFlowiseError (e.g. oversized tools payload) was already persisted — rethrow as-is
|
||||
if (connectError instanceof InternalFlowiseError) throw connectError
|
||||
record.status = CustomMcpServerStatus.ERROR
|
||||
await repo.save(record)
|
||||
throw new InternalFlowiseError(
|
||||
StatusCodes.BAD_REQUEST,
|
||||
`Failed to connect to Custom MCP server: ${getErrorMessage(connectError)}`
|
||||
)
|
||||
} finally {
|
||||
if (toolkit?.client) {
|
||||
try {
|
||||
await toolkit.client.close()
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof InternalFlowiseError) throw error
|
||||
throw new InternalFlowiseError(
|
||||
StatusCodes.INTERNAL_SERVER_ERROR,
|
||||
`Error: customMcpServersService.authorizeCustomMcpServer - ${getErrorMessage(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const getDiscoveredTools = async (id: string, workspaceId: string): Promise<Record<string, any>[]> => {
|
||||
try {
|
||||
const appServer = getRunningExpressApp()
|
||||
const record = await appServer.AppDataSource.getRepository(CustomMcpServer)
|
||||
.createQueryBuilder('custom_mcp_server')
|
||||
.addSelect('custom_mcp_server.tools')
|
||||
.where('custom_mcp_server.id = :id', { id })
|
||||
.andWhere('custom_mcp_server.workspaceId = :workspaceId', { workspaceId })
|
||||
.getOne()
|
||||
if (!record) {
|
||||
throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `Custom MCP server ${id} not found`)
|
||||
}
|
||||
if (!record.tools) {
|
||||
return []
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(record.tools)
|
||||
return Array.isArray(parsed?.tools) ? parsed.tools : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof InternalFlowiseError) throw error
|
||||
throw new InternalFlowiseError(
|
||||
StatusCodes.INTERNAL_SERVER_ERROR,
|
||||
`Error: customMcpServersService.getDiscoveredTools - ${getErrorMessage(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
createCustomMcpServer,
|
||||
getAllCustomMcpServers,
|
||||
getCustomMcpServerById,
|
||||
updateCustomMcpServer,
|
||||
deleteCustomMcpServer,
|
||||
authorizeCustomMcpServer,
|
||||
getDiscoveredTools
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Extracted from ./index.ts so nodes.test.ts can import it without loading
|
||||
// getRunningExpressApp → flowise-components barrel → MCP SDK → pkce-challenge (ESM-only),
|
||||
// which crashes Jest on Node 18 with "needs ES Modules in the VM API".
|
||||
// `import type` is erased at compile time, so this file has zero runtime deps on flowise-components.
|
||||
import type { INode, INodeOptionsValue, INodeParams, ClientType } from 'flowise-components'
|
||||
|
||||
// Filter node inputs by client. Params/options with a `client` array that excludes the requesting client are removed. No-ops when client is omitted.
|
||||
export const filterNodeByClient = (node: INode, client?: ClientType): INode => {
|
||||
if (!client || !node.inputs) return node
|
||||
|
||||
const filterParam = (param: INodeParams): INodeParams => {
|
||||
const filtered: INodeParams = { ...param }
|
||||
if (filtered.options) {
|
||||
filtered.options = filtered.options.filter((opt: INodeOptionsValue) => !opt.client || opt.client.includes(client))
|
||||
}
|
||||
if (filtered.tabs) {
|
||||
filtered.tabs = filtered.tabs.filter((t) => !t.client || t.client.includes(client)).map(filterParam)
|
||||
}
|
||||
if (filtered.array) {
|
||||
filtered.array = filtered.array.filter((a) => !a.client || a.client.includes(client)).map(filterParam)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
const filteredInputs = (node.inputs as INodeParams[]).filter((param) => !param.client || param.client.includes(client)).map(filterParam)
|
||||
|
||||
return { ...node, inputs: filteredInputs }
|
||||
}
|
||||
@@ -2,13 +2,16 @@ import { cloneDeep, omit } from 'lodash'
|
||||
import { StatusCodes } from 'http-status-codes'
|
||||
import { getRunningExpressApp } from '../../utils/getRunningExpressApp'
|
||||
import { INodeData, MODE } from '../../Interface'
|
||||
import { INode, INodeOptionsValue, INodeParams, ClientType } from 'flowise-components'
|
||||
import { ClientType, INodeOptionsValue } from 'flowise-components'
|
||||
import { databaseEntities } from '../../utils'
|
||||
import logger from '../../utils/logger'
|
||||
import { InternalFlowiseError } from '../../errors/internalFlowiseError'
|
||||
import { getErrorMessage } from '../../errors/utils'
|
||||
import { OMIT_QUEUE_JOB_DATA } from '../../utils/constants'
|
||||
import { executeCustomNodeFunction } from '../../utils/executeCustomNodeFunction'
|
||||
import { filterNodeByClient } from './filterNodeByClient'
|
||||
|
||||
export { filterNodeByClient }
|
||||
|
||||
// Get all component nodes
|
||||
const getAllNodes = async (client?: ClientType) => {
|
||||
@@ -152,29 +155,6 @@ const executeCustomFunction = async (requestBody: any, workspaceId?: string, org
|
||||
}
|
||||
}
|
||||
|
||||
// Filter node inputs by client. Params/options with a `client` array that excludes the requesting client are removed. No-ops when client is omitted.
|
||||
export const filterNodeByClient = (node: INode, client?: ClientType): INode => {
|
||||
if (!client || !node.inputs) return node
|
||||
|
||||
const filterParam = (param: INodeParams): INodeParams => {
|
||||
const filtered: INodeParams = { ...param }
|
||||
if (filtered.options) {
|
||||
filtered.options = filtered.options.filter((opt: INodeOptionsValue) => !opt.client || opt.client.includes(client))
|
||||
}
|
||||
if (filtered.tabs) {
|
||||
filtered.tabs = filtered.tabs.filter((t) => !t.client || t.client.includes(client)).map(filterParam)
|
||||
}
|
||||
if (filtered.array) {
|
||||
filtered.array = filtered.array.filter((a) => !a.client || a.client.includes(client)).map(filterParam)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
const filteredInputs = (node.inputs as INodeParams[]).filter((param) => !param.client || param.client.includes(client)).map(filterParam)
|
||||
|
||||
return { ...node, inputs: filteredInputs }
|
||||
}
|
||||
|
||||
export default {
|
||||
getAllNodes,
|
||||
getNodeByName,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { filterNodeByClient } from './index'
|
||||
import { filterNodeByClient } from './filterNodeByClient'
|
||||
import type { INode } from 'flowise-components'
|
||||
|
||||
const makeNode = (overrides: Partial<INode> = {}): INode =>
|
||||
|
||||
@@ -54,6 +54,7 @@ import { CachePool } from '../CachePool'
|
||||
import { Variable } from '../database/entities/Variable'
|
||||
import { DocumentStore } from '../database/entities/DocumentStore'
|
||||
import { DocumentStoreFileChunk } from '../database/entities/DocumentStoreFileChunk'
|
||||
import { CustomMcpServer } from '../database/entities/CustomMcpServer'
|
||||
import { InternalFlowiseError } from '../errors/internalFlowiseError'
|
||||
import { StatusCodes } from 'http-status-codes'
|
||||
import {
|
||||
@@ -100,7 +101,8 @@ export const databaseEntities: IDatabaseEntity = {
|
||||
Assistant: Assistant,
|
||||
Variable: Variable,
|
||||
DocumentStore: DocumentStore,
|
||||
DocumentStoreFileChunk: DocumentStoreFileChunk
|
||||
DocumentStoreFileChunk: DocumentStoreFileChunk,
|
||||
CustomMcpServer: CustomMcpServer
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import client from './client'
|
||||
|
||||
const getAllCustomMcpServers = (params) => client.get('/custom-mcp-servers', { params })
|
||||
|
||||
const getCustomMcpServer = (id) => client.get(`/custom-mcp-servers/${id}`)
|
||||
|
||||
const createCustomMcpServer = (body) => client.post(`/custom-mcp-servers`, body)
|
||||
|
||||
const updateCustomMcpServer = (id, body) => client.put(`/custom-mcp-servers/${id}`, body)
|
||||
|
||||
const deleteCustomMcpServer = (id) => client.delete(`/custom-mcp-servers/${id}`)
|
||||
|
||||
const authorizeCustomMcpServer = (id) => client.post(`/custom-mcp-servers/${id}/authorize`)
|
||||
|
||||
const getCustomMcpServerTools = (id) => client.get(`/custom-mcp-servers/${id}/tools`)
|
||||
|
||||
export default {
|
||||
getAllCustomMcpServers,
|
||||
getCustomMcpServer,
|
||||
createCustomMcpServer,
|
||||
updateCustomMcpServer,
|
||||
deleteCustomMcpServer,
|
||||
authorizeCustomMcpServer,
|
||||
getCustomMcpServerTools
|
||||
}
|
||||
@@ -114,3 +114,14 @@ export const AGENTFLOW_ICONS = [
|
||||
color: '#a3b18a'
|
||||
}
|
||||
]
|
||||
|
||||
export const MCP_SERVER_STATUS = {
|
||||
PENDING: 'PENDING',
|
||||
AUTHORIZED: 'AUTHORIZED',
|
||||
ERROR: 'ERROR'
|
||||
}
|
||||
|
||||
export const MCP_AUTH_TYPE = {
|
||||
NONE: 'NONE',
|
||||
CUSTOM_HEADERS: 'CUSTOM_HEADERS'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import PropTypes from 'prop-types'
|
||||
import { useSelector } from 'react-redux'
|
||||
|
||||
// material-ui
|
||||
import { styled } from '@mui/material/styles'
|
||||
import { Box, Grid, Tooltip, Typography, useTheme } from '@mui/material'
|
||||
import { IconTool } from '@tabler/icons-react'
|
||||
|
||||
// project imports
|
||||
import MainCard from '@/ui-component/cards/MainCard'
|
||||
import { MCP_SERVER_STATUS } from '@/store/constant'
|
||||
|
||||
const CardWrapper = styled(MainCard)(({ theme }) => ({
|
||||
background: theme.palette.card.main,
|
||||
color: theme.darkTextPrimary,
|
||||
overflow: 'auto',
|
||||
position: 'relative',
|
||||
boxShadow: '0 2px 14px 0 rgb(32 40 45 / 8%)',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
background: theme.palette.card.hover,
|
||||
boxShadow: '0 2px 14px 0 rgb(32 40 45 / 20%)'
|
||||
},
|
||||
height: '100%',
|
||||
minHeight: '160px',
|
||||
maxHeight: '300px',
|
||||
width: '100%',
|
||||
overflowWrap: 'break-word',
|
||||
whiteSpace: 'pre-line'
|
||||
}))
|
||||
|
||||
const getStatusColors = (status, isDarkMode, theme) => {
|
||||
switch (status) {
|
||||
case MCP_SERVER_STATUS.AUTHORIZED:
|
||||
return isDarkMode ? ['#1b5e20', '#2e7d32', '#ffffff'] : ['#e8f5e9', '#81c784', '#43a047']
|
||||
case MCP_SERVER_STATUS.ERROR:
|
||||
return isDarkMode ? ['#b71c1c', '#c62828', '#ffffff'] : ['#ffebee', '#ef9a9a', '#c62828']
|
||||
case MCP_SERVER_STATUS.PENDING:
|
||||
default:
|
||||
return isDarkMode
|
||||
? [theme.palette.grey[800], theme.palette.grey[500], theme.palette.grey[200]]
|
||||
: [theme.palette.grey[100], theme.palette.grey[400], theme.palette.grey[700]]
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================|| MCP ITEM CARD ||=========================== //
|
||||
|
||||
const MCPItemCard = ({ data, onClick }) => {
|
||||
const theme = useTheme()
|
||||
const customization = useSelector((state) => state.customization)
|
||||
const isDarkMode = customization.isDarkMode
|
||||
|
||||
const toolCount = typeof data.toolCount === 'number' ? data.toolCount : 0
|
||||
|
||||
const statusColors = getStatusColors(data.status, isDarkMode, theme)
|
||||
|
||||
return (
|
||||
<CardWrapper content={false} onClick={onClick} sx={{ border: 1, borderColor: theme.palette.grey[900] + 25, borderRadius: 2 }}>
|
||||
<Box sx={{ height: '100%', p: 2.25 }}>
|
||||
<Grid container justifyContent='space-between' direction='column' sx={{ height: '100%' }} gap={2}>
|
||||
{/* Header: icon + name + status badge */}
|
||||
<Box display='flex' flexDirection='column' sx={{ flex: 1, width: '100%' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{data.iconSrc && (
|
||||
<div
|
||||
style={{
|
||||
width: 35,
|
||||
height: 35,
|
||||
display: 'flex',
|
||||
flexShrink: 0,
|
||||
marginRight: 10,
|
||||
borderRadius: '50%',
|
||||
backgroundImage: `url(${data.iconSrc})`,
|
||||
backgroundSize: 'contain',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'center center'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!data.iconSrc && data.color && (
|
||||
<div
|
||||
style={{
|
||||
width: 35,
|
||||
height: 35,
|
||||
display: 'flex',
|
||||
flexShrink: 0,
|
||||
marginRight: 10,
|
||||
borderRadius: '50%',
|
||||
background: data.color
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Typography
|
||||
sx={{
|
||||
display: '-webkit-box',
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 500,
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
flex: 1,
|
||||
mr: 1
|
||||
}}
|
||||
>
|
||||
{data.name}
|
||||
</Typography>
|
||||
{/* Status badge */}
|
||||
{data.status && (
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
background: statusColors[0],
|
||||
borderRadius: '25px',
|
||||
paddingTop: '3px',
|
||||
paddingBottom: '3px',
|
||||
paddingLeft: '10px',
|
||||
paddingRight: '10px'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: statusColors[1],
|
||||
marginRight: 5
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: '0.65rem', color: statusColors[2], textTransform: 'uppercase' }}>
|
||||
{data.status}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Server URL */}
|
||||
{data.serverUrl && (
|
||||
<Tooltip title={data.serverUrl} placement='bottom-start'>
|
||||
<Typography
|
||||
variant='body2'
|
||||
sx={{
|
||||
mt: 1,
|
||||
color: theme.palette.text.secondary,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 1,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
fontSize: '0.8rem'
|
||||
}}
|
||||
>
|
||||
{data.serverUrl}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Footer: tool count badge */}
|
||||
<Grid container columnGap={2} rowGap={1}>
|
||||
<div
|
||||
style={{
|
||||
paddingLeft: '7px',
|
||||
paddingRight: '7px',
|
||||
paddingTop: '3px',
|
||||
paddingBottom: '3px',
|
||||
fontSize: '11px',
|
||||
width: 'max-content',
|
||||
borderRadius: '25px',
|
||||
boxShadow: isDarkMode ? '0 2px 14px 0 rgb(255 255 255 / 20%)' : '0 2px 14px 0 rgb(32 40 45 / 20%)',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<IconTool style={{ marginRight: 5 }} size={15} />
|
||||
{toolCount} {toolCount === 1 ? 'tool' : 'tools'}
|
||||
</div>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</CardWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
MCPItemCard.propTypes = {
|
||||
data: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
serverUrl: PropTypes.string,
|
||||
status: PropTypes.string,
|
||||
tools: PropTypes.string, // JSON stringified array (legacy; no longer shipped on list responses)
|
||||
toolCount: PropTypes.number,
|
||||
iconSrc: PropTypes.string,
|
||||
color: PropTypes.string
|
||||
}).isRequired,
|
||||
onClick: PropTypes.func
|
||||
}
|
||||
|
||||
export default MCPItemCard
|
||||
@@ -0,0 +1,211 @@
|
||||
import PropTypes from 'prop-types'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { styled } from '@mui/material/styles'
|
||||
import { tableCellClasses } from '@mui/material/TableCell'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
Typography,
|
||||
useTheme
|
||||
} from '@mui/material'
|
||||
import { IconTool } from '@tabler/icons-react'
|
||||
import { MCP_SERVER_STATUS } from '@/store/constant'
|
||||
|
||||
const StyledTableCell = styled(TableCell)(({ theme }) => ({
|
||||
borderColor: theme.palette.grey[900] + 25,
|
||||
[`&.${tableCellClasses.head}`]: {
|
||||
color: theme.palette.grey[900]
|
||||
},
|
||||
[`&.${tableCellClasses.body}`]: {
|
||||
fontSize: 14,
|
||||
height: 64
|
||||
}
|
||||
}))
|
||||
|
||||
const StyledTableRow = styled(TableRow)(() => ({
|
||||
'&:last-child td, &:last-child th': {
|
||||
border: 0
|
||||
}
|
||||
}))
|
||||
|
||||
const getStatusColors = (status, isDarkMode, theme) => {
|
||||
switch (status) {
|
||||
case MCP_SERVER_STATUS.AUTHORIZED:
|
||||
return isDarkMode ? ['#1b5e20', '#2e7d32', '#ffffff'] : ['#e8f5e9', '#81c784', '#43a047']
|
||||
case MCP_SERVER_STATUS.ERROR:
|
||||
return isDarkMode ? ['#b71c1c', '#c62828', '#ffffff'] : ['#ffebee', '#ef9a9a', '#c62828']
|
||||
case MCP_SERVER_STATUS.PENDING:
|
||||
default:
|
||||
return isDarkMode
|
||||
? [theme.palette.grey[800], theme.palette.grey[500], theme.palette.grey[200]]
|
||||
: [theme.palette.grey[100], theme.palette.grey[400], theme.palette.grey[700]]
|
||||
}
|
||||
}
|
||||
|
||||
export const StatusBadge = ({ status }) => {
|
||||
const theme = useTheme()
|
||||
const customization = useSelector((state) => state.customization)
|
||||
const colors = getStatusColors(status, customization.isDarkMode, theme)
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
background: colors[0],
|
||||
borderRadius: '25px',
|
||||
paddingTop: '3px',
|
||||
paddingBottom: '3px',
|
||||
paddingLeft: '10px',
|
||||
paddingRight: '10px',
|
||||
width: 'fit-content'
|
||||
}}
|
||||
>
|
||||
<div style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: colors[1], marginRight: 5 }} />
|
||||
<span style={{ fontSize: '0.65rem', color: colors[2], textTransform: 'uppercase' }}>{status}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
StatusBadge.propTypes = {
|
||||
status: PropTypes.string
|
||||
}
|
||||
|
||||
export const MCPServersTable = ({ data, isLoading, onSelect }) => {
|
||||
const theme = useTheme()
|
||||
const customization = useSelector((state) => state.customization)
|
||||
|
||||
return (
|
||||
<TableContainer sx={{ border: 1, borderColor: theme.palette.grey[900] + 25, borderRadius: 2 }} component={Paper}>
|
||||
<Table sx={{ minWidth: 650 }} size='small' aria-label='MCP servers table'>
|
||||
<TableHead
|
||||
sx={{
|
||||
backgroundColor: customization.isDarkMode ? theme.palette.common.black : theme.palette.grey[100],
|
||||
height: 56
|
||||
}}
|
||||
>
|
||||
<TableRow>
|
||||
<StyledTableCell>Name</StyledTableCell>
|
||||
<StyledTableCell>Server URL</StyledTableCell>
|
||||
<StyledTableCell>Status</StyledTableCell>
|
||||
<StyledTableCell>Tools</StyledTableCell>
|
||||
<StyledTableCell> </StyledTableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<>
|
||||
{[0, 1].map((i) => (
|
||||
<StyledTableRow key={i}>
|
||||
{[0, 1, 2, 3, 4].map((j) => (
|
||||
<StyledTableCell key={j}>
|
||||
<Skeleton variant='text' />
|
||||
</StyledTableCell>
|
||||
))}
|
||||
</StyledTableRow>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{data?.map((row) => {
|
||||
const toolCount = typeof row.toolCount === 'number' ? row.toolCount : 0
|
||||
return (
|
||||
<StyledTableRow key={row.id}>
|
||||
<StyledTableCell>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{row.iconSrc && (
|
||||
<div
|
||||
style={{
|
||||
width: 35,
|
||||
height: 35,
|
||||
flexShrink: 0,
|
||||
borderRadius: '50%',
|
||||
backgroundImage: `url(${row.iconSrc})`,
|
||||
backgroundSize: 'contain',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'center center'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!row.iconSrc && row.color && (
|
||||
<div
|
||||
style={{
|
||||
width: 35,
|
||||
height: 35,
|
||||
flexShrink: 0,
|
||||
borderRadius: '50%',
|
||||
background: row.color
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Typography sx={{ fontSize: 14, fontWeight: 500 }}>
|
||||
<Button onClick={() => onSelect(row)} sx={{ textAlign: 'left' }}>
|
||||
{row.name}
|
||||
</Button>
|
||||
</Typography>
|
||||
</Box>
|
||||
</StyledTableCell>
|
||||
<StyledTableCell>
|
||||
<Tooltip title={row.serverUrl} placement='top'>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: 13,
|
||||
color: 'text.secondary',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 1,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
maxWidth: 300
|
||||
}}
|
||||
>
|
||||
{row.serverUrl}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
</StyledTableCell>
|
||||
<StyledTableCell>{row.status && <StatusBadge status={row.status} />}</StyledTableCell>
|
||||
<StyledTableCell>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<IconTool size={14} />
|
||||
<Typography sx={{ fontSize: 13 }}>
|
||||
{toolCount} {toolCount === 1 ? 'tool' : 'tools'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</StyledTableCell>
|
||||
<StyledTableCell />
|
||||
</StyledTableRow>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)
|
||||
}
|
||||
|
||||
MCPServersTable.propTypes = {
|
||||
data: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
serverUrl: PropTypes.string.isRequired,
|
||||
status: PropTypes.string,
|
||||
tools: PropTypes.string, // JSON stringified array (legacy; no longer shipped on list responses)
|
||||
toolCount: PropTypes.number,
|
||||
iconSrc: PropTypes.string,
|
||||
color: PropTypes.string
|
||||
})
|
||||
),
|
||||
isLoading: PropTypes.bool,
|
||||
onSelect: PropTypes.func
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,25 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
|
||||
// material-ui
|
||||
import { Box, Stack, ButtonGroup, Skeleton, ToggleButtonGroup, ToggleButton } from '@mui/material'
|
||||
import { Box, Stack, ButtonGroup, Skeleton, ToggleButtonGroup, ToggleButton, Tabs, Tab } from '@mui/material'
|
||||
import { useTheme } from '@mui/material/styles'
|
||||
|
||||
// project imports
|
||||
import MainCard from '@/ui-component/cards/MainCard'
|
||||
import ItemCard from '@/ui-component/cards/ItemCard'
|
||||
import MCPItemCard from '@/ui-component/cards/MCPItemCard'
|
||||
import ToolDialog from './ToolDialog'
|
||||
import CustomMcpServerDialog from './CustomMcpServerDialog'
|
||||
import ViewHeader from '@/layout/MainLayout/ViewHeader'
|
||||
import ErrorBoundary from '@/ErrorBoundary'
|
||||
import { ToolsTable } from '@/ui-component/table/ToolsListTable'
|
||||
import { MCPServersTable } from '@/ui-component/table/MCPServersTable'
|
||||
import { PermissionButton, StyledPermissionButton } from '@/ui-component/button/RBACButtons'
|
||||
import TablePagination, { DEFAULT_ITEMS_PER_PAGE } from '@/ui-component/pagination/TablePagination'
|
||||
|
||||
// API
|
||||
import toolsApi from '@/api/tools'
|
||||
import customMcpServersApi from '@/api/custommcpservers'
|
||||
|
||||
// Hooks
|
||||
import useApi from '@/hooks/useApi'
|
||||
@@ -31,8 +35,11 @@ import ToolEmptySVG from '@/assets/images/tools_empty.svg'
|
||||
const Tools = () => {
|
||||
const theme = useTheme()
|
||||
const getAllToolsApi = useApi(toolsApi.getAllTools)
|
||||
const getAllCustomMcpServersApi = useApi(customMcpServersApi.getAllCustomMcpServers)
|
||||
const { error, setError } = useError()
|
||||
|
||||
const [tabValue, setTabValue] = useState(0)
|
||||
|
||||
const [isLoading, setLoading] = useState(true)
|
||||
const [showDialog, setShowDialog] = useState(false)
|
||||
const [dialogProps, setDialogProps] = useState({})
|
||||
@@ -40,6 +47,14 @@ const Tools = () => {
|
||||
|
||||
const inputRef = useRef(null)
|
||||
|
||||
// MCP Servers state
|
||||
const [mcpLoading, setMcpLoading] = useState(true)
|
||||
const [showMcpDialog, setShowMcpDialog] = useState(false)
|
||||
const [mcpDialogProps, setMcpDialogProps] = useState({})
|
||||
const [mcpTotal, setMcpTotal] = useState(0)
|
||||
const [mcpCurrentPage, setMcpCurrentPage] = useState(1)
|
||||
const [mcpPageLimit, setMcpPageLimit] = useState(DEFAULT_ITEMS_PER_PAGE)
|
||||
|
||||
/* Table Pagination */
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [pageLimit, setPageLimit] = useState(DEFAULT_ITEMS_PER_PAGE)
|
||||
@@ -59,6 +74,20 @@ const Tools = () => {
|
||||
getAllToolsApi.request(params)
|
||||
}
|
||||
|
||||
const onCustomMcpPageChange = (page, limit) => {
|
||||
setMcpCurrentPage(page)
|
||||
setMcpPageLimit(limit)
|
||||
refreshCustomMcp(page, limit)
|
||||
}
|
||||
|
||||
const refreshCustomMcp = (page, limit) => {
|
||||
const params = {
|
||||
page: page || mcpCurrentPage,
|
||||
limit: limit || mcpPageLimit
|
||||
}
|
||||
getAllCustomMcpServersApi.request(params)
|
||||
}
|
||||
|
||||
const handleChange = (event, nextView) => {
|
||||
if (nextView === null) return
|
||||
localStorage.setItem('toolsDisplayStyle', nextView)
|
||||
@@ -125,6 +154,41 @@ const Tools = () => {
|
||||
refresh(currentPage, pageLimit)
|
||||
}
|
||||
|
||||
const onAuthorize = () => {
|
||||
refreshCustomMcp(mcpCurrentPage, mcpPageLimit)
|
||||
}
|
||||
|
||||
// MCP Server handlers
|
||||
const addNewCustomMcpServer = () => {
|
||||
setMcpDialogProps({ type: 'ADD' })
|
||||
setShowMcpDialog(true)
|
||||
}
|
||||
|
||||
const editCustomMcpServer = async (server) => {
|
||||
try {
|
||||
const resp = await customMcpServersApi.getCustomMcpServer(server.id)
|
||||
setMcpDialogProps({ type: 'EDIT', data: resp.data ?? server })
|
||||
} catch {
|
||||
setMcpDialogProps({ type: 'EDIT', data: server })
|
||||
}
|
||||
setShowMcpDialog(true)
|
||||
}
|
||||
|
||||
const onCustomMcpConfirm = () => {
|
||||
setShowMcpDialog(false)
|
||||
refreshCustomMcp(mcpCurrentPage, mcpPageLimit)
|
||||
}
|
||||
|
||||
const onCustomMcpCreated = async (newServerId) => {
|
||||
refreshCustomMcp(mcpCurrentPage, mcpPageLimit)
|
||||
try {
|
||||
const resp = await customMcpServersApi.getCustomMcpServer(newServerId)
|
||||
setMcpDialogProps({ type: 'EDIT', data: resp.data ?? { id: newServerId } })
|
||||
} catch {
|
||||
setMcpDialogProps({ type: 'EDIT', data: { id: newServerId } })
|
||||
}
|
||||
}
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const onSearchChange = (event) => {
|
||||
setSearch(event.target.value)
|
||||
@@ -136,10 +200,19 @@ const Tools = () => {
|
||||
)
|
||||
}
|
||||
|
||||
function filterCustomMcpServers(data) {
|
||||
const s = search.toLowerCase()
|
||||
return data.name.toLowerCase().indexOf(s) > -1 || (data.serverUrl && data.serverUrl.toLowerCase().indexOf(s) > -1)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh(currentPage, pageLimit)
|
||||
if (tabValue === 0) {
|
||||
refresh(currentPage, pageLimit)
|
||||
} else {
|
||||
refreshCustomMcp(mcpCurrentPage, mcpPageLimit)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
}, [tabValue])
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(getAllToolsApi.loading)
|
||||
@@ -151,6 +224,171 @@ const Tools = () => {
|
||||
}
|
||||
}, [getAllToolsApi.data])
|
||||
|
||||
useEffect(() => {
|
||||
setMcpLoading(getAllCustomMcpServersApi.loading)
|
||||
}, [getAllCustomMcpServersApi.loading])
|
||||
|
||||
useEffect(() => {
|
||||
if (getAllCustomMcpServersApi.data) {
|
||||
setMcpTotal(getAllCustomMcpServersApi.data.total)
|
||||
}
|
||||
}, [getAllCustomMcpServersApi.data])
|
||||
|
||||
const viewToggle = (disabled) => (
|
||||
<ToggleButtonGroup
|
||||
sx={{ borderRadius: 2, maxHeight: 40 }}
|
||||
value={view}
|
||||
color='primary'
|
||||
disabled={disabled}
|
||||
exclusive
|
||||
onChange={handleChange}
|
||||
>
|
||||
<ToggleButton
|
||||
sx={{
|
||||
borderColor: theme.palette.grey[900] + 25,
|
||||
borderRadius: 2,
|
||||
color: theme?.customization?.isDarkMode ? 'white' : 'inherit'
|
||||
}}
|
||||
variant='contained'
|
||||
value='card'
|
||||
title='Card View'
|
||||
>
|
||||
<IconLayoutGrid />
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
sx={{
|
||||
borderColor: theme.palette.grey[900] + 25,
|
||||
borderRadius: 2,
|
||||
color: theme?.customization?.isDarkMode ? 'white' : 'inherit'
|
||||
}}
|
||||
variant='contained'
|
||||
value='list'
|
||||
title='List View'
|
||||
>
|
||||
<IconList />
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
)
|
||||
|
||||
const renderCustomToolsToolbar = () => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{viewToggle(total === 0)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<PermissionButton
|
||||
permissionId={'tools:create'}
|
||||
variant='outlined'
|
||||
onClick={() => inputRef.current.click()}
|
||||
startIcon={<IconFileUpload />}
|
||||
sx={{ borderRadius: 2, height: 40 }}
|
||||
>
|
||||
Load
|
||||
</PermissionButton>
|
||||
<input style={{ display: 'none' }} ref={inputRef} type='file' hidden accept='.json' onChange={(e) => handleFileUpload(e)} />
|
||||
</Box>
|
||||
<ButtonGroup disableElevation aria-label='outlined primary button group'>
|
||||
<StyledPermissionButton
|
||||
permissionId={'tools:create'}
|
||||
variant='contained'
|
||||
onClick={addNew}
|
||||
startIcon={<IconPlus />}
|
||||
sx={{ borderRadius: 2, height: 40 }}
|
||||
>
|
||||
Create
|
||||
</StyledPermissionButton>
|
||||
</ButtonGroup>
|
||||
</Box>
|
||||
)
|
||||
|
||||
const renderMcpServersToolbar = () => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{viewToggle(mcpTotal === 0)}
|
||||
<ButtonGroup disableElevation aria-label='outlined primary button group'>
|
||||
<StyledPermissionButton
|
||||
permissionId={'tools:create'}
|
||||
variant='contained'
|
||||
onClick={addNewCustomMcpServer}
|
||||
startIcon={<IconPlus />}
|
||||
sx={{ borderRadius: 2, height: 40 }}
|
||||
>
|
||||
Add Custom MCP Server
|
||||
</StyledPermissionButton>
|
||||
</ButtonGroup>
|
||||
</Box>
|
||||
)
|
||||
|
||||
const renderCustomToolsTab = () => (
|
||||
<>
|
||||
{isLoading && (
|
||||
<Box display='grid' gridTemplateColumns='repeat(3, 1fr)' gap={gridSpacing}>
|
||||
<Skeleton variant='rounded' height={160} />
|
||||
<Skeleton variant='rounded' height={160} />
|
||||
<Skeleton variant='rounded' height={160} />
|
||||
</Box>
|
||||
)}
|
||||
{!isLoading && total > 0 && (
|
||||
<>
|
||||
{!view || view === 'card' ? (
|
||||
<Box display='grid' gridTemplateColumns='repeat(3, 1fr)' gap={gridSpacing}>
|
||||
{getAllToolsApi.data?.data?.filter(filterTools).map((data, index) => (
|
||||
<ItemCard data={data} key={index} onClick={() => edit(data)} />
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<ToolsTable data={getAllToolsApi.data?.data?.filter(filterTools) || []} isLoading={isLoading} onSelect={edit} />
|
||||
)}
|
||||
{/* Pagination and Page Size Controls */}
|
||||
<TablePagination currentPage={currentPage} limit={pageLimit} total={total} onChange={onChange} />
|
||||
</>
|
||||
)}
|
||||
{!isLoading && total === 0 && (
|
||||
<Stack sx={{ alignItems: 'center', justifyContent: 'center' }} flexDirection='column'>
|
||||
<Box sx={{ p: 2, height: 'auto' }}>
|
||||
<img style={{ objectFit: 'cover', height: '20vh', width: 'auto' }} src={ToolEmptySVG} alt='ToolEmptySVG' />
|
||||
</Box>
|
||||
<div>No Tools Created Yet</div>
|
||||
</Stack>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
const renderMcpServersTab = () => (
|
||||
<>
|
||||
{mcpLoading && (
|
||||
<Box display='grid' gridTemplateColumns='repeat(3, 1fr)' gap={gridSpacing}>
|
||||
<Skeleton variant='rounded' height={160} />
|
||||
<Skeleton variant='rounded' height={160} />
|
||||
<Skeleton variant='rounded' height={160} />
|
||||
</Box>
|
||||
)}
|
||||
{!mcpLoading && mcpTotal > 0 && (
|
||||
<>
|
||||
{!view || view === 'card' ? (
|
||||
<Box display='grid' gridTemplateColumns='repeat(3, 1fr)' gap={gridSpacing}>
|
||||
{getAllCustomMcpServersApi.data?.data?.filter(filterCustomMcpServers).map((server) => (
|
||||
<MCPItemCard key={server.id} data={server} onClick={() => editCustomMcpServer(server)} />
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<MCPServersTable
|
||||
data={getAllCustomMcpServersApi.data?.data?.filter(filterCustomMcpServers) || []}
|
||||
isLoading={mcpLoading}
|
||||
onSelect={editCustomMcpServer}
|
||||
/>
|
||||
)}
|
||||
<TablePagination currentPage={mcpCurrentPage} limit={mcpPageLimit} total={mcpTotal} onChange={onCustomMcpPageChange} />
|
||||
</>
|
||||
)}
|
||||
{!mcpLoading && mcpTotal === 0 && (
|
||||
<Stack sx={{ alignItems: 'center', justifyContent: 'center' }} flexDirection='column'>
|
||||
<Box sx={{ p: 2, height: 'auto' }}>
|
||||
<img style={{ objectFit: 'cover', height: '20vh', width: 'auto' }} src={ToolEmptySVG} alt='ToolEmptySVG' />
|
||||
</Box>
|
||||
<div>No Custom MCP Servers Added Yet</div>
|
||||
</Stack>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<MainCard>
|
||||
@@ -161,112 +399,28 @@ const Tools = () => {
|
||||
<ViewHeader
|
||||
onSearchChange={onSearchChange}
|
||||
search={true}
|
||||
searchPlaceholder='Search Tools'
|
||||
searchPlaceholder={tabValue === 0 ? 'Search Tools' : 'Search Custom MCP Servers'}
|
||||
title='Tools'
|
||||
description='External functions or APIs the agent can use to take action'
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 2,
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider'
|
||||
}}
|
||||
>
|
||||
<ToggleButtonGroup
|
||||
sx={{ borderRadius: 2, maxHeight: 40 }}
|
||||
value={view}
|
||||
color='primary'
|
||||
disabled={total === 0}
|
||||
exclusive
|
||||
onChange={handleChange}
|
||||
>
|
||||
<ToggleButton
|
||||
sx={{
|
||||
borderColor: theme.palette.grey[900] + 25,
|
||||
borderRadius: 2,
|
||||
color: theme?.customization?.isDarkMode ? 'white' : 'inherit'
|
||||
}}
|
||||
variant='contained'
|
||||
value='card'
|
||||
title='Card View'
|
||||
>
|
||||
<IconLayoutGrid />
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
sx={{
|
||||
borderColor: theme.palette.grey[900] + 25,
|
||||
borderRadius: 2,
|
||||
color: theme?.customization?.isDarkMode ? 'white' : 'inherit'
|
||||
}}
|
||||
variant='contained'
|
||||
value='list'
|
||||
title='List View'
|
||||
>
|
||||
<IconList />
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<PermissionButton
|
||||
permissionId={'tools:create'}
|
||||
variant='outlined'
|
||||
onClick={() => inputRef.current.click()}
|
||||
startIcon={<IconFileUpload />}
|
||||
sx={{ borderRadius: 2, height: 40 }}
|
||||
>
|
||||
Load
|
||||
</PermissionButton>
|
||||
<input
|
||||
style={{ display: 'none' }}
|
||||
ref={inputRef}
|
||||
type='file'
|
||||
hidden
|
||||
accept='.json'
|
||||
onChange={(e) => handleFileUpload(e)}
|
||||
/>
|
||||
</Box>
|
||||
<ButtonGroup disableElevation aria-label='outlined primary button group'>
|
||||
<StyledPermissionButton
|
||||
permissionId={'tools:create'}
|
||||
variant='contained'
|
||||
onClick={addNew}
|
||||
startIcon={<IconPlus />}
|
||||
sx={{ borderRadius: 2, height: 40 }}
|
||||
>
|
||||
Create
|
||||
</StyledPermissionButton>
|
||||
</ButtonGroup>
|
||||
</ViewHeader>
|
||||
{isLoading && (
|
||||
<Box display='grid' gridTemplateColumns='repeat(3, 1fr)' gap={gridSpacing}>
|
||||
<Skeleton variant='rounded' height={160} />
|
||||
<Skeleton variant='rounded' height={160} />
|
||||
<Skeleton variant='rounded' height={160} />
|
||||
</Box>
|
||||
)}
|
||||
{!isLoading && total > 0 && (
|
||||
<>
|
||||
{!view || view === 'card' ? (
|
||||
<Box display='grid' gridTemplateColumns='repeat(3, 1fr)' gap={gridSpacing}>
|
||||
{getAllToolsApi.data?.data?.filter(filterTools).map((data, index) => (
|
||||
<ItemCard data={data} key={index} onClick={() => edit(data)} />
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<ToolsTable
|
||||
data={getAllToolsApi.data?.data?.filter(filterTools) || []}
|
||||
isLoading={isLoading}
|
||||
onSelect={edit}
|
||||
/>
|
||||
)}
|
||||
{/* Pagination and Page Size Controls */}
|
||||
<TablePagination currentPage={currentPage} limit={pageLimit} total={total} onChange={onChange} />
|
||||
</>
|
||||
)}
|
||||
{!isLoading && total === 0 && (
|
||||
<Stack sx={{ alignItems: 'center', justifyContent: 'center' }} flexDirection='column'>
|
||||
<Box sx={{ p: 2, height: 'auto' }}>
|
||||
<img
|
||||
style={{ objectFit: 'cover', height: '20vh', width: 'auto' }}
|
||||
src={ToolEmptySVG}
|
||||
alt='ToolEmptySVG'
|
||||
/>
|
||||
</Box>
|
||||
<div>No Tools Created Yet</div>
|
||||
</Stack>
|
||||
)}
|
||||
<Tabs value={tabValue} onChange={(e, newValue) => setTabValue(newValue)} aria-label='tools tabs'>
|
||||
<Tab label='Custom Tools' />
|
||||
<Tab label='Custom MCP Servers' />
|
||||
</Tabs>
|
||||
<Box sx={{ pb: 1 }}>{tabValue === 0 ? renderCustomToolsToolbar() : renderMcpServersToolbar()}</Box>
|
||||
</Box>
|
||||
{tabValue === 0 && renderCustomToolsTab()}
|
||||
{tabValue === 1 && renderMcpServersTab()}
|
||||
</Stack>
|
||||
)}
|
||||
</MainCard>
|
||||
@@ -276,7 +430,17 @@ const Tools = () => {
|
||||
onCancel={() => setShowDialog(false)}
|
||||
onConfirm={onConfirm}
|
||||
setError={setError}
|
||||
></ToolDialog>
|
||||
/>
|
||||
<CustomMcpServerDialog
|
||||
show={showMcpDialog}
|
||||
dialogProps={mcpDialogProps}
|
||||
onCancel={() => {
|
||||
setShowMcpDialog(false)
|
||||
}}
|
||||
onConfirm={onCustomMcpConfirm}
|
||||
onAuthorize={onAuthorize}
|
||||
onCreated={onCustomMcpCreated}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user