Skip to content

Commit 62a5d1d

Browse files
feat: turn chatflow into MCP server (#5930)
* feat: turn chatflow into MCP server - Added '@modelcontextprotocol/sdk' version 1.12.0 - Added 'zod' version 3.25.32 - Added migrations for MCP server config - Added MCP endpoints (Streamable HTTP and SSE) - Added MCP server configuration tab - Added unit tests for MCP endpoints and services * feat: Implement MCP endpoint service with SSE support - Added a new MCP endpoint service to handle requests for chatflows, including support for SSE (Server-Sent Events) and stateless transport. - Introduced functions for handling MCP requests, SSE connections, and message routing. - Implemented error handling for service errors and session management for SSE. - Created unit tests for the MCP server service, covering configuration CRUD operations and token management. - Updated the MCP server configuration logic to ensure proper validation and error handling. - Refactored the UI component to utilize a new API hook for fetching MCP server configuration. * chore: handle error when get mcp server config * - Introduced a new chat type 'MCP' in the ChatType enum, including UI filters and backend processing. - Updated the zod dependency version specification to support both ^3.25.76 and ^4. - Fix setHasExistingConfig * fix: update chatflowCallback to enable chatflow building with active state for MCP * refactor: remove deprecated SSE transport endpoints and related logic - Removed handleGet and handleSseMessage methods from MCP endpoint controller. - Updated MCP endpoint routes to eliminate SSE-related routes. - Refactored service layer to remove SSE session management and related methods. - Updated tests to remove references to deprecated SSE functionality. - Adjusted UI component to remove SSE endpoint URL handling. * chore: update MCP endpoint routes and references to align with new API structure * chore: remove abortChatMessage when mcp request closed * optimize mcpserver dialog for dark mode * feat: implement form input schema generation for agentflow chatflows --------- Co-authored-by: Henry <hzj94@hotmail.com>
1 parent 96fca43 commit 62a5d1d

39 files changed

Lines changed: 2819 additions & 49 deletions

CONTRIBUTING.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ Flowise support different environment variables to configure your instance. You
159159
| PORT | The HTTP port Flowise runs on | Number | 3000 |
160160
| CORS_ALLOW_CREDENTIALS | Enables CORS `Access-Control-Allow-Credentials` when `true` | Boolean | false |
161161
| CORS_ORIGINS | The allowed origins for all cross-origin HTTP calls | String | |
162+
| MCP_CORS_ORIGINS | The allowed origins for MCP endpoint cross-origin calls. If unset, only non-browser (no Origin header) requests are allowed. Set to `*` to allow all origins. | String | |
162163
| IFRAME_ORIGINS | The allowed origins for iframe src embedding | String | |
163164
| FLOWISE_FILE_SIZE_LIMIT | Upload File Size Limit | String | 50mb |
164165
| DEBUG | Print logs from components | Boolean | |

docker/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ PORT=3000
7676
# NUMBER_OF_PROXIES= 1
7777
# CORS_ALLOW_CREDENTIALS=false
7878
# CORS_ORIGINS=*
79+
# MCP_CORS_ORIGINS=*
7980
# IFRAME_ORIGINS=*
8081
# FLOWISE_FILE_SIZE_LIMIT=50mb
8182
# SHOW_COMMUNITY_NODES=true

docker/docker-compose-queue-prebuilt.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ services:
7676
# SETTINGS
7777
- NUMBER_OF_PROXIES=${NUMBER_OF_PROXIES}
7878
- CORS_ORIGINS=${CORS_ORIGINS}
79+
- MCP_CORS_ORIGINS=${MCP_CORS_ORIGINS}
7980
- IFRAME_ORIGINS=${IFRAME_ORIGINS}
8081
- FLOWISE_FILE_SIZE_LIMIT=${FLOWISE_FILE_SIZE_LIMIT}
8182
- SHOW_COMMUNITY_NODES=${SHOW_COMMUNITY_NODES}

docker/docker-compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ services:
6161
# SETTINGS
6262
- NUMBER_OF_PROXIES=${NUMBER_OF_PROXIES}
6363
- CORS_ORIGINS=${CORS_ORIGINS}
64+
- MCP_CORS_ORIGINS=${MCP_CORS_ORIGINS}
6465
- IFRAME_ORIGINS=${IFRAME_ORIGINS}
6566
- FLOWISE_FILE_SIZE_LIMIT=${FLOWISE_FILE_SIZE_LIMIT}
6667
- SHOW_COMMUNITY_NODES=${SHOW_COMMUNITY_NODES}

packages/server/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ PORT=3000
7575
# NUMBER_OF_PROXIES= 1
7676
# CORS_ALLOW_CREDENTIALS=false
7777
# CORS_ORIGINS=*
78+
# MCP_CORS_ORIGINS=*
7879
# IFRAME_ORIGINS=*
7980
# FLOWISE_FILE_SIZE_LIMIT=50mb
8081
# SHOW_COMMUNITY_NODES=true

packages/server/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
"@aws-sdk/client-secrets-manager": "^3.699.0",
6666
"@bull-board/api": "^6.11.0",
6767
"@bull-board/express": "^6.11.0",
68+
"@modelcontextprotocol/sdk": "^1.10.1",
6869
"@google-cloud/logging-winston": "^6.0.0",
6970
"@keyv/redis": "^4.2.0",
7071
"@oclif/core": "4.0.7",
@@ -150,7 +151,8 @@
150151
"uuid": "^10.0.0",
151152
"winston": "^3.9.0",
152153
"winston-azure-blob": "^1.5.0",
153-
"winston-daily-rotate-file": "^5.0.0"
154+
"winston-daily-rotate-file": "^5.0.0",
155+
"zod": "^3.25.76 || ^4"
154156
},
155157
"devDependencies": {
156158
"@types/content-disposition": "0.5.8",

packages/server/src/Interface.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ export enum MODE {
3030
export enum ChatType {
3131
INTERNAL = 'INTERNAL',
3232
EXTERNAL = 'EXTERNAL',
33-
EVALUATION = 'EVALUATION'
33+
EVALUATION = 'EVALUATION',
34+
MCP = 'MCP'
3435
}
3536

3637
export enum ChatMessageRatingType {
@@ -70,6 +71,7 @@ export interface IChatFlow {
7071
apiConfig?: string
7172
category?: string
7273
type?: ChatflowType
74+
mcpServerConfig?: string
7375
workspaceId: string
7476
}
7577

@@ -403,6 +405,7 @@ export interface IExecuteFlowParams extends IPredictionQueueAppServer {
403405
parentExecutionId?: string
404406
iterationContext?: ICommonObject
405407
isTool?: boolean
408+
chatType?: ChatType
406409
}
407410

408411
export interface INodeOverrides {
@@ -421,6 +424,13 @@ export interface IVariableOverride {
421424
enabled: boolean
422425
}
423426

427+
export interface IMcpServerConfig {
428+
enabled: boolean
429+
token: string
430+
description?: string
431+
toolName?: string
432+
}
433+
424434
// DocumentStore related
425435
export * from './Interface.DocumentStore'
426436

packages/server/src/commands/base.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export abstract class BaseCommand extends Command {
1616
FLOWISE_FILE_SIZE_LIMIT: Flags.string(),
1717
PORT: Flags.string(),
1818
CORS_ORIGINS: Flags.string(),
19+
MCP_CORS_ORIGINS: Flags.string(),
1920
IFRAME_ORIGINS: Flags.string(),
2021
DEBUG: Flags.string(),
2122
NUMBER_OF_PROXIES: Flags.string(),
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
/**
2+
* Unit tests for MCP endpoint controller (packages/server/src/controllers/mcp-endpoint/index.ts)
3+
*
4+
* Tests the Express request handlers: token extraction, auth enforcement,
5+
* rate limiter middleware delegation, and request routing to the service layer.
6+
*/
7+
import { Request, Response, NextFunction } from 'express'
8+
9+
// --- Mock setup ---
10+
const mockHandleMcpRequest = jest.fn()
11+
const mockHandleMcpDeleteRequest = jest.fn()
12+
13+
jest.mock('../../services/mcp-endpoint', () => ({
14+
__esModule: true,
15+
default: {
16+
handleMcpRequest: (...args: any[]) => mockHandleMcpRequest(...args),
17+
handleMcpDeleteRequest: (...args: any[]) => mockHandleMcpDeleteRequest(...args)
18+
}
19+
}))
20+
21+
const mockGetRateLimiter = jest.fn().mockReturnValue((_req: any, _res: any, next: any) => next())
22+
23+
jest.mock('../../utils/rateLimit', () => ({
24+
RateLimiterManager: {
25+
getInstance: () => ({
26+
getRateLimiter: () => mockGetRateLimiter()
27+
})
28+
}
29+
}))
30+
31+
jest.mock('../../utils/logger', () => ({
32+
__esModule: true,
33+
default: {
34+
debug: jest.fn(),
35+
info: jest.fn(),
36+
warn: jest.fn(),
37+
error: jest.fn()
38+
}
39+
}))
40+
41+
// Import after mocking
42+
import mcpEndpointController from '.'
43+
44+
// Helper: create mock Express objects
45+
function mockReq(overrides: Record<string, any> = {}): Request {
46+
return {
47+
params: { chatflowId: 'flow-123' },
48+
headers: {},
49+
query: {},
50+
get: jest.fn(),
51+
...overrides
52+
} as unknown as Request
53+
}
54+
55+
function mockRes(): Response {
56+
const res: any = {
57+
status: jest.fn().mockReturnThis(),
58+
json: jest.fn().mockReturnThis(),
59+
locals: {}
60+
}
61+
return res as Response
62+
}
63+
64+
function mockNext(): NextFunction {
65+
return jest.fn()
66+
}
67+
68+
beforeEach(() => {
69+
jest.clearAllMocks()
70+
})
71+
72+
describe('MCP Endpoint Controller', () => {
73+
describe('authenticateToken', () => {
74+
it('returns 401 when Authorization header is missing', () => {
75+
const req = mockReq({ headers: {} })
76+
const res = mockRes()
77+
const next = mockNext()
78+
79+
mcpEndpointController.authenticateToken(req, res, next)
80+
81+
expect(res.status).toHaveBeenCalledWith(401)
82+
expect(res.json).toHaveBeenCalledWith(
83+
expect.objectContaining({
84+
jsonrpc: '2.0',
85+
error: expect.objectContaining({ code: -32001 })
86+
})
87+
)
88+
expect(next).not.toHaveBeenCalled()
89+
})
90+
91+
it('returns 401 when Authorization header is not Bearer', () => {
92+
const req = mockReq({ headers: { authorization: 'Basic dXNlcjpwYXNz' } })
93+
const res = mockRes()
94+
const next = mockNext()
95+
96+
mcpEndpointController.authenticateToken(req, res, next)
97+
98+
expect(res.status).toHaveBeenCalledWith(401)
99+
expect(next).not.toHaveBeenCalled()
100+
})
101+
102+
it('returns 401 when Bearer token is empty', () => {
103+
const req = mockReq({ headers: { authorization: 'Bearer ' } })
104+
const res = mockRes()
105+
const next = mockNext()
106+
107+
mcpEndpointController.authenticateToken(req, res, next)
108+
109+
expect(res.status).toHaveBeenCalledWith(401)
110+
expect(next).not.toHaveBeenCalled()
111+
})
112+
113+
it('sets res.locals.token and calls next on valid Bearer token', () => {
114+
const req = mockReq({ headers: { authorization: 'Bearer my-secret-token' } })
115+
const res = mockRes()
116+
const next = mockNext()
117+
118+
mcpEndpointController.authenticateToken(req, res, next)
119+
120+
expect(res.locals.token).toBe('my-secret-token')
121+
expect(next).toHaveBeenCalled()
122+
expect(res.status).not.toHaveBeenCalled()
123+
})
124+
})
125+
126+
describe('handlePost', () => {
127+
it('calls service with chatflowId and token from res.locals.token', async () => {
128+
const req = mockReq({ params: { chatflowId: 'flow-123' } })
129+
const res = mockRes()
130+
res.locals.token = 'my-secret-token'
131+
const next = mockNext()
132+
mockHandleMcpRequest.mockResolvedValue(undefined)
133+
134+
await mcpEndpointController.handlePost(req, res, next)
135+
136+
expect(mockHandleMcpRequest).toHaveBeenCalledWith('flow-123', 'my-secret-token', req, res)
137+
})
138+
139+
it('calls next(error) on unexpected errors', async () => {
140+
const req = mockReq({ params: { chatflowId: 'flow-123' } })
141+
const res = mockRes()
142+
res.locals.token = 'token'
143+
const next = mockNext()
144+
const error = new Error('Unexpected')
145+
mockHandleMcpRequest.mockRejectedValue(error)
146+
147+
await mcpEndpointController.handlePost(req, res, next)
148+
149+
expect(next).toHaveBeenCalledWith(error)
150+
})
151+
})
152+
153+
describe('handleDelete', () => {
154+
it('delegates to handleMcpDeleteRequest with chatflowId', async () => {
155+
const req = mockReq({ params: { chatflowId: 'flow-789' } })
156+
const res = mockRes()
157+
const next = mockNext()
158+
mockHandleMcpDeleteRequest.mockResolvedValue(undefined)
159+
160+
await mcpEndpointController.handleDelete(req, res, next)
161+
162+
expect(mockHandleMcpDeleteRequest).toHaveBeenCalledWith('flow-789', req, res)
163+
})
164+
})
165+
166+
describe('getRateLimiterMiddleware', () => {
167+
it('delegates to RateLimiterManager', async () => {
168+
const req = mockReq()
169+
const res = mockRes()
170+
const next = mockNext()
171+
172+
await mcpEndpointController.getRateLimiterMiddleware(req, res, next)
173+
174+
expect(mockGetRateLimiter).toHaveBeenCalled()
175+
})
176+
})
177+
})
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { NextFunction, Request, Response } from 'express'
2+
import mcpEndpointService from '../../services/mcp-endpoint'
3+
import { RateLimiterManager } from '../../utils/rateLimit'
4+
import logger from '../../utils/logger'
5+
6+
/**
7+
* Extract token from the Authorization: Bearer <token> header.
8+
* Returns null if not present or malformed.
9+
*/
10+
function extractToken(req: Request): string | null {
11+
const authHeader = req.headers.authorization
12+
if (!authHeader || !authHeader.startsWith('Bearer ')) return null
13+
const token = authHeader.slice(7).trim()
14+
return token.length > 0 ? token : null
15+
}
16+
17+
/**
18+
* Authentication middleware — validates Bearer token and attaches it to res.locals.
19+
*/
20+
const authenticateToken = (req: Request, res: Response, next: NextFunction) => {
21+
const token = extractToken(req)
22+
if (!token) {
23+
res.status(401).json({
24+
jsonrpc: '2.0',
25+
error: { code: -32001, message: 'Unauthorized: missing or invalid Authorization header. Use Bearer <token>.' },
26+
id: null
27+
})
28+
return
29+
}
30+
res.locals.token = token
31+
next()
32+
}
33+
34+
/**
35+
* Rate limiter middleware for MCP endpoint — reuses per-chatflow rate limiters.
36+
*/
37+
const getRateLimiterMiddleware = async (req: Request, res: Response, next: NextFunction) => {
38+
try {
39+
return RateLimiterManager.getInstance().getRateLimiter()(req, res, next)
40+
} catch (error) {
41+
next(error)
42+
}
43+
}
44+
45+
/**
46+
* Handle POST /api/v1/mcp/:chatflowId — MCP JSON-RPC messages
47+
* Auth: token must be in Authorization: Bearer <token> header
48+
*/
49+
const handlePost = async (req: Request, res: Response, next: NextFunction) => {
50+
try {
51+
const { chatflowId } = req.params
52+
const token = res.locals.token as string
53+
54+
logger.debug(`[MCP] POST request for chatflow: ${chatflowId}`)
55+
await mcpEndpointService.handleMcpRequest(chatflowId, token, req, res)
56+
} catch (error) {
57+
next(error)
58+
}
59+
}
60+
61+
/**
62+
* Handle DELETE /api/v1/mcp/:chatflowId — Session termination
63+
*/
64+
const handleDelete = async (req: Request, res: Response, next: NextFunction) => {
65+
try {
66+
const { chatflowId } = req.params
67+
await mcpEndpointService.handleMcpDeleteRequest(chatflowId, req, res)
68+
} catch (error) {
69+
next(error)
70+
}
71+
}
72+
73+
export default {
74+
authenticateToken,
75+
handlePost,
76+
handleDelete,
77+
getRateLimiterMiddleware
78+
}

0 commit comments

Comments
 (0)