Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions forge/comms/platformAutomation.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ class PlatformAutomationHandler {
this.client.on('request/platform-automation:forge', this.eventHandler)
}

eventHandler = async ({ userId, mcpSessionId, command, data, meta } = {}, onSuccess, onError) => {
eventHandler = async ({ userId, mcpSessionId, command, data, meta, scope } = {}, onSuccess, onError) => {
try {
let result = {}
this.app.log.info(`platform-automation request: userId=${userId} mcpSessionId=${mcpSessionId} command=${command} tool=${data?.name || 'n/a'}`)
Expand All @@ -123,6 +123,12 @@ class PlatformAutomationHandler {
const toolName = data?.name
const args = data?.input || {}

// The caller scope (readOnly plus any team restriction from the
// session token) rides with the request so tools can report and
// enforce what the session is allowed to do. Prefer the top-level
// value, falling back to meta for callers that attach it there.
const callerScope = scope ?? meta?.scope

// TODO: Probably sensible to verify that toolDefinition matches the tool to ensure no tampering has occurred
const { toolDefinition } = meta || {}

Expand Down Expand Up @@ -163,7 +169,7 @@ class PlatformAutomationHandler {
}

const { formatResponse } = require('../ee/lib/mcp/toolLoader')
const response = await tool.handler(args, { inject, app: this.app, user, mcpSessionId })
const response = await tool.handler(args, { inject, app: this.app, user, mcpSessionId, scope: callerScope })
result = formatResponse(response)
}
break
Expand Down
86 changes: 81 additions & 5 deletions forge/db/controllers/AccessToken.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ const { generateToken, generateNumericToken, sha256, randomPhrase } = require('.

const DEFAULT_TOKEN_SESSION_EXPIRY = 1000 * 60 * 30 // 30 mins session - with refresh token support

const DEFAULT_REFRESH_TOKEN_EXPIRY = 1000 * 60 * 60 * 24 * 30 // 30 days - sliding refresh token lifetime

// Concurrent refreshes of the same refresh token reuse the cached access token
// rather than each minting a new one and overwriting the row. Re-mint once the
// cached token is within this window of expiry.
const MCP_ACCESS_TOKEN_CACHE = 'mcp-oauth-access-token'
const MCP_ACCESS_TOKEN_REMAINING_LIMIT = 1000 * 60 * 5 // 5 minutes

const DEFAULT_DEVICE_OTC_EXPIRY = 1000 * 60 * 60 * 24 // 24 hours

/*
Expand Down Expand Up @@ -265,6 +273,39 @@ module.exports = {
await app.settings.set('platform:stats:token', false)
},

createMCPOAuthToken: async function (app, userId, { readOnly = false, teamIds = [] } = {}) {
const token = generateToken(32, 'ffpat')
const refreshToken = generateToken(32, 'ffpat')
const expiresAt = Date.now() + DEFAULT_TOKEN_SESSION_EXPIRY
const refreshTokenExpiresAt = Date.now() + DEFAULT_REFRESH_TOKEN_EXPIRY

await app.db.sequelize.transaction(async (t) => {
const tok = await app.db.models.AccessToken.create({
name: 'MCP Agent',
token,
refreshToken,
scope: '',
expiresAt,
refreshTokenExpiresAt,
readOnly,
adminOptIn: false,
ownerId: '' + userId,
ownerType: 'user'
}, { transaction: t })

if (teamIds.length > 0) {
const scopes = teamIds.map(teamId => ({
AccessTokenId: tok.id,
TeamId: app.db.models.Team.decodeHashid(teamId),
UserId: userId
}))
await app.db.models.AccessTokenTeamScope.bulkCreate(scopes, { transaction: t })
}
})

return { token, expiresAt, refreshToken }
},

createPersonalAccessToken: async function (app, user, scope, expiresAt, name, { readOnly = false, adminOptIn = false, teamIds = [] } = {}) {
const userId = typeof user === 'number' ? user : user.id
const token = generateToken(32, 'ffpat')
Expand Down Expand Up @@ -402,8 +443,13 @@ module.exports = {

refreshToken: async function (app, refreshToken) {
const existingToken = await app.db.models.AccessToken.byRefreshToken(refreshToken)
if (existingToken) {
const [prefix] = refreshToken.split('_')
if (!existingToken) {
return null
}
const [prefix] = refreshToken.split('_')

// Editor sessions have no refresh lifetime: rotate the refresh token each use.
if (!existingToken.refreshTokenExpiresAt) {
const tokenUpdates = {
token: generateToken(32, prefix),
refreshToken: generateToken(32, prefix),
Expand All @@ -412,7 +458,30 @@ module.exports = {
await app.db.models.AccessToken.update(tokenUpdates, { where: { refreshToken: existingToken.refreshToken } })
return tokenUpdates
}
return null

// Past its lifetime the refresh token is dead: remove the row.
if (existingToken.refreshTokenExpiresAt.getTime() < Date.now()) {
Comment thread
andypalmi marked this conversation as resolved.
await existingToken.destroy()
return null
}

// Stable refresh token: concurrent refreshes reuse the cached access token
// instead of each minting one and overwriting the row.
const cache = app.caches?.getCache?.(MCP_ACCESS_TOKEN_CACHE, { ttl: DEFAULT_TOKEN_SESSION_EXPIRY, max: 10000 })
const cacheKey = sha256(refreshToken)
const cached = await cache?.get(cacheKey)
if (cached && cached.expiresAt - Date.now() > MCP_ACCESS_TOKEN_REMAINING_LIMIT) {
return { token: cached.token, expiresAt: cached.expiresAt, refreshToken }
}

const token = generateToken(32, prefix)
const expiresAt = Date.now() + DEFAULT_TOKEN_SESSION_EXPIRY
await app.db.models.AccessToken.update(
{ token, expiresAt, refreshTokenExpiresAt: Date.now() + DEFAULT_REFRESH_TOKEN_EXPIRY },
{ where: { refreshToken: existingToken.refreshToken } }
)
await cache?.set(cacheKey, { token, expiresAt })
return { token, expiresAt, refreshToken }
},

/**
Expand All @@ -434,8 +503,15 @@ module.exports = {
})
if (accessToken) {
if (accessToken.expiresAt && accessToken.expiresAt.getTime() < Date.now()) {
await accessToken.destroy()
accessToken = null
const refreshTokenValid = accessToken.refreshTokenExpiresAt && accessToken.refreshTokenExpiresAt.getTime() > Date.now()
if (refreshTokenValid) {
// Refresh token still valid: reject the access token but keep the
// row so the client can refresh (RFC 6749 §1.5).
accessToken = null
} else {
await accessToken.destroy()
accessToken = null
}
}
}
return accessToken
Expand Down
11 changes: 11 additions & 0 deletions forge/db/controllers/AuthClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,17 @@ module.exports = {
return client
},

// Register a public MCP client (RFC 7591). No owner, no secret: PKCE only,
// and the generated clientID is the sole credential.
createMCPClient: async function (app, { name, redirectURIs } = {}) {
return app.db.models.AuthClient.create({
clientID: generateToken(32, 'ffmcp'),
ownerType: 'mcp',
name: name || 'MCP Agent',
redirectURIs: redirectURIs || []
})
},

removeClientForDevice: async function (app, device) {
const existingAuthClient = await device.getAuthClient()
if (existingAuthClient) {
Expand Down
30 changes: 30 additions & 0 deletions forge/db/migrations/20260824-01-add-mcp-authclient-fields.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* Add fields to AuthClients so MCP agents can register dynamically (RFC 7591).
*
* Existing clients (project/device editor auth) are owned by a resource via
* ownerType/ownerId and authenticate with a clientSecret. MCP clients have no
* owning resource and are public (PKCE, no secret): they reuse ownerType with
* ownerType='mcp' and add a display name and the redirect URIs approved at
* registration.
*
* name - the client_name supplied at registration
* redirectURIs - JSON array of redirect URIs the client may use
*/

const { DataTypes } = require('sequelize')

module.exports = {
up: async (context) => {
await context.addColumn('AuthClients', 'name', {
type: DataTypes.STRING,
allowNull: true,
defaultValue: null
})
await context.addColumn('AuthClients', 'redirectURIs', {
type: DataTypes.TEXT,
allowNull: true,
defaultValue: null
})
},
down: async (context) => {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* Give refresh tokens a lifetime independent of the access token.
*
* An AccessToken row holds both the access token and its refresh token. The
* access token is short-lived (expiresAt); the refresh token is meant to
* outlive it so a client can obtain a new access token after expiry
* (RFC 6749 §1.5). Without a separate expiry the refresh token's lifetime was
* tied to the access token's, so expiring the access token also discarded the
* refresh token and made refresh impossible.
*
* refreshTokenExpiresAt - when the refresh token itself expires. Null for
* tokens that do not use a refresh lifetime, which
* keep the previous behaviour.
*/

const { DataTypes } = require('sequelize')

module.exports = {
up: async (context) => {
await context.addColumn('AccessTokens', 'refreshTokenExpiresAt', {
type: DataTypes.DATE,
allowNull: true,
defaultValue: null
})
},
down: async (context) => {}
}
1 change: 1 addition & 0 deletions forge/db/models/AccessToken.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ module.exports = {
}
}
},
refreshTokenExpiresAt: { type: DataTypes.DATE },
name: { type: DataTypes.STRING },
readOnly: { type: DataTypes.BOOLEAN, defaultValue: false, allowNull: false },
adminOptIn: { type: DataTypes.BOOLEAN, defaultValue: false, allowNull: false }
Expand Down
14 changes: 13 additions & 1 deletion forge/db/models/AuthClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,19 @@ module.exports = {
}
},
ownerId: { type: DataTypes.STRING },
ownerType: { type: DataTypes.STRING }
// 'project'/'device' for editor auth clients; 'mcp' for dynamically registered MCP clients (public, no secret)
ownerType: { type: DataTypes.STRING },
name: { type: DataTypes.STRING, allowNull: true },
redirectURIs: {
type: DataTypes.TEXT,
get () {
const rawValue = this.getDataValue('redirectURIs')
return rawValue ? JSON.parse(rawValue) : []
},
set (value) {
this.setDataValue('redirectURIs', JSON.stringify(value || []))
}
}
},
associations: function (M) {
this.belongsTo(M.Project, { foreignKey: 'ownerId', constraints: false })
Expand Down
34 changes: 34 additions & 0 deletions forge/ee/lib/mcp/tools/users.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
module.exports = [
{
name: 'platform_get_active_user',
Comment thread
andypalmi marked this conversation as resolved.
title: 'Get Active User',
description: `FlowFuse platform automation tool:
Get the profile of the user this MCP session is authenticated as, along with what the session's token is allowed to do.
Returns the user's ID (hashid), username, name, email and admin flag, plus a token object:
token.readOnly - when true, write and delete tools are rejected, so only read tools can be used.
token.allTeams - when true, the token is not restricted to a subset of teams and every team the user belongs to is in reach.
token.teams - when allTeams is false, the IDs of the only teams the token may act on. Calls against any other team will fail.
Use this to resolve the current user's ID before calling tools that need a userId, such as listing browser sessions,
and to check up front whether an action the user asked for is within the session's access.`,
annotations: { readOnlyHint: true, destructiveHint: false },
inputSchema: {},
handler: async (args, { inject, scope }) => {
const response = await inject({ method: 'GET', url: '/api/v1/user' })
if (response.statusCode >= 400) {
return response
}

// No scope means the tool was not called through a scoped token (the FlowFuse
// Expert path), so the session has the user's full access.
const teams = Array.isArray(scope?.teams) ? scope.teams : []
return {
...response.json(),
token: {
readOnly: scope?.readOnly === true,
allTeams: teams.length === 0,
teams
}
}
}
}
]
Loading
Loading