Skip to content

Commit b1cff0e

Browse files
authored
Merge pull request #6549 from FlowFuse/6546-expert-access-mcp-with-auth
Add support for expert access to mcp servers with auth
2 parents e24d719 + fd4610d commit b1cff0e

9 files changed

Lines changed: 1138 additions & 74 deletions

File tree

forge/ee/routes/httpTokens/index.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,11 @@ module.exports = async function (app) {
4040
preHandler: app.needsPermission('project:edit')
4141
}, async (request, reply) => {
4242
const tokens = await app.db.models.AccessToken.getProjectHTTPTokens(request.project)
43+
// exclude FF-Expert auto generated HTTP MCP tokens from listing
44+
const withoutExpertMcpTokens = tokens.filter(token => !isExpertMcpToken(token))
45+
const tokensView = app.db.views.AccessToken.instanceHTTPTokenSummaryList(withoutExpertMcpTokens)
4346
reply.send({
44-
tokens: app.db.views.AccessToken.instanceHTTPTokenSummaryList(tokens),
47+
tokens: tokensView,
4548
count: tokens.length
4649
})
4750
})
@@ -51,6 +54,10 @@ module.exports = async function (app) {
5154
}, async (request, reply) => {
5255
try {
5356
const body = request.body
57+
// Prevent creation of Expert MCP Access Tokens via this route
58+
if (isExpertMcpToken({ scope: body.scope })) {
59+
throw new Error('Cannot create Expert MCP Access Token via this route')
60+
}
5461
const token = await app.db.controllers.AccessToken.createHTTPNodeToken(request.project, body.name, body.scope, body.expiresAt)
5562
// token has already been sanitised via views.AccessToken.instanceHTTPTokenSummary
5663
await app.auditLog.Project.project.httpToken.created(request.session.User, null, request.project, body)
@@ -67,6 +74,10 @@ module.exports = async function (app) {
6774
try {
6875
const oldToken = await app.db.models.AccessToken.byId(request.params.id, 'http', request.project.id)
6976
if (oldToken) {
77+
// Prevent modification of Expert MCP Access Tokens via this route
78+
if (isExpertMcpToken(oldToken)) {
79+
throw new Error('Cannot modify Expert MCP Access Token')
80+
}
7081
const body = request.body
7182
const token = await app.db.controllers.AccessToken.updateHTTPNodeToken(request.project, request.params.id, body.scope, body.expiresAt)
7283
const updates = new app.auditLog.formatters.UpdatesCollection()
@@ -99,4 +110,11 @@ module.exports = async function (app) {
99110
reply.code(400).send(resp)
100111
}
101112
})
113+
114+
function isExpertMcpToken (token) {
115+
if (!token || !token.scope) {
116+
return false
117+
}
118+
return token.scope.includes('ff-expert:mcp')
119+
}
102120
}

forge/expert/index.js

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
const fp = require('fastify-plugin')
2+
const { LRUCache } = require('lru-cache')
3+
// decorate the app with the expert helpers and cache utilities
4+
5+
module.exports = fp(async function (app, _opts) {
6+
// Get the assistant service configuration
7+
const serviceEnabled = app.config.expert?.enabled === true
8+
const expertUrl = app.config.expert?.service?.url
9+
const serviceToken = app.config.expert?.service?.token
10+
const requestTimeout = app.config.expert?.service?.requestTimeout || 60000
11+
12+
const TOKEN_TTL = app.config.expert?.tokenCache?.ttl || 5 * 60 * 1000 // Default 5 minutes
13+
const TOKEN_REMAINING_LIMIT = 15000 // token life edge window (avoid using tokens about to expire)
14+
const mcpAccessTokenCache = new LRUCache({
15+
name: 'ExpertMCPAccessTokenCache', // for testing purposes
16+
max: app.config.expert?.tokenCache?.max || 1000,
17+
ttl: TOKEN_TTL,
18+
updateAgeOnGet: false // do not update the age on get, we want it to expire after the original ttl
19+
})
20+
21+
function clearMcpAccessTokenCache (cacheKey) {
22+
if (cacheKey) {
23+
mcpAccessTokenCache.delete(cacheKey)
24+
} else {
25+
mcpAccessTokenCache.clear()
26+
}
27+
}
28+
29+
async function getOrCreateMcpAccessToken (instance, instanceType, instanceId, teamHttpSecurityFeatureEnabled) {
30+
let mcpAccessToken
31+
if (mcpAccessTokenCache.has(instanceId)) {
32+
const remainingTTL = mcpAccessTokenCache.getRemainingTTL(instanceId)
33+
if (remainingTTL > TOKEN_REMAINING_LIMIT) { // only use cached token if it has more than 5 second remaining
34+
mcpAccessToken = mcpAccessTokenCache.get(instanceId)
35+
}
36+
}
37+
38+
if (!mcpAccessToken) {
39+
const instanceSettings = await instance.getSetting('settings')
40+
const httpNodeAuth = instanceSettings?.httpNodeAuth
41+
const tokenName = 'FlowFuse Expert MCP Access Token'
42+
const scope = ['ff-expert:mcp', instanceType]
43+
if (httpNodeAuth?.type === 'flowforge-user' && teamHttpSecurityFeatureEnabled) {
44+
// FlowFuse auth is enabled for this instance
45+
const expiresAt = new Date(Date.now() + (TOKEN_TTL))
46+
const token = await app.db.controllers.AccessToken.createHTTPNodeToken(instance, tokenName, scope, expiresAt)
47+
mcpAccessToken = {
48+
scheme: 'Bearer',
49+
scope,
50+
token: token.token
51+
}
52+
} else if (httpNodeAuth?.type === 'basic') {
53+
// Basic auth is enabled - MCP client will need to use basic auth
54+
mcpAccessToken = {
55+
scheme: 'Basic',
56+
scope,
57+
token: '' // basic auth is not supported - we have no access to the password. For now, just return an empty string.
58+
}
59+
} else {
60+
// default - no auth
61+
mcpAccessToken = {
62+
scheme: '',
63+
scope,
64+
token: null
65+
}
66+
}
67+
mcpAccessTokenCache.set(instanceId, mcpAccessToken)
68+
}
69+
return mcpAccessToken
70+
}
71+
72+
function getCachedMcpAccessToken (instanceId) {
73+
if (mcpAccessTokenCache.has(instanceId)) {
74+
const remainingTTL = mcpAccessTokenCache.getRemainingTTL(instanceId)
75+
if (remainingTTL > 15000) { // only use cached token if it is not about to expire
76+
return mcpAccessTokenCache.get(instanceId)
77+
}
78+
}
79+
return null
80+
}
81+
82+
app.decorate('expert', {
83+
serviceEnabled,
84+
expertUrl,
85+
serviceToken,
86+
requestTimeout,
87+
mcp: {
88+
clearTokenCache: clearMcpAccessTokenCache,
89+
getCachedToken: getCachedMcpAccessToken,
90+
getOrCreateToken: getOrCreateMcpAccessToken
91+
}
92+
})
93+
}, { name: 'app.expert' })

forge/forge.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const config = require('./config') // eslint-disable-line n/no-unpublished-requi
1111
const containers = require('./containers')
1212
const db = require('./db')
1313
const ee = require('./ee')
14+
const expert = require('./expert')
1415
const housekeeper = require('./housekeeper')
1516
const { generatePassword } = require('./lib/userTeam')
1617
const license = require('./licensing')
@@ -251,9 +252,10 @@ module.exports = async (options = {}) => {
251252
await server.register(license)
252253
// Audit Logging
253254
await server.register(auditLog)
254-
255255
// Housekeeper
256256
await server.register(housekeeper)
257+
// Expert
258+
await server.register(expert)
257259

258260
// HTTP Server configuration
259261
if (!server.settings.get('cookieSecret')) {

0 commit comments

Comments
 (0)