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
4 changes: 2 additions & 2 deletions forge/ee/db/models/MCPRegistration.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,13 @@ module.exports = {
if (includeInstance) {
include.push({
model: M.Project,
attributes: ['hashid', 'id', 'name', 'slug', 'links', 'url', 'ApplicationId'],
attributes: ['hashid', 'id', 'name', 'slug', 'links', 'url', 'ApplicationId', 'state'],
required: false,
on: instanceOwnershipJoin
})
include.push({
model: M.Device,
attributes: ['hashid', 'id', 'name', 'type', 'ApplicationId'],
attributes: ['hashid', 'id', 'name', 'type', 'ApplicationId', 'state'],
required: false,
on: deviceOwnershipJoin,
include: {
Expand Down
14 changes: 13 additions & 1 deletion forge/lib/permissions.js
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,19 @@ const Permissions = {
// MCP
'team:mcp:list': { description: 'List the team MCP endpoints', role: Roles.Member },

'assistant:call': { description: 'Call the Assistant service' }
'assistant:call': { description: 'Call the Assistant service' },

// FF Expert
// MCP RBACs
'expert:insights:mcp:allow': { description: 'Can use the MCP', role: Roles.Viewer },
'expert:insights:mcp:prompt:allow': { description: 'Can use MCP Prompts', role: Roles.Viewer }, // FUTURE - ff expert MCP prompts not yet implemented
'expert:insights:mcp:resource:allow': { description: 'Can use MCP Resources', role: Roles.Viewer },
'expert:insights:mcp:resourcetemplate:allow': { description: 'Can use MCP Resource Templates', role: Roles.Viewer },
'expert:insights:mcp:tool:allow': { description: 'Can use readonly MCP Tools', role: Roles.Viewer }, // By default, viewer can use readonly tools,non-destructive, non-open-world tools
'expert:insights:mcp:tool:write': { description: 'Can use readonly MCP Tools', role: Roles.Member }, // readonly=false: implies it may modify data (though not necessarily destructive)
'expert:insights:mcp:tool:destructive': { description: 'Can use destructive MCP Tools', role: Roles.Owner }, // destructive true implies it may perform destructive actions
'expert:insights:mcp:tool:open-world': { description: 'Can use open-world MCP Tools', role: Roles.Member }, // open-world true implies it interacts with external entities
'expert:insights:mcp:tool:non-idempotent': { description: 'Can use non-idempotent MCP Tools', role: Roles.Member } // non-idempotent true implies it can NOT be safely called multiple times without side-effects. Only matters if readonly is false or destructive is true
}

module.exports = {
Expand Down
116 changes: 104 additions & 12 deletions forge/routes/api/expert.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
*/
const { default: axios } = require('axios')
const { v4: uuidv4 } = require('uuid')

const { filterAccessibleMCPServerFeatures } = require('../../services/expert.js')

module.exports = async function (app) {
// Get the assistant service configuration
const serviceEnabled = app.config.expert?.enabled === true
Expand Down Expand Up @@ -52,6 +55,7 @@ module.exports = async function (app) {
if (!existingRole) {
return reply.status(404).send({ code: 'not_found', error: 'Not Found' })
}
request.teamMembership = existingRole
request.team = await app.db.models.Team.byId(teamId)
if (!request.team) {
return reply.status(404).send({ code: 'not_found', error: 'Not Found' })
Expand Down Expand Up @@ -94,6 +98,32 @@ module.exports = async function (app) {
async (request, reply) => {
const sessionId = request.headers['x-chat-session-id'] ?? uuidv4()
const transactionId = request.headers['x-chat-transaction-id']
const context = request.body.context || {}

// If MCP capabilities are provided in the context, filter them based on user access
const selectedCapabilities = context.selectedCapabilities
if (selectedCapabilities && Array.isArray(selectedCapabilities) && selectedCapabilities.length > 0) {
const applications = {}
const mcpServersList = []

// first pass - get associated applications for the MCP servers selected by user
for (const server of selectedCapabilities || []) {
const applicationId = server.application
if (!applicationId) { continue }

if (!Object.hasOwnProperty.call(applications, applicationId)) {
applications[applicationId] = await app.db.models.Application.byId(applicationId)
}
const application = applications[applicationId]
if (application) {
mcpServersList.push({ server, application })
}
}
// second pass - filter features per MCP server based on user access to features (e.g. a tool with the destructive hint requires extra permission than a read-only tool)
const filteredServers = filterAccessibleMCPServerFeatures(app, mcpServersList, request.team, request.teamMembership)
context.selectedCapabilities = filteredServers?.length > 0 ? filteredServers : undefined
}

let query = request.body.query
if (request.body.history) {
query = ''
Expand Down Expand Up @@ -160,6 +190,7 @@ module.exports = async function (app) {
type: 'object',
properties: {
team: { type: 'string' },
application: { type: 'string' },
instance: { type: 'string' },
instanceType: { type: 'string', enum: ['instance', 'device'] },
instanceName: { type: 'string' },
Expand Down Expand Up @@ -198,38 +229,73 @@ module.exports = async function (app) {
const runningInstancesWithMCPServer = []
const transactionId = request.headers['x-chat-transaction-id']
const mcpCapabilitiesUrl = `${expertUrl.split('/').slice(0, -1).join('/')}/mcp/features`

// Get the MCP servers registered for this team
const mcpServers = await app.db.models.MCPRegistration.byTeam(request.team.id, { includeInstance: true }) || []

// Scan each MCP server and ensure the user has access to the associated application and that the instance is running
// then collect the MCP server info for the running instances MCP servers
// filter out any that the user doesn't have access to
const applicationCache = {}
const instanceToApplicationLookup = {}
for (const server of mcpServers) {
const { name, protocol, endpointRoute, TeamId, Project, Device, title, version, description } = server
if (TeamId !== request.team.id) {
// shouldn't happen due to byTeam filter, but just in case
continue
}
let owner, ownerId, ownerType
let instance, instanceId, instanceType
if (Device) {
ownerType = 'device'
owner = Device
ownerId = Device.hashid
instanceType = 'device'
instance = Device
instanceId = Device.hashid
} else if (Project) {
ownerType = 'instance'
owner = Project
ownerId = Project.id
instanceType = 'instance'
instance = Project
instanceId = Project.id
} else {
continue
}

const liveState = await owner.liveState({ omitStorageFlows: true })
// if instance is not expected to be running, skip it (avoids unnecessary timeouts)
if (instance?.state !== 'running') {
continue
}

// Ensure an application is linked to this instance
const applicationId = app.db.models.Application.encodeHashid(instance.ApplicationId)
if (!applicationId) {
continue // e.g. skip devices without an application as they can't be validated for access
}
if (!applicationCache[applicationId]) {
const applicationModel = await app.db.models.Application.byId(applicationId)
applicationCache[applicationId] = applicationModel
}
const application = applicationCache[applicationId]
if (!application) {
continue // skip - application not found
}
instanceToApplicationLookup[instanceId] = application

// Now we have the application & know it is supposed to be running, check user actually has access
// before bothering to check instance live state or calling backend for MCP features!
if (!app.hasPermission(request.teamMembership, 'expert:insights:mcp:allow', { application })) {
continue // user doesn't have access to this instance
}

// Now we have confirmed access is allowed, double check instance is running before offering MCP features (will avoid timeouts)
const liveState = await instance.liveState({ omitStorageFlows: true })
if (liveState?.meta?.state !== 'running') {
continue
}

runningInstancesWithMCPServer.push({
team: request.team.hashid,
instance: ownerId,
instanceType: ownerType,
instanceName: owner.name,
instanceUrl: owner.url,
application: application.hashid,
instance: instanceId,
instanceType,
instanceName: instance.name,
instanceUrl: instance.url,
mcpServerName: name,
mcpEndpoint: endpointRoute,
mcpProtocol: protocol,
Expand All @@ -238,9 +304,18 @@ module.exports = async function (app) {
description
})
}

// if no running instances with MCP server, return early
if (runningInstancesWithMCPServer.length === 0) {
return reply.send({ servers: [], transactionId })
}

// Call to backend to request MCP capabilities from expert service
// For reference - this POST:
// * calls the backend expert service endpoint /mcp/features
// * it connects to each MCP server registered
// * retrieves the prompts/resources/tools
// * adds them to the response along with the MCP server info
const response = await axios.post(mcpCapabilitiesUrl, {
teamId: request.team.hashid,
servers: runningInstancesWithMCPServer
Expand All @@ -256,6 +331,22 @@ module.exports = async function (app) {
if (response.data.transactionId !== transactionId) {
throw new Error('Transaction ID mismatch')
}
const mcpServersResponse = response.data.servers || []
const serverList = []
// load the associate application models so that we can filter features based on user access
for (const serverItem of mcpServersResponse) {
const application = applicationCache[serverItem.application]
if (application) {
// should allays be an application due to prior checks
// skip this as bad data
serverList.push({
server: serverItem,
application
})
}
}
// now check tools/resources/prompts access per server based on team membership
response.data.servers = filterAccessibleMCPServerFeatures(app, serverList, request.team, request.teamMembership)

reply.send(response.data)
} catch (error) {
Expand All @@ -267,6 +358,7 @@ module.exports = async function (app) {
/**
* @typedef {Object} MCPServerItem MCP server info for a team
* @property {string} team
* @property {string} application
* @property {string} instance
* @property {string} instanceType
* @property {string} instanceName
Expand Down
109 changes: 109 additions & 0 deletions forge/services/expert.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* Filter MCP server features based on user access permissions for the owner application.
* If a user does not have access to a specific feature (e.g. a tool with destructive hint), it is removed from the server's feature list.
* If a server has no accessible features after filtering, it is removed from the list.
* @param {ForgeApplication} app
* @param {Array<{server: object, application: import('sequelize').Model, applicationId: string, instanceId: string}>} serverList
* @param {import('sequelize').Model} team
* @param {import('sequelize').Model} teamMembership
* @returns {MCPServerItem[]}
*/
module.exports.filterAccessibleMCPServerFeatures = function (app, serverList, team, teamMembership) {
const servers = []
for (const serverDetails of serverList) {
const { server, application } = serverDetails
const permissionContext = { application }

// sanity checks
if (!application) {
continue // not expected, but just in case
}
if (team.id !== application.TeamId || team.hashid !== server.team) {
continue
}

// first pass - is the user allowed access to this MCP server at all?
if (!app.hasPermission(teamMembership, 'expert:insights:mcp:allow', { application })) {
continue // user doesn't have access to this instance
}

// NOTE: prompts are not yet implemented in the expert backend
// const defaultPromptPermission = app.hasPermission(teamMembership, 'expert:insights:mcp:prompt:allow', permissionContext)
const defaultResourcePermission = app.hasPermission(teamMembership, 'expert:insights:mcp:resource:allow', permissionContext)
const defaultResourceTemplatePermission = app.hasPermission(teamMembership, 'expert:insights:mcp:resourcetemplate:allow', permissionContext)
const defaultToolPermission = app.hasPermission(teamMembership, 'expert:insights:mcp:tool:allow', permissionContext)
const allowToolWrite = app.hasPermission(teamMembership, 'expert:insights:mcp:tool:write', permissionContext)
const allowToolDestructive = app.hasPermission(teamMembership, 'expert:insights:mcp:tool:destructive', permissionContext)
const allowToolOpenWorld = app.hasPermission(teamMembership, 'expert:insights:mcp:tool:open-world', permissionContext)
const allowToolNonIdempotent = app.hasPermission(teamMembership, 'expert:insights:mcp:tool:non-idempotent', permissionContext)

const result = { ...server }

if (result.resources && Array.isArray(result.resources)) {
result.resources = result.resources.filter(_resource => {
return defaultResourcePermission
})
}

if (result.resourceTemplates && Array.isArray(result.resourceTemplates)) {
result.resourceTemplates = result.resourceTemplates.filter(_resourceTemplate => {
return defaultResourceTemplatePermission
})
}

if (result.tools && Array.isArray(result.tools)) {
result.tools = result.tools.filter(tool => {
if (defaultToolPermission !== true) {
return false
}
// at this point, we have established the user has general tool access - now filter based on annotations/hints
const isReadonly = tool.annotations?.readOnlyHint === true
const isDestructive = tool.annotations?.destructiveHint !== false // air on side of caution - if not specified, assume destructive
const isOpenWorld = tool.annotations?.openWorldHint === true
const isIdempotent = tool.annotations?.idempotentHint === true
const writeAccessRequired = isDestructive === true || isReadonly === false

// Sanity check combinations
if (isReadonly && isDestructive) {
// this is not a valid combination - destructive tools cannot be read-only
return false
}

// test access based on hints - worst to best
if (writeAccessRequired) {
if (isDestructive === true) {
if (!allowToolDestructive) {
return false
}
}
if (isReadonly === false) {
if (!allowToolWrite) {
return false
}
}
if (isIdempotent === false) {
if (!allowToolNonIdempotent) {
return false
}
}
}
if (isOpenWorld === true) {
if (!allowToolOpenWorld) {
return false
}
}
return true
})
}
servers.push(result)
}

// finally, before sending the response, filter out any servers that have no accessible features
return servers.filter(server => {
const hasPrompts = Array.isArray(server.prompts) && server.prompts.length > 0
const hasResources = Array.isArray(server.resources) && server.resources.length > 0
const hasResourceTemplates = Array.isArray(server.resourceTemplates) && server.resourceTemplates.length > 0
const hasTools = Array.isArray(server.tools) && server.tools.length > 0
return hasPrompts || hasResources || hasResourceTemplates || hasTools
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -123,33 +123,33 @@ export default {
option.description
]
: []),
(option.tools
(option.tools?.length
? [
'Tools:',
...option.tools.map(t => t.name)
]
: []),
: null),
// (option.prompts
// ? [
// 'Prompts:',
// ...option.prompts.map(p => p.name)
// ...option.prompts?.map(p => p.name)
// ]
// : []),
(option.resources
(option.resources?.length
? [
'Resources:',
...option.resources.map(r => r.name)
]
: []),
(option.resourceTemplates
: null),
(option.resourceTemplates?.length
? [
'Resource Templates:',
...option.resourceTemplates.map(r => r.name)
]
: [])
: null)
]

return lines.map(e => e.join('\n')).join('\n\n')
return lines.filter(Boolean).map(e => e.join('\n')).join('\n\n')
}
}
}
Expand Down
Loading
Loading