diff --git a/packages/components/nodes/tools/MCP/CustomMcpServerTool/CustomMcpServerTool.ts b/packages/components/nodes/tools/MCP/CustomMcpServerTool/CustomMcpServerTool.ts new file mode 100644 index 00000000000..a2634ddc7ef --- /dev/null +++ b/packages/components/nodes/tools/MCP/CustomMcpServerTool/CustomMcpServerTool.ts @@ -0,0 +1,187 @@ +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 => { + try { + const appDataSource = options.appDataSource as DataSource + const databaseEntities = options.databaseEntities as IDatabaseEntity + if (!appDataSource || !databaseEntities?.['CustomMcpServer']) { + return [] + } + + const searchOptions = options.searchOptions || {} + const mcpServers = await appDataSource.getRepository(databaseEntities['CustomMcpServer']).find({ + where: { ...searchOptions, 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 => { + 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 { + 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 { + 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 serverRecord = await appDataSource.getRepository(databaseEntities['CustomMcpServer']).findOneBy({ id: serverId }) + 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 = {} + 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 + } + } 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 } diff --git a/packages/components/nodes/tools/MCP/CustomMcpServerTool/customMCP.png b/packages/components/nodes/tools/MCP/CustomMcpServerTool/customMCP.png new file mode 100644 index 00000000000..6950234610a Binary files /dev/null and b/packages/components/nodes/tools/MCP/CustomMcpServerTool/customMCP.png differ diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 6b77c943e4d..3360a0e5de1 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -17,3 +17,4 @@ export * from './validator' export * from './agentflowv2Generator' export * from './httpSecurity' export * from './pythonCodeValidator' +export { MCPToolkit } from '../nodes/tools/MCP/core' diff --git a/packages/components/src/utils.ts b/packages/components/src/utils.ts index 36bdeb2a50f..80de1bb0a75 100644 --- a/packages/components/src/utils.ts +++ b/packages/components/src/utils.ts @@ -610,7 +610,7 @@ const getEncryptionKey = async (): Promise => { * @param {IComponentCredentials} componentCredentials * @returns {Promise} */ -const decryptCredentialData = async (encryptedData: string): Promise => { +export const decryptCredentialData = async (encryptedData: string): Promise => { let decryptedDataStr: string if (USE_AWS_SECRETS_MANAGER && secretsManagerClient) { diff --git a/packages/server/src/Interface.Metrics.ts b/packages/server/src/Interface.Metrics.ts index e437631cc16..1d15566d74a 100644 --- a/packages/server/src/Interface.Metrics.ts +++ b/packages/server/src/Interface.Metrics.ts @@ -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' } diff --git a/packages/server/src/Interface.ts b/packages/server/src/Interface.ts index 60175e23528..1ee81f94e86 100644 --- a/packages/server/src/Interface.ts +++ b/packages/server/src/Interface.ts @@ -181,6 +181,36 @@ 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 + status: CustomMcpServerStatus | string + createdDate: Date + updatedDate: Date + workspaceId: string +} + +export interface ICustomMcpServerResponse extends Omit { + authConfig?: Record +} + export interface IComponentNodes { [key: string]: INode } diff --git a/packages/server/src/controllers/custom-mcp-servers/index.test.ts b/packages/server/src/controllers/custom-mcp-servers/index.test.ts new file mode 100644 index 00000000000..9982517d60d --- /dev/null +++ b/packages/server/src/controllers/custom-mcp-servers/index.test.ts @@ -0,0 +1,438 @@ +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 +const mockGetPageAndLimitParams = getPageAndLimitParams as jest.Mock + +const makeReq = (overrides: Partial = {}): 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 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('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) + }) + }) +}) diff --git a/packages/server/src/controllers/custom-mcp-servers/index.ts b/packages/server/src/controllers/custom-mcp-servers/index.ts new file mode 100644 index 00000000000..da0eb515203 --- /dev/null +++ b/packages/server/src/controllers/custom-mcp-servers/index.ts @@ -0,0 +1,198 @@ +import { NextFunction, Request, Response } from 'express' +import { StatusCodes } from 'http-status-codes' +import { InternalFlowiseError } from '../../errors/internalFlowiseError' +import customMcpServersService from '../../services/custom-mcp-servers' +import { getPageAndLimitParams } from '../../utils/pagination' + +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 + // Explicit allowlist — id/workspaceId/timestamps must not be overrideable by client + const mcpBody: Record = {} + 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 { page, limit } = getPageAndLimitParams(req) + 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 + // Explicit allowlist + const mcpBody: Record = {} + 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 +} diff --git a/packages/server/src/database/entities/CustomMcpServer.ts b/packages/server/src/database/entities/CustomMcpServer.ts new file mode 100644 index 00000000000..687835ba32a --- /dev/null +++ b/packages/server/src/database/entities/CustomMcpServer.ts @@ -0,0 +1,44 @@ +/* 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' }) + tools?: string + + @Column({ default: 'PENDING' }) + status: string + + @Column({ type: 'timestamp' }) + @CreateDateColumn() + createdDate: Date + + @Column({ type: 'timestamp' }) + @UpdateDateColumn() + updatedDate: Date + + @Column({ nullable: false, type: 'text' }) + workspaceId: string +} diff --git a/packages/server/src/database/entities/index.ts b/packages/server/src/database/entities/index.ts index ad19b4e2e80..94b2a4347a5 100644 --- a/packages/server/src/database/entities/index.ts +++ b/packages/server/src/database/entities/index.ts @@ -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, diff --git a/packages/server/src/database/migrations/mariadb/1766000000000-AddCustomMcpServer.ts b/packages/server/src/database/migrations/mariadb/1766000000000-AddCustomMcpServer.ts new file mode 100644 index 00000000000..33912b78f67 --- /dev/null +++ b/packages/server/src/database/migrations/mariadb/1766000000000-AddCustomMcpServer.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddCustomMcpServer1766000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + 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, + \`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\` text NOT NULL, + PRIMARY KEY (\`id\`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS \`custom_mcp_server\``) + } +} diff --git a/packages/server/src/database/migrations/mariadb/index.ts b/packages/server/src/database/migrations/mariadb/index.ts index f9d3d5fdcd8..9ea8d643418 100644 --- a/packages/server/src/database/migrations/mariadb/index.ts +++ b/packages/server/src/database/migrations/mariadb/index.ts @@ -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 { AddAuthTables1720230151482 } from '../../../enterprise/database/migrations/mariadb/1720230151482-AddAuthTables' import { AddWorkspace1725437498242 } from '../../../enterprise/database/migrations/mariadb/1725437498242-AddWorkspace' import { AddWorkspaceShared1726654922034 } from '../../../enterprise/database/migrations/mariadb/1726654922034-AddWorkspaceShared' @@ -111,5 +112,6 @@ export const mariadbMigrations = [ AddChatFlowNameIndex1759424809984, FixDocumentStoreFileChunkLongText1765000000000, AddApiKeyPermission1765360298674, - AddReasonContentToChatMessage1764759496768 + AddReasonContentToChatMessage1764759496768, + AddCustomMcpServer1766000000000 ] diff --git a/packages/server/src/database/migrations/mysql/1766000000000-AddCustomMcpServer.ts b/packages/server/src/database/migrations/mysql/1766000000000-AddCustomMcpServer.ts new file mode 100644 index 00000000000..bb56c8ae672 --- /dev/null +++ b/packages/server/src/database/migrations/mysql/1766000000000-AddCustomMcpServer.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddCustomMcpServer1766000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + 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, + \`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\` text NOT NULL, + PRIMARY KEY (\`id\`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS \`custom_mcp_server\``) + } +} diff --git a/packages/server/src/database/migrations/mysql/index.ts b/packages/server/src/database/migrations/mysql/index.ts index a22168aefcf..1791c196d68 100644 --- a/packages/server/src/database/migrations/mysql/index.ts +++ b/packages/server/src/database/migrations/mysql/index.ts @@ -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 { AddAuthTables1720230151482 } from '../../../enterprise/database/migrations/mysql/1720230151482-AddAuthTables' import { AddWorkspace1720230151484 } from '../../../enterprise/database/migrations/mysql/1720230151484-AddWorkspace' import { AddWorkspaceShared1726654922034 } from '../../../enterprise/database/migrations/mysql/1726654922034-AddWorkspaceShared' @@ -113,5 +114,6 @@ export const mysqlMigrations = [ AddChatFlowNameIndex1759424828558, FixDocumentStoreFileChunkLongText1765000000000, AddApiKeyPermission1765360298674, - AddReasonContentToChatMessage1764759496768 + AddReasonContentToChatMessage1764759496768, + AddCustomMcpServer1766000000000 ] diff --git a/packages/server/src/database/migrations/postgres/1766000000000-AddCustomMcpServer.ts b/packages/server/src/database/migrations/postgres/1766000000000-AddCustomMcpServer.ts new file mode 100644 index 00000000000..090bfc25bfc --- /dev/null +++ b/packages/server/src/database/migrations/postgres/1766000000000-AddCustomMcpServer.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddCustomMcpServer1766000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + 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, + "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) + );` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS custom_mcp_server`) + } +} diff --git a/packages/server/src/database/migrations/postgres/index.ts b/packages/server/src/database/migrations/postgres/index.ts index 9303033e02b..2719f65acc7 100644 --- a/packages/server/src/database/migrations/postgres/index.ts +++ b/packages/server/src/database/migrations/postgres/index.ts @@ -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 { AddAuthTables1720230151482 } from '../../../enterprise/database/migrations/postgres/1720230151482-AddAuthTables' import { AddWorkspace1720230151484 } from '../../../enterprise/database/migrations/postgres/1720230151484-AddWorkspace' import { AddWorkspaceShared1726654922034 } from '../../../enterprise/database/migrations/postgres/1726654922034-AddWorkspaceShared' @@ -109,5 +110,6 @@ export const postgresMigrations = [ AddTextToSpeechToChatFlow1759419194331, AddChatFlowNameIndex1759424903973, AddApiKeyPermission1765360298674, - AddReasonContentToChatMessage1764759496768 + AddReasonContentToChatMessage1764759496768, + AddCustomMcpServer1766000000000 ] diff --git a/packages/server/src/database/migrations/sqlite/1766000000000-AddCustomMcpServer.ts b/packages/server/src/database/migrations/sqlite/1766000000000-AddCustomMcpServer.ts new file mode 100644 index 00000000000..04280df7a19 --- /dev/null +++ b/packages/server/src/database/migrations/sqlite/1766000000000-AddCustomMcpServer.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddCustomMcpServer1766000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + 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, "status" varchar NOT NULL DEFAULT ('PENDING'), "createdDate" datetime NOT NULL DEFAULT (datetime('now')), "updatedDate" datetime NOT NULL DEFAULT (datetime('now')), "workspaceId" text NOT NULL);` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "custom_mcp_server"`) + } +} diff --git a/packages/server/src/database/migrations/sqlite/index.ts b/packages/server/src/database/migrations/sqlite/index.ts index 90b42a2475f..c3a48f4fead 100644 --- a/packages/server/src/database/migrations/sqlite/index.ts +++ b/packages/server/src/database/migrations/sqlite/index.ts @@ -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 { AddAuthTables1720230151482 } from '../../../enterprise/database/migrations/sqlite/1720230151482-AddAuthTables' import { AddWorkspace1720230151484 } from '../../../enterprise/database/migrations/sqlite/1720230151484-AddWorkspace' import { AddWorkspaceShared1726654922034 } from '../../../enterprise/database/migrations/sqlite/1726654922034-AddWorkspaceShared' @@ -105,5 +106,6 @@ export const sqliteMigrations = [ AddTextToSpeechToChatFlow1759419136055, AddChatFlowNameIndex1759424923093, AddApiKeyPermission1765360298674, - AddReasonContentToChatMessage1764759496768 + AddReasonContentToChatMessage1764759496768, + AddCustomMcpServer1766000000000 ] diff --git a/packages/server/src/routes/custom-mcp-servers/index.ts b/packages/server/src/routes/custom-mcp-servers/index.ts new file mode 100644 index 00000000000..caadf3fdf0e --- /dev/null +++ b/packages/server/src/routes/custom-mcp-servers/index.ts @@ -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 diff --git a/packages/server/src/routes/index.ts b/packages/server/src/routes/index.ts index bb7ce05d896..6acdfe433a7 100644 --- a/packages/server/src/routes/index.ts +++ b/packages/server/src/routes/index.ts @@ -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' @@ -124,6 +125,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('/auth', authRouter) router.use('/audit', IdentityManager.checkFeatureByPlan('feat:login-activity'), auditRouter) diff --git a/packages/server/src/services/custom-mcp-servers/index.test.ts b/packages/server/src/services/custom-mcp-servers/index.test.ts new file mode 100644 index 00000000000..b05e357821d --- /dev/null +++ b/packages/server/src/services/custom-mcp-servers/index.test.ts @@ -0,0 +1,585 @@ +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 mockQueryBuilder = { + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getManyAndCount: mockGetManyAndCount +} +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() +jest.mock('flowise-components', () => ({ + MCPToolkit: jest.fn().mockImplementation(() => ({ + initialize: mockInitialize, + _tools: { tools: [{ name: 'tool1' }] }, + client: { close: mockClose } + })) +})) + +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 = {}) => ({ + id: 'mcp-1', + name: 'Test Server', + serverUrl: 'https://api.example.com/mcp/token123', + iconSrc: null, + color: null, + authType: 'NONE', + authConfig: null, + tools: null, + 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 not filter by workspaceId when not provided', async () => { + mockGetManyAndCount.mockResolvedValue([[], 0]) + + await customMcpServersService.getAllCustomMcpServers(undefined) + + expect(mockQueryBuilder.andWhere).not.toHaveBeenCalled() + }) + + 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 () => { + mockFindOneBy.mockResolvedValue(makeRecord({ authConfig: null })) + + const result = await customMcpServersService.getCustomMcpServerById('mcp-1', 'ws-1') + + expect(mockFindOneBy).toHaveBeenCalledWith({ id: 'mcp-1', workspaceId: 'ws-1' }) + 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' } }) + mockFindOneBy.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 () => { + mockFindOneBy.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 () => { + mockFindOneBy.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 () => { + mockFindOneBy.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 () => { + mockFindOneBy.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 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 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 () => { + mockFindOneBy.mockResolvedValue(null) + + await expect(customMcpServersService.getDiscoveredTools('mcp-1', 'ws-1')).rejects.toThrow('not found') + }) + + it('should return empty array when tools is null', async () => { + mockFindOneBy.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' }] } + mockFindOneBy.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 () => { + mockFindOneBy.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 () => { + mockFindOneBy.mockResolvedValue(makeRecord({ tools: JSON.stringify({ other: 'data' }) })) + + const result = await customMcpServersService.getDiscoveredTools('mcp-1', 'ws-1') + + expect(result).toEqual([]) + }) + }) +}) diff --git a/packages/server/src/services/custom-mcp-servers/index.ts b/packages/server/src/services/custom-mcp-servers/index.ts new file mode 100644 index 00000000000..192f6b273cf --- /dev/null +++ b/packages/server/src/services/custom-mcp-servers/index.ts @@ -0,0 +1,341 @@ +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 } 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 = '************' + +/** + * 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 validateServerUrl = (url: string): void => { + let parsed: URL + try { + parsed = new URL(url) + } catch { + throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, `Invalid Server URL: "${url}" is not a valid 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(':', '')}"` + ) + } +} + +const createCustomMcpServer = async (requestBody: any, orgId: string): Promise => { + try { + const appServer = getRunningExpressApp() + const newRecord = new CustomMcpServer() + if (requestBody.serverUrl) validateServerUrl(requestBody.serverUrl) + + // Encrypt authConfig if present + if (requestBody.authConfig && typeof requestBody.authConfig === 'object') { + 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') + + if (page > 0 && limit > 0) { + queryBuilder.skip((page - 1) * limit) + queryBuilder.take(limit) + } + if (workspaceId) queryBuilder.andWhere('custom_mcp_server.workspaceId = :workspaceId', { workspaceId }) + 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 => { + try { + const appServer = getRunningExpressApp() + const dbResponse = await appServer.AppDataSource.getRepository(CustomMcpServer).findOneBy({ + id, + workspaceId + }) + 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 + if (masked.headers && typeof masked.headers === 'object') { + const redactedHeaders: Record = {} + 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 => { + 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`) + } + + // Preserve the real serverUrl if the client sent back a masked value + if (requestBody.serverUrl && requestBody.serverUrl.includes(REDACTED_VALUE)) { + requestBody.serverUrl = record.serverUrl + } else if (requestBody.serverUrl) { + validateServerUrl(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 = {} + for (const [key, value] of Object.entries(requestBody.authConfig.headers as Record)) { + // Keep existing value if client sent the redacted placeholder + if (value === REDACTED_VALUE && key in (existingDecrypted.headers as Record)) { + mergedHeaders[key] = (existingDecrypted.headers as Record)[key] + } else { + mergedHeaders[key] = value + } + } + requestBody.authConfig = { ...requestBody.authConfig, headers: mergedHeaders } + } + } catch { + // existing authConfig couldn't be decrypted — use incoming as-is + } + } + 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 => { + 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 => { + 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 = {} + 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 + } + } + } 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') + await toolkit.initialize() + + const discoveredTools = toolkit._tools || [] + const toolsJson = JSON.stringify(discoveredTools) + record.tools = toolsJson + record.status = CustomMcpServerStatus.AUTHORIZED + await repo.save(record) + + logger.debug(`[CustomMcpServerService]: Authorized Custom MCP server ${id}, discovered ${discoveredTools.length} tools`) + + return sanitizeCustomMcpServer(record) + } catch (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[]> => { + 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 (!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 +} diff --git a/packages/server/src/utils/index.ts b/packages/server/src/utils/index.ts index 7ea3adbfd8e..3e48c6a1156 100644 --- a/packages/server/src/utils/index.ts +++ b/packages/server/src/utils/index.ts @@ -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 } /** diff --git a/packages/ui/src/api/custommcpservers.js b/packages/ui/src/api/custommcpservers.js new file mode 100644 index 00000000000..a0c173aa00d --- /dev/null +++ b/packages/ui/src/api/custommcpservers.js @@ -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 +} diff --git a/packages/ui/src/store/constant.js b/packages/ui/src/store/constant.js index 627959fa1f2..a5641280030 100644 --- a/packages/ui/src/store/constant.js +++ b/packages/ui/src/store/constant.js @@ -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' +} diff --git a/packages/ui/src/ui-component/cards/MCPItemCard.jsx b/packages/ui/src/ui-component/cards/MCPItemCard.jsx new file mode 100644 index 00000000000..6868ad1fadd --- /dev/null +++ b/packages/ui/src/ui-component/cards/MCPItemCard.jsx @@ -0,0 +1,218 @@ +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 + + let toolCount = 0 + try { + const parsed = data.tools ? JSON.parse(data.tools) : [] + if (Array.isArray(parsed?.tools)) { + toolCount = parsed.tools.length + } + } catch (_) { + /* ignore */ + } + + const statusColors = getStatusColors(data.status, isDarkMode, theme) + + return ( + + + + {/* Header: icon + name + status badge */} + +
+ {data.iconSrc && ( +
+ )} + {!data.iconSrc && data.color && ( +
+ )} + + {data.name} + + {/* Status badge */} + {data.status && ( +
+
+ + {data.status} + +
+ )} +
+ + {/* Server URL */} + {data.serverUrl && ( + + + {data.serverUrl} + + + )} + + + {/* Footer: tool count badge */} + +
+ + {toolCount} {toolCount === 1 ? 'tool' : 'tools'} +
+
+ + + + ) +} + +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 + iconSrc: PropTypes.string, + color: PropTypes.string + }).isRequired, + onClick: PropTypes.func +} + +export default MCPItemCard diff --git a/packages/ui/src/ui-component/table/MCPServersTable.jsx b/packages/ui/src/ui-component/table/MCPServersTable.jsx new file mode 100644 index 00000000000..bc886f5e5cc --- /dev/null +++ b/packages/ui/src/ui-component/table/MCPServersTable.jsx @@ -0,0 +1,216 @@ +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 ( +
+
+ {status} +
+ ) +} + +StatusBadge.propTypes = { + status: PropTypes.string +} + +export const MCPServersTable = ({ data, isLoading, onSelect }) => { + const theme = useTheme() + const customization = useSelector((state) => state.customization) + + return ( + + + + + Name + Server URL + Status + Tools +   + + + + {isLoading ? ( + <> + {[0, 1].map((i) => ( + + {[0, 1, 2, 3, 4].map((j) => ( + + + + ))} + + ))} + + ) : ( + <> + {data?.map((row) => { + let toolCount = 0 + try { + const parsed = JSON.parse(row.tools) + toolCount = Array.isArray(parsed?.tools) ? parsed.tools.length : 0 + } catch (_) { + /* ignore */ + } + return ( + + + + {row.iconSrc && ( +
+ )} + {!row.iconSrc && row.color && ( +
+ )} + + + + + + + + + {row.serverUrl} + + + + {row.status && } + + + + + {toolCount} {toolCount === 1 ? 'tool' : 'tools'} + + + + + + ) + })} + + )} + +
+
+ ) +} + +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 + iconSrc: PropTypes.string, + color: PropTypes.string + }) + ), + isLoading: PropTypes.bool, + onSelect: PropTypes.func +} diff --git a/packages/ui/src/views/tools/CustomMcpServerDialog.jsx b/packages/ui/src/views/tools/CustomMcpServerDialog.jsx new file mode 100644 index 00000000000..3be720a16f1 --- /dev/null +++ b/packages/ui/src/views/tools/CustomMcpServerDialog.jsx @@ -0,0 +1,614 @@ +import { createPortal } from 'react-dom' +import PropTypes from 'prop-types' +import { useState, useEffect } from 'react' +import { useDispatch } from 'react-redux' +import { enqueueSnackbar as enqueueSnackbarAction, closeSnackbar as closeSnackbarAction } from '@/store/actions' + +import { + Box, + Button, + Typography, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Stack, + OutlinedInput, + FormHelperText, + Select, + MenuItem, + FormControl, + Chip, + CircularProgress, + Accordion, + AccordionSummary, + AccordionDetails +} from '@mui/material' +import ExpandMoreIcon from '@mui/icons-material/ExpandMore' +import { useTheme } from '@mui/material/styles' +import { StyledButton } from '@/ui-component/button/StyledButton' +import { TooltipWithParser } from '@/ui-component/tooltip/TooltipWithParser' +import ConfirmDialog from '@/ui-component/dialog/ConfirmDialog' +import { StyledPermissionButton } from '@/ui-component/button/RBACButtons' +import { StatusBadge } from '@/ui-component/table/MCPServersTable' + +// Icons +import { IconX, IconPlugConnected, IconEdit, IconPlus, IconTrash } from '@tabler/icons-react' + +// API +import customMcpServersApi from '@/api/custommcpservers' + +// Hooks +import useConfirm from '@/hooks/useConfirm' +import useNotifier from '@/utils/useNotifier' +import { HIDE_CANVAS_DIALOG, SHOW_CANVAS_DIALOG } from '@/store/actions' +import { MCP_SERVER_STATUS, MCP_AUTH_TYPE } from '@/store/constant' +import { generateRandomGradient } from '@/utils/genericHelper' + +const CustomMcpServerDialog = ({ show, dialogProps, onCancel, onConfirm, onAuthorize }) => { + const portalElement = document.getElementById('portal') + const dispatch = useDispatch() + const theme = useTheme() + + useNotifier() + const { confirm } = useConfirm() + const enqueueSnackbar = (...args) => dispatch(enqueueSnackbarAction(...args)) + const closeSnackbar = (...args) => dispatch(closeSnackbarAction(...args)) + + const [serverId, setServerId] = useState('') + const [serverName, setServerName] = useState('') + const [serverUrl, setServerUrl] = useState('') + const [iconSrc, setIconSrc] = useState('') + const [color, setColor] = useState('') + const [authType, setAuthType] = useState(MCP_AUTH_TYPE.NONE) + const [headers, setHeaders] = useState([{ key: '', value: '' }]) + const [status, setStatus] = useState(MCP_SERVER_STATUS.PENDING) + const [discoveredTools, setDiscoveredTools] = useState([]) + const [authorizing, setAuthorizing] = useState(false) + const [isEditing, setIsEditing] = useState(false) + const [serverUrlError, setServerUrlError] = useState('') + + const validateServerUrl = (url) => { + if (!url) { + setServerUrlError('Server URL is required') + return false + } + try { + const parsed = new URL(url) + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + setServerUrlError('Only http and https URLs are allowed') + return false + } + } catch { + setServerUrlError('Enter a valid URL (e.g. https://example.com/mcp)') + return false + } + setServerUrlError('') + return true + } + + useEffect(() => { + if (show) dispatch({ type: SHOW_CANVAS_DIALOG }) + else dispatch({ type: HIDE_CANVAS_DIALOG }) + return () => dispatch({ type: HIDE_CANVAS_DIALOG }) + }, [show, dispatch]) + + useEffect(() => { + if (dialogProps.type === 'EDIT' && dialogProps.data) { + setServerId(dialogProps.data.id) + setServerName(dialogProps.data.name) + setServerUrl(dialogProps.data.serverUrl) + setIconSrc(dialogProps.data.iconSrc || '') + setColor(dialogProps.data.color || '') + setAuthType(dialogProps.data.authType || MCP_AUTH_TYPE.NONE) + setStatus(dialogProps.data.status || MCP_SERVER_STATUS.PENDING) + // Parse discovered tools + if (dialogProps.data.tools) { + try { + const parsed = JSON.parse(dialogProps.data.tools) + const tools = Array.isArray(parsed?.tools) ? parsed.tools : [] + setDiscoveredTools(tools) + } catch { + setDiscoveredTools([]) + } + } else { + setDiscoveredTools([]) + } + // Parse header fields from authConfig + if (dialogProps.data.authType === MCP_AUTH_TYPE.CUSTOM_HEADERS && dialogProps.data.authConfig?.headers) { + const hdrs = dialogProps.data.authConfig.headers + const entries = Object.entries(hdrs).map(([key, value]) => ({ key, value })) + setHeaders(entries.length > 0 ? entries : [{ key: '', value: '' }]) + } else { + setHeaders([{ key: '', value: '' }]) + } + setIsEditing(false) + setServerUrlError('') + } else if (dialogProps.type === 'ADD') { + setServerId('') + setServerName('') + setServerUrl('') + setIconSrc('') + setColor('') + setAuthType(MCP_AUTH_TYPE.NONE) + setHeaders([{ key: '', value: '' }]) + setStatus(MCP_SERVER_STATUS.PENDING) + setDiscoveredTools([]) + setServerUrlError('') + setIsEditing(true) + } + }, [dialogProps]) + + const showSnackbar = (message, variant = 'success') => { + enqueueSnackbar({ + message, + options: { + key: new Date().getTime() + Math.random(), + variant, + action: (key) => ( + + ), + ...(variant === 'error' && { persist: true }) + } + }) + } + + const getErrorMsg = (error) => + typeof error.response?.data === 'object' ? error.response.data.message : error.response?.data || error.message + + const addNewServer = async () => { + if (!validateServerUrl(serverUrl)) return + try { + const body = { + name: serverName, + serverUrl, + iconSrc: iconSrc || undefined, + color: color || generateRandomGradient(), + authType + } + if (authType === MCP_AUTH_TYPE.CUSTOM_HEADERS) { + const hdrs = {} + headers.forEach(({ key, value }) => { + if (key) hdrs[key] = value + }) + if (Object.keys(hdrs).length > 0) body.authConfig = { headers: hdrs } + } + const resp = await customMcpServersApi.createCustomMcpServer(body) + if (resp.data) { + showSnackbar('MCP Server added') + onConfirm(resp.data.id) + } + } catch (error) { + showSnackbar(`Failed to add MCP Server: ${getErrorMsg(error)}`, 'error') + onCancel() + } + } + + const saveServer = async () => { + if (!validateServerUrl(serverUrl)) return + try { + const body = { + name: serverName, + serverUrl, + iconSrc: iconSrc || undefined, + color: color || undefined, + authType + } + if (authType === MCP_AUTH_TYPE.CUSTOM_HEADERS) { + const hdrs = {} + headers.forEach(({ key, value }) => { + if (key) hdrs[key] = value + }) + if (Object.keys(hdrs).length > 0) body.authConfig = { headers: hdrs } + } else { + body.authConfig = null // Clear authConfig if switching to no authentication + } + const resp = await customMcpServersApi.updateCustomMcpServer(serverId, body) + if (resp.data) { + showSnackbar('MCP Server saved') + onConfirm(resp.data.id) + } + } catch (error) { + showSnackbar(`Failed to save MCP Server: ${getErrorMsg(error)}`, 'error') + onCancel() + } + } + + const authorizeServer = async () => { + const targetId = serverId + if (!targetId) return + setAuthorizing(true) + try { + const resp = await customMcpServersApi.authorizeCustomMcpServer(targetId) + if (resp.data) { + setStatus(resp.data.status) + if (resp.data.tools) { + try { + const parsed = JSON.parse(resp.data.tools) || {} + const tools = Array.isArray(parsed?.tools) ? parsed.tools : [] + setDiscoveredTools(tools) + showSnackbar(`Connected! Discovered ${tools.length} tools`) + onAuthorize(targetId) + } catch { + setDiscoveredTools([]) + } + } + } + } catch (error) { + setStatus(MCP_SERVER_STATUS.ERROR) + showSnackbar(`Authorization failed: ${getErrorMsg(error)}`, 'error') + } finally { + setAuthorizing(false) + } + } + + const cancelEditing = () => { + if (dialogProps.data) { + setServerName(dialogProps.data.name) + setServerUrl(dialogProps.data.serverUrl) + setIconSrc(dialogProps.data.iconSrc || '') + setColor(dialogProps.data.color || '') + setAuthType(dialogProps.data.authType || MCP_AUTH_TYPE.NONE) + if (dialogProps.data.authType === MCP_AUTH_TYPE.CUSTOM_HEADERS && dialogProps.data.authConfig?.headers) { + const hdrs = dialogProps.data.authConfig.headers + const entries = Object.entries(hdrs).map(([key, value]) => ({ key, value })) + setHeaders(entries.length > 0 ? entries : [{ key: '', value: '' }]) + } else { + setHeaders([{ key: '', value: '' }]) + } + setServerUrlError('') + } + setIsEditing(false) + } + + const deleteServer = async () => { + const isConfirmed = await confirm({ + title: 'Delete MCP Server', + description: `Delete MCP server "${serverName}"?`, + confirmButtonName: 'Delete', + cancelButtonName: 'Cancel' + }) + if (isConfirmed) { + try { + const resp = await customMcpServersApi.deleteCustomMcpServer(serverId) + if (resp.data) { + showSnackbar('MCP Server deleted') + onConfirm() + } + } catch (error) { + showSnackbar(`Failed to delete MCP Server: ${getErrorMsg(error)}`, 'error') + onCancel() + } + } + } + + const component = show ? ( + + + + + + {dialogProps.type === 'ADD' ? 'Add Custom MCP Server' : serverName || 'Custom MCP Server'} + + {dialogProps.type === 'EDIT' && } + + + {dialogProps.type === 'EDIT' && !isEditing && ( + } + onClick={() => setIsEditing(true)} + > + Edit + + )} + + + + + {/* View Mode (read-only) */} + {dialogProps.type === 'EDIT' && !isEditing && ( + + + + Server Name + + {serverName} + + + + Server URL + + + {serverUrl} + + + {iconSrc && ( + + + Icon Source + + {iconSrc} + + )} + + + Authentication + + + {authType === MCP_AUTH_TYPE.NONE ? 'No Authentication' : 'Custom Headers'} + + + {authType === MCP_AUTH_TYPE.CUSTOM_HEADERS && headers.some((h) => h.key) && ( + + + Headers + + {headers + .filter((h) => h.key) + .map((h, idx) => ( + + {h.key}: •••••••• + + ))} + + )} + + )} + + {/* Edit Mode (input fields) */} + {isEditing && ( + + + + + Server Name +  * + + + + setServerName(e.target.value)} + /> + + + + + Server URL +  * + + + + { + setServerUrl(e.target.value) + if (serverUrlError) validateServerUrl(e.target.value) + }} + onBlur={(e) => validateServerUrl(e.target.value)} + /> + {serverUrlError && {serverUrlError}} + + + + Icon Source + + setIconSrc(e.target.value)} + /> + + + {/* Authentication */} + + + Authentication + + + + + + + {authType === MCP_AUTH_TYPE.CUSTOM_HEADERS && ( + + {headers.map((header, index) => ( + + + {index === 0 && Header Key} + { + const updated = [...headers] + updated[index] = { ...updated[index], key: e.target.value } + setHeaders(updated) + }} + /> + + + {index === 0 && Header Value} + { + const updated = [...headers] + updated[index] = { ...updated[index], value: e.target.value } + setHeaders(updated) + }} + /> + + + {headers.length > 1 && ( + + )} + + + ))} + + + + + )} + + )} + + {/* Discovered Tools */} + {dialogProps.type === 'EDIT' && !isEditing && discoveredTools.length > 0 && ( + + }> + + Discovered Tools + + + + + + {discoveredTools.map((tool, index) => ( + + + {tool.name} + + {tool.description && ( + + {tool.description} + + )} + {tool.inputSchema?.properties && ( + + {Object.keys(tool.inputSchema.properties).map((param) => ( + + ))} + + )} + + ))} + + + + )} + + + + {dialogProps.type === 'EDIT' && ( + + Delete + + )} + + + {dialogProps.type === 'EDIT' && !isEditing && ( + : } + > + {authorizing ? 'Connecting...' : 'Authorize'} + + )} + {isEditing && ( + <> + {dialogProps.type === 'EDIT' && ( + + Cancel + + )} + (dialogProps.type === 'ADD' ? addNewServer() : saveServer())} + > + {dialogProps.type === 'ADD' ? 'Add' : 'Save'} + + + )} + + + + + ) : null + + return createPortal(component, portalElement) +} + +CustomMcpServerDialog.propTypes = { + show: PropTypes.bool, + dialogProps: PropTypes.object, + onCancel: PropTypes.func, + onConfirm: PropTypes.func, + onAuthorize: PropTypes.func +} + +export default CustomMcpServerDialog diff --git a/packages/ui/src/views/tools/index.jsx b/packages/ui/src/views/tools/index.jsx index 10dff435140..a87d4c62afc 100644 --- a/packages/ui/src/views/tools/index.jsx +++ b/packages/ui/src/views/tools/index.jsx @@ -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,31 @@ 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 [search, setSearch] = useState('') const onSearchChange = (event) => { setSearch(event.target.value) @@ -136,10 +190,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 +214,200 @@ const Tools = () => { } }, [getAllToolsApi.data]) + useEffect(() => { + setMcpLoading(getAllCustomMcpServersApi.loading) + }, [getAllCustomMcpServersApi.loading]) + + useEffect(() => { + if (getAllCustomMcpServersApi.data) { + setMcpTotal(getAllCustomMcpServersApi.data.total) + } + }, [getAllCustomMcpServersApi.data]) + + const renderCustomToolsTab = () => ( + <> + + + + + + + + + + + inputRef.current.click()} + startIcon={} + sx={{ borderRadius: 2, height: 40 }} + > + Load + + handleFileUpload(e)} + /> + + + } + sx={{ borderRadius: 2, height: 40 }} + > + Create + + + + {isLoading && ( + + + + + + )} + {!isLoading && total > 0 && ( + <> + {!view || view === 'card' ? ( + + {getAllToolsApi.data?.data?.filter(filterTools).map((data, index) => ( + edit(data)} /> + ))} + + ) : ( + + )} + {/* Pagination and Page Size Controls */} + + + )} + {!isLoading && total === 0 && ( + + + ToolEmptySVG + +
No Tools Created Yet
+
+ )} + + ) + + const renderMcpServersTab = () => ( + <> + + + + + + + + + + + } + sx={{ borderRadius: 2, height: 40 }} + > + Add Custom MCP Server + + + + {mcpLoading && ( + + + + + + )} + {!mcpLoading && mcpTotal > 0 && ( + <> + {!view || view === 'card' ? ( + + {getAllCustomMcpServersApi.data?.data?.filter(filterCustomMcpServers).map((server) => ( + editCustomMcpServer(server)} /> + ))} + + ) : ( + + )} + + + )} + {!mcpLoading && mcpTotal === 0 && ( + + + ToolEmptySVG + +
No Custom MCP Servers Added Yet
+
+ )} + + ) + return ( <> @@ -161,112 +418,21 @@ const Tools = () => { + setTabValue(newValue)} + aria-label='tools tabs' + sx={{ borderBottom: 1, borderColor: 'divider' }} > - - - - - - - - - - inputRef.current.click()} - startIcon={} - sx={{ borderRadius: 2, height: 40 }} - > - Load - - handleFileUpload(e)} - /> - - - } - sx={{ borderRadius: 2, height: 40 }} - > - Create - - - - {isLoading && ( - - - - - - )} - {!isLoading && total > 0 && ( - <> - {!view || view === 'card' ? ( - - {getAllToolsApi.data?.data?.filter(filterTools).map((data, index) => ( - edit(data)} /> - ))} - - ) : ( - - )} - {/* Pagination and Page Size Controls */} - - - )} - {!isLoading && total === 0 && ( - - - ToolEmptySVG - -
No Tools Created Yet
-
- )} + + + + {tabValue === 0 && renderCustomToolsTab()} + {tabValue === 1 && renderMcpServersTab()} )}
@@ -276,7 +442,16 @@ const Tools = () => { onCancel={() => setShowDialog(false)} onConfirm={onConfirm} setError={setError} - > + /> + { + setShowMcpDialog(false) + }} + onConfirm={onCustomMcpConfirm} + onAuthorize={onAuthorize} + /> ) }