UNPKG

@flowfuse/flowfuse

Version:

An open source low-code development platform

164 lines (146 loc) 9.35 kB
// /** // * This module provides the handler for instance events // */ const { filterAccessibleMCPServerFeatures } = require('../services/expert.js') /** * InstanceCommsHandler * @class InstanceCommsHandler * @memberof forge.comms */ class InstanceCommsHandler { /** * New InstanceCommsHandler instance * @param {import('../forge').ForgeApplication} app Fastify app * @param {import('./commsClient').CommsClient} client Comms Client */ constructor (app, client) { this.app = app this.client = client client.on('request/instance/expert/insight', async (userId, command, /** @type {MCPServerDetails} */ mcpServer, mcpDefinitionKind, mcpDefinition, data, onSuccess, onError) => { // MCP ROUTE: step 2 (hosted) // Called By: an MQTT inflight message (from the Expert Agent) // Calls To : driver helper fn callMCPTool (via the forge app container wrapper) const { team: teamId, application: applicationId, instance: instanceId, instanceType, mcpServer: mcpServerId } = mcpServer const isToolCall = command === 'mcp:call-tool' const isResourceCall = command === 'mcp:read-resource' && mcpDefinitionKind === 'mcp_resource' const isResourceTemplateCall = command === 'mcp:read-resource' && mcpDefinitionKind === 'mcp_resource_template' const toolDefinition = isToolCall ? mcpDefinition : null const resourceDefinition = isResourceCall ? mcpDefinition : null const resourceTemplateDefinition = isResourceTemplateCall ? mcpDefinition : null try { // Premise: // The incoming request contains information to call an MCP tool on a specific instance. // 1. Check that the for the MCP server supplied, the user has access (application level) // 2. Check that the for the MCP server feature being performed (prompt/resource/tool), that the user has access to (feature level) // 3. Re-resolve the MCP server against the team's trusted MCP registry, verify // instance/application ownership, and get/create access tokens as needed based on the // instance node security settings // first pass - basic sanity checks, picking up associated models for the user, team membership, etc if (!teamId || !applicationId || !instanceId || !mcpServerId || instanceType !== 'instance') { return onError('Invalid MCP request - missing required fields', 'MCP_INVALID_REQUEST') } if (typeof instanceId !== 'string') { return onError('Invalid instance ID', 'MCP_INVALID_INSTANCE_ID') } const instance = await this.app.db.models.Project.byId(instanceId) if (!instance) { return onError('Invalid instance', 'MCP_INVALID_INSTANCE') } // get associated db models for the user and team membership // reload the trusted registration and ensure it is still valid for this team and instance const registration = await app.db.models.MCPRegistration.byId(mcpServerId) if (!registration) { return onError('No MCP registration found', 'MCP_NO_REGISTRATION') } const application = instance.Application await application.reload({ attributes: ['TeamId'] }) const team = instance.Team const teamOk = team.hashid === teamId && application.TeamId === team.id && registration.TeamId === team.id const applicationOk = application.hashid === applicationId && instance.ApplicationId === application.id const instanceOk = registration.targetId === instance.id.toString() && registration.targetType === instanceType if (!teamOk || !applicationOk || !instanceOk) { return onError('Invalid team, application, or instance', 'MCP_INVALID_TEAM_APPLICATION_INSTANCE') } const serverEntry = { application, server: { ...mcpServer, tools: toolDefinition ? [toolDefinition] : [], resources: resourceDefinition ? [resourceDefinition] : [], resourceTemplates: resourceTemplateDefinition ? [resourceTemplateDefinition] : [] } } const user = await app.db.models.User.byId(userId) if (!user || user.hashid !== userId) { return onError('Invalid user', 'MCP_INVALID_USER') } const existingRole = await user.getTeamMembership(teamId) const accessibleServers = filterAccessibleMCPServerFeatures(app, [serverEntry], team, existingRole) const accessibleServer = accessibleServers.find(s => s.mcpServer === mcpServerId) if (!accessibleServer) { return onError('User does not have access to MCP server', 'MCP_NO_ACCESS') } // Prepare command data and method based on the type of MCP call (tool, resource, or resource template) let commandMethod const commandData = [] if (isToolCall) { const accessibleTool = accessibleServer.tools.find(t => t.name === data.name) if (!accessibleTool) { return onError('User does not have access to MCP tool', 'MCP_NO_ACCESS_TOOL') } commandData.push(data.name) commandData.push(data.input) commandMethod = app.containers.callMCPTool } else if (isResourceCall) { const accessibleResource = accessibleServer.resources.find(r => r.uri === resourceDefinition.uri) if (!accessibleResource) { return onError('User does not have access to MCP resource', 'MCP_NO_ACCESS_RESOURCE') } commandData.push(data.uri) commandMethod = app.containers.readMCPResource } else if (isResourceTemplateCall) { const accessibleResourceTemplate = accessibleServer.resourceTemplates.find(r => r.uriTemplate === resourceTemplateDefinition.uriTemplate) if (!accessibleResourceTemplate) { return onError('User does not have access to MCP resource template', 'MCP_NO_ACCESS_RESOURCE_TEMPLATE') } // Prepare the commandData for the resource template call, including resolving the final URI from the template and input values // NOTE: The Expert Agent will typically unfurl the template and provide a fully resolved URI, but if it is not provided (or contains // placeholders), we will compute it below using the template and input values let uri = data.uri if ((!uri || /\{([^}]+)\}/.test(uri))) { // compute the final URI by replacing placeholders in the template with input values const template = data.uriTemplate || uri const input = data.input || {} uri = template.replace(/\{([^}]+)\}/g, (match, key) => { const cleanKey = key.replace(/[*?]/g, '') // strip RFC6570 modifiers e.g. {var*} or {var?} to get the clean key for input lookup return input[cleanKey] !== undefined ? encodeURIComponent(input[cleanKey]) : match }) } commandData.push(uri) commandMethod = app.containers.readMCPResource } else { return onError('Invalid MCP command', 'MCP_INVALID_COMMAND') } // update the endpoint with the resolved access token for the instance and team const teamType = await instance.Team.getTeamType() const teamHttpSecurityFeature = !!teamType.properties.features?.teamHttpSecurity const endpoint = { mcpEndpoint: mcpServer.mcpEndpoint, headers: mcpServer.headers || {}, accessToken: await app.expert.mcp.getOrCreateToken(instance, mcpServer.instanceType, instanceId, teamHttpSecurityFeature) || null } try { const result = await commandMethod(instance, endpoint, ...commandData) onSuccess(result) } catch (err) { return onError(`An error occurred performing insight request: ${err.message}`, 'MCP_INSIGHT_REQUEST_ERROR', err) } } catch (err) { return onError(`Error handling expert insights inflight request: ${err.message}`, 'MCP_INSIGHT_REQUEST_ERROR', err) } }) } } module.exports = { InstanceCommsHandler: (app, client) => new InstanceCommsHandler(app, client) }