diff --git a/forge/comms/aclManager.js b/forge/comms/aclManager.js index c7b7274f26..daad4f6c92 100644 --- a/forge/comms/aclManager.js +++ b/forge/comms/aclManager.js @@ -199,11 +199,11 @@ module.exports = function (app) { } const [commandAgent, commandName] = commandParts switch (commandAgent) { - // case 'forge': - // if (['mcp-get-features', 'mcp-call-tool'].indexOf(commandName) === -1) { - // throw ValidationError('invalid platform command for platform api') - // } - // break + case 'automation': + if (['mcp-get-features', 'mcp-call-tool'].indexOf(commandName) === -1) { + throw ValidationError('invalid platform command for platform api') + } + break case 'insights': if (['mcp-call-tool', 'mcp-read-resource'].includes(commandName) === false) { throw ValidationError('invalid platform command for insights') diff --git a/forge/comms/commsClient.js b/forge/comms/commsClient.js index 45bbf55c51..b40c949638 100644 --- a/forge/comms/commsClient.js +++ b/forge/comms/commsClient.js @@ -59,10 +59,33 @@ class CommsClient extends EventEmitter { const channelCommand = topicParts[6] // dynamic value, e.g. mcp:call-tool or mcp:read-resource const direction = topicParts[7] // request or response (only for inflight channels) + // these are common for both insights and platform automations + let payload + try { + payload = JSON.parse(message.toString()) + } catch (err) { + this.app.log.warn(`Ignoring malformed expert payload on ${topic}: ${err.message}`) + return + } + const correlationData = packet.properties?.correlationData + const userProperties = packet.properties?.userProperties + const mqttOptions = { properties: { correlationData, userProperties } } + + if (!correlationData || !userProperties) { + this.app.log.warn(`'Tool call request missing correlationData or userProperties: ${message.toString()}`) + return // do not respond, the agent will timeout and handle it + } + // end common bits + const supportedInsightsCommands = { 'insights:mcp-call-tool': 'mcp:call-tool', 'insights:mcp-read-resource': 'mcp:read-resource' } + const supportedPlatformAutomationCommands = { + 'automation:mcp-get-features': 'mcp-get-features', + 'automation:mcp-call-tool': 'mcp-call-tool' + } + if (supportedInsightsCommands[channelCommand] && direction === 'request') { const isInsightsToolCall = channel === 'platform' && channelCommand === 'insights:mcp-call-tool' && direction === 'request' const isInsightsResourceCall = channel === 'platform' && channelCommand === 'insights:mcp-read-resource' && direction === 'request' @@ -74,37 +97,11 @@ class CommsClient extends EventEmitter { // has permission to access this topic. Now, we verify the payload contains the required fields to process the request. // If OK, emit the message to the appropriate instance/device handler (handled in ./instances.js or ./devices.js) - const payload = JSON.parse(message.toString()) const command = supportedInsightsCommands[channelCommand] const data = payload.data || {} const { kind, mcpServer, toolDefinition, resourceDefinition, resourceTemplateDefinition } = payload.meta || {} - const correlationData = packet.properties?.correlationData - const userProperties = packet.properties?.userProperties - if (!correlationData || !userProperties) { - console.warn('Expert Insight tool call request missing correlationData or userProperties', payload) - return // do not respond, the agent will timeout and handle it - } - - const mqttOptions = { properties: { correlationData, userProperties } } const responseTopic = `ff/v1/expert/${userId}/${sessionId}/${channel}/${channelCommand}/response` - - /** Callback for failed MCP request. Publishes a structured error back to the agent. */ - const onError = (content, code, error) => { - const data = { - code: code || error?.code || 'MCP_ERROR', - content: `Error: ${content}`, - isError: true - } - if (error) { - data.type = error?.name || error?.constructor?.name || 'Error' - data.message = error?.message || error?.toString() - } - this.client.publish(responseTopic, JSON.stringify(data), mqttOptions) - } - /** Callback for successful MCP request. Publishes the result back to the agent. */ - const onSuccess = (result) => { - this.client.publish(responseTopic, JSON.stringify(result), mqttOptions) - } + const { onSuccess, onError } = this.createMqttCallbacks(responseTopic, mqttOptions) // check that the mcpServer contains the required fields to process the request if (!mcpServer || !['instance', 'device'].includes(mcpServer.instanceType) || !mcpServer.instance || !mcpServer.mcpServer) { @@ -141,6 +138,24 @@ class CommsClient extends EventEmitter { onSuccess, // success callback onError // failure callback ) + } else if (supportedPlatformAutomationCommands[channelCommand] && direction === 'request') { + // channel command is either 'mcp-get-features' or 'mcp-call-tool' + const responseTopic = `ff/v1/expert/${userId}/${sessionId}/platform/${channelCommand}/response` + const command = supportedPlatformAutomationCommands[channelCommand] + const data = payload.data || {} + const { onSuccess, onError } = this.createMqttCallbacks(responseTopic, mqttOptions) + + this.emit( + 'request/platform-automation:forge', // event name + { + userId, // ID of user making the request + command, // command, + data, // payload data + meta: payload.meta + }, + onSuccess, // success callback + onError // failure callback + ) } } else if (ownerType === 'p') { this.emit('status/project', { @@ -223,6 +238,29 @@ class CommsClient extends EventEmitter { } } + /** + * Creates onSuccess/onError callbacks that publish results back to the agent + * over the given MQTT response topic. + */ + createMqttCallbacks (responseTopic, mqttOptions) { + const onError = (content, code, error) => { + const data = { + code: code || error?.code || 'MCP_ERROR', + content: `Error: ${content}`, + isError: true + } + if (error) { + data.type = error?.name || error?.constructor?.name || 'Error' + data.message = error?.message || error?.toString() + } + this.client.publish(responseTopic, JSON.stringify(data), mqttOptions) + } + const onSuccess = (result) => { + this.client.publish(responseTopic, JSON.stringify(result), mqttOptions) + } + return { onSuccess, onError } + } + /** * Publish to a topic * @param {string} topic Topic to publish to diff --git a/forge/comms/index.js b/forge/comms/index.js index 0015219e1d..1bf512f8aa 100644 --- a/forge/comms/index.js +++ b/forge/comms/index.js @@ -4,6 +4,7 @@ const ACLManager = require('./aclManager') const { CommsClient } = require('./commsClient') const { DeviceCommsHandler } = require('./devices') const { InstanceCommsHandler } = require('./instances') +const { PlatformAutomationHandler } = require('./platformAutomation.js') /** * This module represents the real-time comms component of the platform. @@ -32,6 +33,7 @@ module.exports = fp(async function (app, _opts) { // Create the handler for any device-related messages const deviceCommsHandler = DeviceCommsHandler(app, client) const instanceCommsHandler = InstanceCommsHandler(app, client) + const platformAutomationHandler = PlatformAutomationHandler(app, client) // Not in the current release, but when we handle Launcher status // via MQTT, it will arrive here. Compare to the status/device handler in `devices.js` @@ -44,6 +46,7 @@ module.exports = fp(async function (app, _opts) { devices: deviceCommsHandler, instances: instanceCommsHandler, aclManager: ACLManager(app), + platformAutomation: platformAutomationHandler, platform: { settings: { sync: function (key) { diff --git a/forge/comms/platformAutomation.js b/forge/comms/platformAutomation.js new file mode 100644 index 0000000000..6c5259de85 --- /dev/null +++ b/forge/comms/platformAutomation.js @@ -0,0 +1,133 @@ +// /** +// * This module provides the handler for platform automation events +// */ + +const { default: z } = require('zod') + +/** + * PlatformAutomationHandler + * @class PlatformAutomationHandler + * @memberof forge.comms + */ +class PlatformAutomationHandler { + /** + * @param {import('../forge').ForgeApplication} app Fastify app + * @param {import('./commsClient').CommsClient} client Comms Client + */ + constructor (app, client) { + this.app = app + this.client = client + + /** Tool definitions without the handler functions - for sending across the wire to the agent for tool discovery */ + this._wireToolDefinitions = null + this._fullToolDefinitions = null + + this.setupEventHandler() + } + + /** + * Lazily loads and caches the full tool definitions (with handlers) + * from the EE MCP module. + */ + loadTools () { + if (!this._fullToolDefinitions) { + const { loadToolDefinitions } = require('../ee/lib/mcp/toolLoader') + this._fullToolDefinitions = loadToolDefinitions() + this._wireToolDefinitions = this._fullToolDefinitions.map(({ name, description, inputSchema, annotations }) => ({ + name, + description, + inputSchema: inputSchema && z.toJSONSchema(z.object(inputSchema)), + annotations + })) + } + } + + /** + * Returns wire-safe tool definitions (no handler functions). + */ + getToolDefinitions () { + this.loadTools() + return this._wireToolDefinitions + } + + /** + * Finds a tool definition by name (including its handler). + */ + findTool (toolName) { + this.loadTools() + return this._fullToolDefinitions.find(t => t.name === toolName) + } + + setupEventHandler () { + this.client.on('request/platform-automation:forge', this.eventHandler) + } + + eventHandler = async ({ userId, command, data, meta } = {}, onSuccess, onError) => { + try { + let result = {} + + switch (command) { + case 'mcp-get-features': + result = { tools: this.getToolDefinitions() } + break + case 'mcp-call-tool': { + const toolName = data?.name + const args = data?.input || {} + + // TODO: Probably sensible to verify that toolDefinition matches the tool to ensure no tampering has occurred + const { toolDefinition } = meta || {} + + const { annotations } = toolDefinition + const tool = this.findTool(toolName) + + // Verify tool annotations haven't been tampered with + if (JSON.stringify({ annotations }) !== JSON.stringify({ annotations: tool.annotations })) { + return onError( + 'Tool definition mismatch', + 'MCP_PLATFORM_TOOL_TAMPERED' + ) + } + + if (!tool) { + return onError( + `Unknown platform tool: ${toolName}`, + 'MCP_PLATFORM_TOOL_NOT_FOUND' + ) + } + + const user = await this.app.db.models.User.byId(userId) + if (user) { + const { token } = await this.app.expert.mcp.getOrCreatePlatformToken(user) + const inject = (opts) => this.app.inject({ + ...opts, + headers: { + ...opts.headers, + authorization: `Bearer ${token}`, + 'x-ff-automation-source': 'expert' + } + }) + + const { formatResponse } = require('../ee/lib/mcp/toolLoader') + const response = await tool.handler(args, { inject }) + result = formatResponse(response) + } + break + } + default: + // unrecognized command + } + + onSuccess(result) + } catch (err) { + return onError( + `An error occurred performing a platform automation request: ${err.message}`, + 'MCP_PLATFORM_AUTOMATION_REQUEST_ERROR', + err + ) + } + } +} + +module.exports = { + PlatformAutomationHandler: (app, client) => new PlatformAutomationHandler(app, client) +} diff --git a/forge/db/controllers/AccessToken.js b/forge/db/controllers/AccessToken.js index bc2bb3cd9a..6c7567c24f 100644 --- a/forge/db/controllers/AccessToken.js +++ b/forge/db/controllers/AccessToken.js @@ -149,7 +149,7 @@ module.exports = { /** * Create an AccessToken for the editor. */ - createTokenForUser: async function (app, user, expiresAt, scope = [], includeRefresh) { + createTokenForUser: async function (app, user, expiresAt, scope = [], includeRefresh, ownerType = 'user') { const userId = typeof user === 'number' ? user : user.id const token = generateToken(32, 'ffu') const refreshToken = includeRefresh ? generateToken(32, 'ffu') : null @@ -162,7 +162,7 @@ module.exports = { expiresAt, scope, ownerId: '' + userId, - ownerType: 'user' + ownerType }) return { token, expiresAt, refreshToken } }, diff --git a/forge/ee/lib/expert/index.js b/forge/ee/lib/expert/index.js index c46429a8bd..c1f4048ab0 100644 --- a/forge/ee/lib/expert/index.js +++ b/forge/ee/lib/expert/index.js @@ -3,6 +3,16 @@ const fp = require('fastify-plugin') const TOKEN_CACHE_NAME = 'ExpertMCPAccessTokenCache' +const EXPERT_MCP_SCOPE = 'ff-expert:mcp' +const EXPERT_MCP_PLATFORM_SCOPE = 'ff-expert:platform' +// Dedicated owner type so platform-automation tokens are not treated as general user tokens +const EXPERT_MCP_PLATFORM_OWNER_TYPE = 'user:expert-mcp' + +const EXPERT_MCP_SCOPES = [ + EXPERT_MCP_SCOPE, + EXPERT_MCP_PLATFORM_SCOPE +] + module.exports = fp(async function (app, _opts) { // Get the assistant service configuration const serviceEnabled = app.config.expert?.enabled === true @@ -60,7 +70,7 @@ module.exports = fp(async function (app, _opts) { httpNodeAuth = deviceSettings?.httpNodeAuth } const tokenName = 'FlowFuse Expert MCP Access Token' - const scope = ['ff-expert:mcp', instanceType] + const scope = [EXPERT_MCP_SCOPE, instanceType] if (httpNodeAuth?.type === 'flowforge-user' && teamHttpSecurityFeatureEnabled) { // FlowFuse auth is enabled for this instance const expiresAt = new Date(Date.now() + (TOKEN_TTL)) @@ -97,6 +107,30 @@ module.exports = fp(async function (app, _opts) { return readCachedMcpAccessToken(instanceId) } + async function getOrCreateMcpPlatformToken (user) { + const cacheKey = `platform:${user.hashid}` + const cached = await readCachedMcpAccessToken(cacheKey) + if (cached) { + return cached + } + + const expiresAt = new Date(Date.now() + TOKEN_TTL) + const { token } = await app.db.controllers.AccessToken.createTokenForUser( + user, + expiresAt, + [EXPERT_MCP_PLATFORM_SCOPE], + undefined, + EXPERT_MCP_PLATFORM_OWNER_TYPE + ) + + const entry = { token } + await tokenCache().set(cacheKey, { + value: entry, + expiresAt: Date.now() + TOKEN_TTL + }) + return entry + } + app.decorate('expert', { serviceEnabled, expertUrl, @@ -105,7 +139,10 @@ module.exports = fp(async function (app, _opts) { mcp: { clearTokenCache: clearMcpAccessTokenCache, getCachedToken: getCachedMcpAccessToken, - getOrCreateToken: getOrCreateMcpAccessToken + getOrCreateToken: getOrCreateMcpAccessToken, + getOrCreatePlatformToken: getOrCreateMcpPlatformToken } }) }, { name: 'app.expert' }) + +module.exports.EXPERT_MCP_SCOPES = EXPERT_MCP_SCOPES diff --git a/forge/ee/lib/mcp/toolLoader.js b/forge/ee/lib/mcp/toolLoader.js index f8655ed360..4c0ce65ca4 100644 --- a/forge/ee/lib/mcp/toolLoader.js +++ b/forge/ee/lib/mcp/toolLoader.js @@ -24,12 +24,15 @@ function loadToolDefinitions () { * Registers all tool definitions on a McpServer instance. * Called once per request since the server is stateless (fresh per request). * - * @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server + * @param {Object} server * @param {Array} toolDefinitions - loaded tool definitions * @param {Function} inject - app.inject helper bound to the request's auth token * @param {Function} checkScope - scope check function (stub for now) + * @param {Object} [options] - optional extra context passed to tool handlers + * @param {Object} [options.comms] - device comms handler for MQTT commands */ -function registerTools (server, toolDefinitions, inject, checkScope) { +function registerTools (server, toolDefinitions, inject, checkScope, options = {}) { + const { comms } = options for (const tool of toolDefinitions) { const config = { description: tool.description, @@ -44,8 +47,8 @@ function registerTools (server, toolDefinitions, inject, checkScope) { if (scopeError) { return scopeError } - const response = await tool.handler(args, { inject }) - return formatResponse(response) + const response = await tool.handler(args, { inject, comms }) + return typeof response?.json === 'function' ? formatResponse(response) : response }) } } @@ -54,16 +57,19 @@ function registerTools (server, toolDefinitions, inject, checkScope) { * Formats an app.inject() response into an MCP CallToolResult. */ function formatResponse (response) { + if (typeof response.json !== 'function') { + return response + } + const body = response.json() if (response.statusCode >= 400) { return { - content: [{ type: 'text', text: JSON.stringify(body) }], + content: body, + code: response.statusCode, isError: true } } - return { - content: [{ type: 'text', text: JSON.stringify(body, null, 2) }] - } + return body } -module.exports = { loadToolDefinitions, registerTools } +module.exports = { formatResponse, loadToolDefinitions, registerTools } diff --git a/forge/ee/lib/mcp/tools/applications.js b/forge/ee/lib/mcp/tools/applications.js new file mode 100644 index 0000000000..9f574f6c27 --- /dev/null +++ b/forge/ee/lib/mcp/tools/applications.js @@ -0,0 +1,160 @@ +const { z } = require('zod') + +module.exports = [ + { + name: 'platform_list_applications', + description: `FlowFuse platform automation tool: + Lists all applications in a team but does not return hosted instances or remote instances. + Call platform_get_application to get details of a specific application. + Call platform_get_remote_instance to get details of a specific remote instance or platform_get_hosted_instance to get details of a specific hosted instance.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + teamId: z.string().describe('The ID or hashid of the team') + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/applications?includeInstances=false&includeApplicationDevices=false` }) + return response + } + }, + { + name: 'platform_get_application', + description: 'FlowFuse platform automation tool: Use this tool to retrieve application metadata (name, description, link, team createdAt and updatedAt)', + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + applicationId: z.string().describe('The ID or hashid of the application') + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/applications/${args.applicationId}` }) + return response + } + }, + { + name: 'platform_get_application_hosted_instances', + description: `FlowFuse platform automation tool: + Gets all the hosted instances that live inside an application. + A hosted instance is a Node-RED that runs on the same environment as the FlowFuse platform. + Use this to see which hosted instances an application has. Each result includes the instance name, URL, and basic settings. + To get the full details of one specific hosted instance, call platform_get_hosted_instance with its ID. + To check if hosted instances are currently running or stopped, call platform_get_application_instances_status instead.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + applicationId: z.string().describe('The ID or hashid of the application') + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/applications/${args.applicationId}/instances` }) + return response + } + }, + { + name: 'platform_get_application_remote_instances', + description: `FlowFuse platform automation tool: + Gets all the remote instances (devices) that live inside an application. + A remote instance is a Node-RED that runs on the user's own hardware (like a Raspberry Pi or a server) rather than on the same environment as the FlowFuse platform. + Use this to see which remote instances are connected to an application, check if they are online or offline, or find one by name. + You can search by name using the query parameter and page through results using cursor or limit. + To get the full details of one specific remote instance, call platform_get_remote_instance with its ID.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + applicationId: z.string().describe('The ID or hashid of the application'), + query: z.string().optional().describe('Search remote instances by name'), + cursor: z.string().optional().describe('Cursor for pagination (the hashid of the last item from the previous page)'), + limit: z.number().min(1).max(10).describe('How many results to return per page') + }, + handler: async (args, { inject }) => { + let url = `/api/v1/applications/${args.applicationId}/devices` + const params = [] + if (args.query) { + params.push(`query=${args.query}`) + } + if (args.cursor) { + params.push(`cursor=${args.cursor}`) + } + if (args.limit) { + params.push(`limit=${args.limit}`) + } + if (params.length > 0) { + url += '?' + params.join('&') + } + const response = await inject({ method: 'GET', url }) + return response + } + }, + { + name: 'platform_get_application_instances_status', + description: `FlowFuse platform automation tool: + Gets the live running status of every hosted instance inside an application. + Use this when you want to know if the hosted instances are running, stopped, or in the middle of deploying. + This is different from platform_get_application_hosted_instances: that tool gives you names and settings, + this tool tells you what is happening right now (is it running? is it deploying? when were the flows last updated?).`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + applicationId: z.string().describe('The ID or hashid of the application') + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/applications/${args.applicationId}/instances/status` }) + return response + } + }, + { + name: 'platform_get_application_audit_log', + description: `FlowFuse platform automation tool: + Gets the audit log (activity history) for an application. Think of it as a diary that writes down everything that happened: who did what, and when. + Use this to find out what changed, who made a change, or to figure out what went wrong by looking at recent activity. + Results come back newest first. Use cursor to page through older entries. + You can narrow down results by event type, username, or scope (application, project, or device).`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + applicationId: z.string().describe('The ID or hashid of the application'), + cursor: z.string().optional().describe('Cursor for pagination (the hashid of the last entry from the previous page)'), + limit: z.number().min(1).max(100).describe('How many entries to return'), + event: z.string().optional().describe('Filter by event type (e.g. "application.created", "project.snapshot.device-target-set")'), + username: z.string().optional().describe('Filter by the username of whoever triggered the event'), + scope: z.string().optional().describe('What level of entries to include: "application", "project", or "device" (default "application")') + }, + handler: async (args, { inject }) => { + let url = `/api/v1/applications/${args.applicationId}/audit-log` + const params = [] + if (args.cursor) { + params.push(`cursor=${args.cursor}`) + } + if (args.limit) { + params.push(`limit=${args.limit}`) + } + if (args.event) { + params.push(`event=${args.event}`) + } + if (args.username) { + params.push(`username=${args.username}`) + } + if (args.scope) { + params.push(`scope=${args.scope}`) + } + if (params.length > 0) { + url += '?' + params.join('&') + } + const response = await inject({ method: 'GET', url }) + return response + } + }, + { + name: 'platform_create_application', + description: `FlowFuse platform automation tool: + Creates a new application in a team. + An application is a container that groups together hosted instances and remote instances that work together. + After the application is created, ask the user if they want to be taken to it. If they do, use the ui_navigate tool with the route name "Application" and params { id: }.`, + annotations: { readOnlyHint: false, destructiveHint: false }, + inputSchema: { + name: z.string().describe('Name for the new application'), + teamId: z.string().describe('The ID or hashid of the team to create the application in'), + description: z.string().optional().describe('Optional description for the application') + }, + handler: async (args, { inject }) => { + const payload = { name: args.name, teamId: args.teamId } + if (args.description) { + payload.description = args.description + } + const response = await inject({ method: 'POST', url: '/api/v1/applications', payload }) + return response + } + } +] diff --git a/forge/ee/lib/mcp/tools/devices.js b/forge/ee/lib/mcp/tools/devices.js new file mode 100644 index 0000000000..6b9e5f14d7 --- /dev/null +++ b/forge/ee/lib/mcp/tools/devices.js @@ -0,0 +1,125 @@ +const { z } = require('zod') + +module.exports = [ + { + name: 'platform_list_team_remote_instances', + description: `FlowFuse platform automation tool: + Lists all remote instances that belong to a team. + Remote instances are sometimes referred to as devices. + A remote instance is a Node-RED that runs on the user's own hardware (like a Raspberry Pi or an edge server) rather than on the same environment as the FlowFuse platform. + Use this when you need to see all the remote instances a team has, regardless of which application they belong to. + If you already know the application, use platform_get_application_remote_instances instead to get a narrower list. + To get the full details of one specific remote instance, call platform_get_remote_instance with its ID.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + teamId: z.string().describe('The ID or hashid of the team'), + query: z.string().optional().describe('Search remote instances by name or type'), + cursor: z.string().optional().describe('Cursor for pagination (the hashid of the last item from the previous page)'), + limit: z.number().min(1).max(10).describe('How many results to return per page') + }, + handler: async (args, { inject }) => { + let url = `/api/v1/teams/${args.teamId}/devices` + const params = [] + if (args.query) { + params.push(`query=${args.query}`) + } + if (args.cursor) { + params.push(`cursor=${args.cursor}`) + } + if (args.limit) { + params.push(`limit=${args.limit}`) + } + if (params.length > 0) { + url += '?' + params.join('&') + } + const response = await inject({ method: 'GET', url }) + return response + } + }, + { + name: 'platform_get_remote_instance', + description: `FlowFuse platform automation tool: + Gets the full details of one specific remote instance. + Remote instances are sometimes referred to as devices. + Use this when you already have a remote instance ID and need to know everything about it: + its name, online/offline status, which application and team it belongs to, what device group it is in, + what snapshot it is currently running, and what snapshot it should be running (the target). + If you need to list all remote instances first, call platform_list_remote_instances or platform_get_application_remote_instances.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + remoteInstanceId: z.string().describe('The ID or hashid of the remote instance') + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/devices/${args.remoteInstanceId}` }) + return response + } + }, + { + name: 'platform_get_remote_instance_status', + description: `FlowFuse platform automation tool: + Gets the live running status of a remote instance by querying the device directly over MQTT. + This returns the real-time state of the Node-RED runtime on the device (running, stopped, installing, etc.), + not the last-known state stored on the platform. + The remote instance must be online and reachable for this to work. If the device is offline, the call will time out. + Use this when you need to know what the device is actually doing right now. + Other tools like platform_create_remote_instance_snapshot require the device to be running. + Always call this tool first to verify the device is live before using those tools.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + teamId: z.string().describe('The hashid of the team that owns the remote instance. You can get this from platform_get_remote_instance or ui_get_context.'), + remoteInstanceId: z.string().describe('The ID or hashid of the remote instance') + }, + handler: async (args, { comms }) => { + if (!comms) { + return { error: 'Device communications not available' } + } + try { + const response = await comms.sendCommandAwaitReply(args.teamId, args.remoteInstanceId, 'get-liveState', {}, { timeout: 3000 }) + return { + state: response?.state || 'unknown', + health: response?.health ?? null, + snapshot: response?.snapshot ?? null + } + } catch (err) { + return { error: 'Device is not reachable. It may be offline or not connected to the platform.' } + } + } + }, + { + name: 'platform_create_remote_instance', + description: `FlowFuse platform automation tool: + Registers a new remote instance (device) in a team. + A remote instance is a Node-RED that runs on the user's own hardware rather than on the same environment as the FlowFuse platform. + This only registers the device on the platform, it does not install anything on the user's hardware. + The response includes credentials that the user will need to configure on their device to connect it to the platform. + If the user wants to assign it to an application, call platform_assign_remote_instance_to_application after creation. + After the device is created, ask the user if they want to be taken to it. If they do, use the ui_navigate tool with the route name "device-overview" and params { id: }.`, + annotations: { readOnlyHint: false, destructiveHint: false }, + inputSchema: { + name: z.string().describe('Name for the new remote instance'), + teamId: z.string().describe('The ID or hashid of the team to register the device in'), + type: z.string().optional().describe('Optional label describing the device type (e.g. "Raspberry Pi 4", "Edge Gateway")') + }, + handler: async (args, { inject }) => { + const payload = { name: args.name, team: args.teamId, type: args.type || '' } + const response = await inject({ method: 'POST', url: '/api/v1/devices', payload }) + return response + } + }, + { + name: 'platform_assign_remote_instance_to_application', + description: `FlowFuse platform automation tool: + Assigns a remote instance to an application. + Use this after creating a remote instance with platform_create_remote_instance, or to move an existing remote instance into a different application. + The remote instance and the application must belong to the same team.`, + annotations: { readOnlyHint: false, destructiveHint: false }, + inputSchema: { + remoteInstanceId: z.string().describe('The ID or hashid of the remote instance'), + applicationId: z.string().describe('The ID or hashid of the application to assign it to') + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'PUT', url: `/api/v1/devices/${args.remoteInstanceId}`, payload: { application: args.applicationId } }) + return response + } + } +] diff --git a/forge/ee/lib/mcp/tools/instances.js b/forge/ee/lib/mcp/tools/instances.js new file mode 100644 index 0000000000..9f15305411 --- /dev/null +++ b/forge/ee/lib/mcp/tools/instances.js @@ -0,0 +1,118 @@ +const { z } = require('zod') + +module.exports = [ + { + name: 'platform_get_hosted_instance', + description: `FlowFuse platform automation tool: + Gets the full details of one specific hosted instance. + A hosted instance is a Node-RED that runs on the same environment as the FlowFuse platform. + Use this when you already have a hosted instance ID and need to know everything about it: + its name, URL, settings, what application and team it belongs to, and its current state. + If you need to list all hosted instances first, call platform_get_application_hosted_instances. + To check the live running status, call platform_get_hosted_instance_status instead.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + hostedInstanceId: z.string().describe('The ID or hashid of the hosted instance') + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/projects/${args.hostedInstanceId}` }) + return response + } + }, + { + name: 'platform_get_hosted_instance_status', + description: `FlowFuse platform automation tool: + Gets the live running status of a specific hosted instance (running, stopped, suspended, starting, etc.). + This is different from platform_get_hosted_instance: that tool gives you metadata and settings, + this tool tells you what the instance is doing right now. + Use this when the user asks if an instance is running, or when you need to check before performing an action that requires it to be online.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + hostedInstanceId: z.string().describe('The ID or hashid of the hosted instance') + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/projects/${args.hostedInstanceId}/status` }) + return response + } + }, + { + name: 'platform_get_hosted_instance_logs', + description: `FlowFuse platform automation tool: + Gets the runtime logs for a hosted instance. + These are the Node-RED console logs showing what happened while the instance was running. + Use this when the user wants to debug a problem, check what happened after a restart, or look for errors. + Results come back newest first. Use cursor to page through older entries.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + hostedInstanceId: z.string().describe('The ID or hashid of the hosted instance'), + limit: z.number().min(1).max(100).describe('Number of log entries to return'), + cursor: z.string().optional().describe('Cursor for pagination (the ID of the last entry from the previous page)') + }, + handler: async (args, { inject }) => { + let url = `/api/v1/projects/${args.hostedInstanceId}/logs` + const params = [] + if (args.limit) { + params.push(`limit=${args.limit}`) + } + if (args.cursor) { + params.push(`cursor=${args.cursor}`) + } + if (params.length > 0) { + url += '?' + params.join('&') + } + const response = await inject({ method: 'GET', url }) + return response + } + }, + { + name: 'platform_check_hosted_instance_name_availability', + description: `FlowFuse platform automation tool: + Checks if a name is available for a new hosted instance. + Hosted instance names must be unique across the entire platform. + Use this before calling platform_create_hosted_instance to make sure the name the user picked is not already taken.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + name: z.string().regex(/^[a-zA-Z][a-zA-Z0-9-]*$/).describe('The hosted instance name to check') + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'POST', url: '/api/v1/projects/check-name', payload: { name: args.name } }) + return response + } + }, + { + name: 'platform_create_hosted_instance', + description: `FlowFuse platform automation tool: + Creates a new hosted Node-RED instance inside an application. The instance starts automatically after creation. + Before calling this tool, gather the required parameters: + 1. Call platform_list_hosted_instance_types first to see what instance types are available on this platform, then ask the user which one they want. + 2. If they want a specific Node-RED version or stack, or just the latest. Call platform_list_stacks to get the options. If the user has no preference, use the latest available stack. + 3. If they want to start from a blueprint (pre-built starter flows). Call platform_list_blueprints to show them what is available. This is optional. + 4. Call platform_list_templates to get the template. If only one template exists, use it automatically. If there are multiple, ask the user which one to use. + 5. Call platform_check_hosted_instance_name_availability to make sure the chosen name is not already taken. + When generating a name, always use hyphens to separate multiple words (e.g. "my-new-instance" not "my new instance"). + After the instance is created, wait a few seconds to give it time to boot up, then ask the user if they want to be taken to it. If they do, use the ui_navigate tool with the route name "instance-overview" and params { id: }.`, + annotations: { readOnlyHint: false, destructiveHint: false }, + inputSchema: { + name: z.string().regex(/^[a-zA-Z][a-zA-Z0-9-]*$/).describe('Name for the new hosted instance. When generating a name, always use hyphens to separate multiple words (e.g. "my-new-instance" not "my new instance").'), + applicationId: z.string().describe('The ID or hashid of the application'), + projectType: z.string().describe('The ID of the hosted instance type (use platform_list_hosted_instance_types to find valid values)'), + stack: z.string().describe('The ID of the stack (use platform_list_stacks to find valid values)'), + template: z.string().describe('The ID of the template (use platform_list_templates to find valid values)'), + flowBlueprintId: z.string().optional().describe('Optional blueprint ID to initialize the hosted instance with starter flows (use platform_list_blueprints to find valid values)') + }, + handler: async (args, { inject }) => { + const payload = { + name: args.name, + applicationId: args.applicationId, + projectType: args.projectType, + stack: args.stack, + template: args.template + } + if (args.flowBlueprintId) { + payload.flowBlueprintId = args.flowBlueprintId + } + const response = await inject({ method: 'POST', url: '/api/v1/projects', payload }) + return response + } + } +] diff --git a/forge/ee/lib/mcp/tools/navigation.js b/forge/ee/lib/mcp/tools/navigation.js new file mode 100644 index 0000000000..0c5affcee8 --- /dev/null +++ b/forge/ee/lib/mcp/tools/navigation.js @@ -0,0 +1,42 @@ +const { z } = require('zod') + +module.exports = [ + { + name: 'platform_open_hosted_instance_editor', + description: 'FlowFuse platform automation tool: Get the URL to open the Node-RED editor for a hosted instance. Returns a URL the user can open in their browser.', + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + hostedInstanceId: z.string().describe('The ID or hashid of the hosted instance') + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/projects/${args.hostedInstanceId}` }) + if (response.statusCode >= 400) { + return response + } + const instance = response.json() + return { + statusCode: 200, + json: () => ({ url: `${instance.url}/editor`, name: instance.name }) + } + } + }, + { + name: 'platform_open_hosted_instance', + description: 'FlowFuse platform automation tool: Get the URL to open the hosted instance dashboard in the FlowFuse platform. Returns a URL the user can open in their browser.', + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + hostedInstanceId: z.string().describe('The ID or hashid of the hosted instance') + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/projects/${args.hostedInstanceId}` }) + if (response.statusCode >= 400) { + return response + } + const instance = response.json() + return { + statusCode: 200, + json: () => ({ url: instance.url, name: instance.name }) + } + } + } +] diff --git a/forge/ee/lib/mcp/tools/platform.js b/forge/ee/lib/mcp/tools/platform.js new file mode 100644 index 0000000000..8353a3e71f --- /dev/null +++ b/forge/ee/lib/mcp/tools/platform.js @@ -0,0 +1,42 @@ +module.exports = [ + { + name: 'platform_list_hosted_instance_types', + description: 'FlowFuse platform automation tool: List all available hosted instance types. Use this to find valid projectType values when creating a hosted instance.', + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: {}, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: '/api/v1/project-types' }) + return response + } + }, + { + name: 'platform_list_stacks', + description: 'FlowFuse platform automation tool: List all available stacks (Node-RED versions). Use this to find valid stack values when creating a hosted instance.', + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: {}, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: '/api/v1/stacks' }) + return response + } + }, + { + name: 'platform_list_templates', + description: 'FlowFuse platform automation tool: List all available templates. When creating a hosted instance, if only one template exists, use it automatically. If multiple templates exist, ask the user which one to use.', + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: {}, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: '/api/v1/templates' }) + return response + } + }, + { + name: 'platform_list_blueprints', + description: 'FlowFuse platform automation tool: List all available flow blueprints. Blueprints provide starter flows that can be used when creating a new hosted instance.', + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: {}, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: '/api/v1/flow-blueprints' }) + return response + } + } +] diff --git a/forge/ee/lib/mcp/tools/snapshots.js b/forge/ee/lib/mcp/tools/snapshots.js new file mode 100644 index 0000000000..4c46a73488 --- /dev/null +++ b/forge/ee/lib/mcp/tools/snapshots.js @@ -0,0 +1,111 @@ +const { z } = require('zod') + +module.exports = [ + { + name: 'platform_list_hosted_instance_snapshots', + description: `FlowFuse platform automation tool: + Lists all snapshots that were taken from a hosted instance. + A snapshot is like a saved photo of everything running on the hosted instance at a point in time: the flows, the settings, and the configuration. + Use this when you need to see what snapshots exist for a hosted instance, for example to pick one to deploy or to check what changed between versions.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + hostedInstanceId: z.string().describe('The ID or hashid of the hosted instance'), + cursor: z.string().optional().describe('Cursor for pagination (the hashid of the last item from the previous page)'), + limit: z.number().min(1).max(20).describe('How many results to return per page') + }, + handler: async (args, { inject }) => { + let url = `/api/v1/projects/${args.hostedInstanceId}/snapshots` + const params = [] + if (args.cursor) { + params.push(`cursor=${args.cursor}`) + } + if (args.limit) { + params.push(`limit=${args.limit}`) + } + if (params.length > 0) { + url += '?' + params.join('&') + } + const response = await inject({ method: 'GET', url }) + return response + } + }, + { + name: 'platform_create_hosted_instance_snapshot', + description: `FlowFuse platform automation tool: + Creates a new snapshot from a hosted instance, capturing everything it is running right now (flows, settings, and configuration). + Think of it as taking a photo of the hosted instance so you can go back to this exact state later or deploy it to other hosted instances. + Use this when the user wants to save the current state of a hosted instance before making changes, or to create a version that can be rolled out elsewhere.`, + annotations: { readOnlyHint: false, destructiveHint: false }, + inputSchema: { + hostedInstanceId: z.string().describe('The ID or hashid of the hosted instance'), + name: z.string().optional().describe('Name for the snapshot'), + description: z.string().optional().describe('Description of the snapshot') + }, + handler: async (args, { inject }) => { + const payload = {} + if (args.name) { + payload.name = args.name + } + if (args.description) { + payload.description = args.description + } + const response = await inject({ method: 'POST', url: `/api/v1/projects/${args.hostedInstanceId}/snapshots`, payload }) + return response + } + }, + { + name: 'platform_list_remote_instance_snapshots', + description: `FlowFuse platform automation tool: + Lists all snapshots that were taken from a remote instance (device). + A snapshot is like a saved photo of everything running on the remote instance at a point in time: the flows, the settings, and the configuration. + Use this when you need to see what snapshots exist for a remote instance, for example to pick one to deploy or to check what changed between versions.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + remoteInstanceId: z.string().describe('The ID or hashid of the remote instance'), + cursor: z.string().optional().describe('Cursor for pagination (the hashid of the last item from the previous page)'), + limit: z.number().min(1).max(20).describe('How many results to return per page') + }, + handler: async (args, { inject }) => { + let url = `/api/v1/devices/${args.remoteInstanceId}/snapshots` + const params = [] + if (args.cursor) { + params.push(`cursor=${args.cursor}`) + } + if (args.limit) { + params.push(`limit=${args.limit}`) + } + if (params.length > 0) { + url += '?' + params.join('&') + } + const response = await inject({ method: 'GET', url }) + return response + } + }, + { + name: 'platform_create_remote_instance_snapshot', + description: `FlowFuse platform automation tool: + This tool will always fail if the remote instance is not reachable. + This tool exclusively creates snapshots, it does not create anything else. + Before calling this tool, you must call platform_get_remote_instance_status first to check that the device is online and running. + Creates a new snapshot from a remote instance, capturing everything it is running right now (flows, settings, and configuration). + Think of it as taking a photo of the remote instance so you can go back to this exact state later or deploy it to other remote instances. + Use this when the user wants to save the current state of a remote instance before making changes, or to create a snapshot that can be rolled out elsewhere.`, + annotations: { readOnlyHint: false, destructiveHint: false }, + inputSchema: { + remoteInstanceId: z.string().describe('The ID or hashid of the remote instance'), + name: z.string().optional().describe('Name for the snapshot'), + description: z.string().optional().describe('Description of the snapshot') + }, + handler: async (args, { inject }) => { + const payload = {} + if (args.name) { + payload.name = args.name + } + if (args.description) { + payload.description = args.description + } + const response = await inject({ method: 'POST', url: `/api/v1/devices/${args.remoteInstanceId}/snapshots`, payload }) + return response + } + } +] diff --git a/forge/ee/lib/mcp/tools/teams.js b/forge/ee/lib/mcp/tools/teams.js index 1efd178c91..cec5047e07 100644 --- a/forge/ee/lib/mcp/tools/teams.js +++ b/forge/ee/lib/mcp/tools/teams.js @@ -2,8 +2,8 @@ const { z } = require('zod') module.exports = [ { - name: 'list-teams', - description: 'List all teams the authenticated user belongs to. Returns team names, slugs, IDs, and membership roles.', + name: 'platform_list_teams', + description: 'FlowFuse platform automation tool: List all teams the authenticated user belongs to. Returns team names, slugs, IDs, and membership roles.', annotations: { readOnlyHint: true, destructiveHint: false }, inputSchema: {}, handler: async (args, { inject }) => { @@ -12,8 +12,8 @@ module.exports = [ } }, { - name: 'get-team', - description: 'Get details of a specific team by its ID, including team type, member count, and instance counts.', + name: 'platform_get_team', + description: 'FlowFuse platform automation tool: Get details of a specific team by its ID, including team type, member count, hosted instance and remote instance counts.', annotations: { readOnlyHint: true, destructiveHint: false }, inputSchema: { teamId: z.string().describe('The ID or hashid of the team') diff --git a/forge/ee/routes/httpTokens/index.js b/forge/ee/routes/httpTokens/index.js index ec7ad93d2f..469c53ce16 100644 --- a/forge/ee/routes/httpTokens/index.js +++ b/forge/ee/routes/httpTokens/index.js @@ -1,3 +1,5 @@ +const { EXPERT_MCP_SCOPES } = require('../../lib/expert') + module.exports = async function (app) { app.config.features.register('httpBearerTokens', true, true) @@ -220,6 +222,7 @@ module.exports = async function (app) { if (!token || !token.scope) { return false } - return token.scope.includes('ff-expert:mcp') + + return token.scope.some(s => EXPERT_MCP_SCOPES.includes(s)) } } diff --git a/forge/ee/routes/mcp/server.js b/forge/ee/routes/mcp/server.js index 73a78ab3af..ff729a8374 100644 --- a/forge/ee/routes/mcp/server.js +++ b/forge/ee/routes/mcp/server.js @@ -1,93 +1,27 @@ -const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js') -const { StreamableHTTPServerTransport } = require('@modelcontextprotocol/sdk/server/streamableHttp.js') - -const { loadToolDefinitions, registerTools } = require('../../lib/mcp/toolLoader') - -// Load tool definitions once at startup -const toolDefinitions = loadToolDefinitions() - /** * MCP Platform Tools Server * - * Exposes FlowFuse platform management capabilities as MCP tools. - * Stateless Streamable HTTP: each POST creates a fresh McpServer and transport. - * Auth via Bearer token (PAT), forwarded through app.inject() to existing routes. + * Groundwork for exposing FlowFuse platform capabilities to third-party MCP + * agents via Streamable HTTP. These endpoints will be enabled in a future + * release once the tool definitions are stable and scoped PATs are in place. + * + * For now, the tool definitions in ee/lib/mcp/tools/ are consumed internally + * by the first-party FlowFuse agent over MQTT. * * @param {import('../../../forge').ForgeApplication} app */ module.exports = async function (app) { - app.addHook('preHandler', async (request, reply) => { - // Gate on feature flag - if (!app.config.features.enabled('expertPlatformAutomation')) { - reply.code(404).send({ code: 'not_found', error: 'Not Found' }) - return - } - // Require a user-owned PAT (not device/project/broker tokens) - if (!request.session?.User) { - reply.code(401).send({ code: 'unauthorized', error: 'unauthorized' }) - } - }) - - /** - * POST / - MCP protocol endpoint (Streamable HTTP) - * - * Each request creates a fresh McpServer instance with a stateless transport. - * The auth token is forwarded to all internal route calls via app.inject(). - */ + // POST will serve the MCP Streamable HTTP protocol once third-party agent support is enabled app.post('/', async (request, reply) => { - const server = new McpServer( - { name: 'FlowFuse Platform', version: '1.0.0' }, - { capabilities: { tools: {} } } - ) - - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: undefined // stateless, no server-side sessions - }) - - // Bind inject to this request's auth token - const inject = (opts) => { - return app.inject({ - ...opts, - headers: { - ...opts.headers, - authorization: request.headers.authorization - } - }) - } - - // Stub scope check: will enforce PAT scopes once scoped PATs (#7411) land. - // When implemented, this will check tool.annotations against the PAT's - // readOnly flag and team scope restrictions. - const checkScope = (_tool) => { - return null // no restriction for now - } - - registerTools(server, toolDefinitions, inject, checkScope) - - await server.connect(transport) - - // Hand off response handling to the MCP transport. - // reply.hijack() tells Fastify we're managing the response directly. - reply.hijack() - - // The MCP SDK's transport uses @hono/node-server internally, which sets - // a drain timeout that calls socket.destroySoon(). Fastify's app.inject() - // creates mock sockets that lack this method, so we polyfill it. - const socket = request.raw.socket - if (socket && !socket.destroySoon) { - socket.destroySoon = () => socket.destroy?.() - } - - await transport.handleRequest(request.raw, reply.raw, request.body) - await server.close() + reply.code(405).send({ code: 'method_not_allowed', error: 'MCP HTTP endpoints are not available.' }) }) - // GET and DELETE are not supported in stateless mode + // GET and DELETE reserved for future session management (stateful MCP transport) app.get('/', async (request, reply) => { - reply.code(405).send({ code: 'method_not_allowed', error: 'Method Not Allowed. Use POST for MCP requests.' }) + reply.code(405).send({ code: 'method_not_allowed', error: 'MCP HTTP endpoints are not available.' }) }) app.delete('/', async (request, reply) => { - reply.code(405).send({ code: 'method_not_allowed', error: 'Method Not Allowed. Stateless mode, no sessions to terminate.' }) + reply.code(405).send({ code: 'method_not_allowed', error: 'MCP HTTP endpoints are not available.' }) }) } diff --git a/forge/routes/auth/index.js b/forge/routes/auth/index.js index 8a252dc103..2f1f0a9db3 100644 --- a/forge/routes/auth/index.js +++ b/forge/routes/auth/index.js @@ -110,8 +110,8 @@ async function init (app, opts) { // delete one time code immediately (spent) await accessToken.destroy() } - if (accessToken.ownerType === 'user') { - request.session.User = await app.db.models.User.findOne({ where: { id: parseInt(accessToken.ownerId) } }) + if (accessToken.ownerType === 'user' || accessToken.ownerType === 'user:expert-mcp') { + request.session.User = await app.db.models.User.findOne({ where: { id: +accessToken.ownerId } }) // Unlike a cookie based session, we'll allow user tokens to continue // working if password has expired or email isn't verified // TODO: validate this choice @@ -158,6 +158,11 @@ async function init (app, opts) { return } } + if (accessToken.scope?.includes('ff-expert:platform') && accessToken.ownerType !== 'user:expert-mcp') { + // this scope is only valid on the dedicated platform-automation token type + reply.code(401).send({ code: 'unauthorized', error: 'unauthorized' }) + return + } return } reply.code(401).send({ code: 'unauthorized', error: 'unauthorized' }) diff --git a/forge/routes/auth/permissions.js b/forge/routes/auth/permissions.js index 9d772def60..15a713be0c 100644 --- a/forge/routes/auth/permissions.js +++ b/forge/routes/auth/permissions.js @@ -24,6 +24,35 @@ const IMPLICIT_TOKEN_SCOPES = { 'broker:clients:list', 'broker:clients:link', 'assistant:call' // permit access to assistant + ], + 'user:expert-mcp': [ + // applications + 'team:projects:list', // list applications, list hosted instances, get instances status + 'project:read', // get application details + 'team:device:list', // list application remote instances + 'application:audit-log', // get application audit log + // devices + 'device:read', // get remote instance details + 'device:create', // create remote instance + 'device:edit', // assign remote instance to application + // hosted instances + 'project:create', // create application, create hosted instance, check instance name + 'project-type:read', + 'project-type:list', + 'project:log', // get hosted instance logs + // snapshots + 'project:snapshot:list', // list hosted instance snapshots + 'project:snapshot:create', // create hosted instance snapshot + 'device:snapshot:list', // list remote instance snapshots + 'device:snapshot:create', // create remote instance snapshot + // teams + 'user:team:list', // list teams + 'team:read', // get team details + // platform + 'stack:list', + 'flow-blueprint:list', + 'project:status', + 'template:list' ] } @@ -69,7 +98,7 @@ module.exports = fp(async function (app, opts) { // Permission disabled via admin settings reply.code(403).send({ code: 'unauthorized', error: 'unauthorized' }) throw new Error() - } else if (permission.role && permission.role !== Roles.Admin && (!request.session.scope || request.session.ownerType === 'user')) { + } else if (permission.role && permission.role !== Roles.Admin && (!request.session.scope || request.session.ownerType === 'user' || request.session.ownerType === 'user:expert-mcp')) { // The user is required to have a role in the team associated with // this request if (!request.teamMembership) { @@ -114,6 +143,7 @@ module.exports = fp(async function (app, opts) { // But they are using an access_token that could be scoped down // We also need to check against the list of implicit scopes for // a given token type (ie device/project) + if (!request.session.scope.includes(scope) && (!IMPLICIT_TOKEN_SCOPES[request.session.ownerType] || !IMPLICIT_TOKEN_SCOPES[request.session.ownerType].includes(scope))) { reply.code(403).send({ code: 'unauthorized', error: 'unauthorized' }) diff --git a/frontend/src/composables/FeatureChecks.ts b/frontend/src/composables/FeatureChecks.ts index 1487d07f82..92d4d69b63 100644 --- a/frontend/src/composables/FeatureChecks.ts +++ b/frontend/src/composables/FeatureChecks.ts @@ -128,7 +128,8 @@ export const FEATURE_CONFIGS: FeatureConfig[] = [ { output: 'isFlowFuseNodesFeatureEnabled', platformKey: 'ffNodes' }, { output: 'isInstanceAutoStackUpdateFeatureEnabled', platformKey: 'autoStackUpdate' }, { output: 'isDevOpsPipelinesFeatureEnabled', platformKey: 'devops-pipelines' }, - { output: 'isExternalMqttBrokerFeatureEnabled', platformKey: 'externalBroker' } + { output: 'isExternalMqttBrokerFeatureEnabled', platformKey: 'externalBroker' }, + { output: 'isExpertPlatformAutomationFeatureEnabled', platformKey: 'expertPlatformAutomation' } ] function isPlatformFeatureEnabled (state: PlatformState, platformKey: string, platformSource?: 'settings'): boolean { diff --git a/frontend/src/mcp/tools/context.ts b/frontend/src/mcp/tools/context.ts new file mode 100644 index 0000000000..3d9c1b9a7c --- /dev/null +++ b/frontend/src/mcp/tools/context.ts @@ -0,0 +1,27 @@ +import { useContextStore } from '@/stores/context.js' +import type { McpToolDefinition } from '@/types' + +const tools: McpToolDefinition[] = [ + { + name: 'ui_get_context', + description: `FlowFuse UI automation tool: + This is your go-to tool whenever a user request is ambiguous or you are not sure what they are referring to. + It tells you everything about where the user is right now: what page they are on, which team they belong to, + which application, hosted instance, or remote instance they are looking at (if any), and whether they are + inside the Node-RED editor (immersive mode) or in the main FlowFuse app. + Always call this tool first when you need to fill in missing context, like figuring out which instance or + application the user is talking about without them having to spell it out. + If there are still things you are not sure about after reading the response, ask the user to clarify.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + type: 'object', + properties: {} + }, + handler () { + const contextStore = useContextStore() + return contextStore.expert + } + } +] + +export default tools diff --git a/frontend/src/mcp/tools/index.ts b/frontend/src/mcp/tools/index.ts new file mode 100644 index 0000000000..cb0174161c --- /dev/null +++ b/frontend/src/mcp/tools/index.ts @@ -0,0 +1,13 @@ +import contextTools from './context.js' +import navigationTools from './navigation.js' +import routesTools from './routes.js' + +import type { McpToolDefinition } from '@/types' + +const allTools: McpToolDefinition[] = [ + ...contextTools, + ...routesTools, + ...navigationTools +] + +export default allTools diff --git a/frontend/src/mcp/tools/navigation.ts b/frontend/src/mcp/tools/navigation.ts new file mode 100644 index 0000000000..df1c3b1988 --- /dev/null +++ b/frontend/src/mcp/tools/navigation.ts @@ -0,0 +1,43 @@ +import type { McpToolDefinition } from '@/types' + +const tools: McpToolDefinition[] = [ + { + name: 'ui_navigate', + description: `FlowFuse UI automation tool: + Navigates the user's browser to a specific page. + Use ui_list_routes to discover valid route names and the parameters they need. + Before navigating, call ui_get_context to remember what page the user is currently on, so you can go back if something goes wrong. + After calling this tool, call ui_get_context again to verify the navigation actually worked and the user ended up on the right page. + If the navigation failed, it might be because a newly created entity has not finished setting up yet. Wait a few seconds and try the navigation again. + If it still does not work after retrying, navigate the user back to the page they were on before and let them know what happened.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + type: 'object', + properties: { + route: { + type: 'string', + description: 'The route name to navigate to (e.g. "instance-overview", "team-home")' + }, + params: { + type: 'object', + description: 'Route parameters (e.g. { id: "abc123" } or { team_slug: "my-team" })', + additionalProperties: { type: 'string' } + } + }, + required: ['route'] + }, + async handler (args, { router }) { + const { route: routeName, params } = args as { route: string, params?: Record } + + const resolved = router.resolve({ name: routeName, params }) + if (!resolved || !resolved.matched.length) { + return { success: false, error: `Route "${routeName}" not found` } + } + + await router.push({ name: routeName, params }) + return { success: true, route: routeName, path: resolved.fullPath } + } + } +] + +export default tools diff --git a/frontend/src/mcp/tools/routes.ts b/frontend/src/mcp/tools/routes.ts new file mode 100644 index 0000000000..c6c276e813 --- /dev/null +++ b/frontend/src/mcp/tools/routes.ts @@ -0,0 +1,40 @@ +import type { Router } from 'vue-router' + +import type { McpToolDefinition } from '@/types' + +function getRouteList (router: Router) { + return router.getRoutes() + .filter(route => route.name && !route.redirect) + .map(route => ({ + name: route.name as string, + path: route.path, + meta: { + title: route.meta?.title || null, + adminOnly: route.meta?.adminOnly || false, + requiresLogin: route.meta?.requiresLogin ?? true + } + })) + .sort((a, b) => a.name.localeCompare(b.name)) +} + +const tools: McpToolDefinition[] = [ + { + name: 'ui_list_routes', + description: `FlowFuse UI automation tool: + Lists all the pages the user can visit in the FlowFuse app, along with their route names, path patterns, and what parameters they need. + Use this to find the right route name and params before calling ui_navigate. + Each route has a name (like "device-overview" or "instance-overview"), a path pattern showing what parameters it expects + (like "/device/:id/overview" means you need to pass { id: "..." }), and metadata like the page title. + You do not need to call this every time. If you already know the route name from a tool description (e.g. platform_create_hosted_instance tells you to use "instance-overview"), just use it directly.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + type: 'object', + properties: {} + }, + handler (_args, { router }) { + return { routes: getRouteList(router) } + } + } +] + +export default tools diff --git a/frontend/src/services/automations.service.ts b/frontend/src/services/automations.service.ts new file mode 100644 index 0000000000..fe67f5404b --- /dev/null +++ b/frontend/src/services/automations.service.ts @@ -0,0 +1,67 @@ +import { BaseService } from './service.contract' + +import allTools from '@/mcp/tools' +import type { AutomationsServiceI, CreateServiceOptions, McpToolDefinition, McpToolWireDefinition } from '@/types' + +class AutomationsService extends BaseService implements AutomationsServiceI { + private $tools: Map + + constructor ({ app, router, services }: CreateServiceOptions) { + super({ + name: 'automations', + app, + router, + services + }) + + this.$tools = new Map() + for (const tool of allTools) { + this.$tools.set(tool.name, tool) + } + } + + /** + * Returns tool definitions without handlers, suitable for + * sending over MQTT in response to a tool list discovery request. + */ + getToolDefinitions (): McpToolWireDefinition[] { + return Array.from(this.$tools.values()).map((tool) => ({ + name: tool.name, + description: tool.description, + annotations: tool.annotations, + inputSchema: tool.inputSchema + })) + } + + /** + * Dispatches a tool call by name. Returns the tool result or an error object. + */ + async dispatch (toolName: string, args: unknown = {}): Promise { + const tool = this.$tools.get(toolName) + if (!tool) { + return { error: `Unknown UI tool: ${toolName}` } + } + + try { + return await tool.handler(args, { router: this.$router! }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { error: `Tool "${toolName}" failed: ${message}` } + } + } +} + +let AutomationsServiceInstance: AutomationsService | null = null + +export function createAutomationsService ({ app, router, services }: CreateServiceOptions): AutomationsService { + if (!AutomationsServiceInstance) { + AutomationsServiceInstance = new AutomationsService({ + app, + router, + services + }) + } + return AutomationsServiceInstance +} + +export default createAutomationsService diff --git a/frontend/src/services/service.registry.ts b/frontend/src/services/service.registry.ts index b49a5bb8b5..c71e161c2a 100644 --- a/frontend/src/services/service.registry.ts +++ b/frontend/src/services/service.registry.ts @@ -1,3 +1,4 @@ +import { createAutomationsService } from './automations.service' import { createBootstrapService } from './bootstrap.service' import { createMqttService } from './mqtt.service' import { createMessagingService } from './post-message.service' @@ -5,5 +6,6 @@ import { createMessagingService } from './post-message.service' export default [ { key: 'bootstrap' as const, create: createBootstrapService, requiredLifecycle: ['init', 'destroy'] as const }, { key: 'postMessage' as const, create: createMessagingService, requiredLifecycle: ['destroy'] as const }, - { key: 'mqtt' as const, create: createMqttService, requiredLifecycle: ['destroy'] as const } + { key: 'mqtt' as const, create: createMqttService, requiredLifecycle: ['destroy'] as const }, + { key: 'automations' as const, create: createAutomationsService, requiredLifecycle: [] as const } ] diff --git a/frontend/src/stores/account-settings.js b/frontend/src/stores/account-settings.js index 933883cbbd..abfe19f6b3 100644 --- a/frontend/src/stores/account-settings.js +++ b/frontend/src/stores/account-settings.js @@ -6,8 +6,7 @@ import { useAccountAuthStore } from '@/stores/account-auth.js' import { useContextStore } from '@/stores/context.js' export const POSTHOG_FLAGS = { - FF_FEATURE_FLAGS: 'FF_FEATURE_FLAGS', - EXPERT_COMMS_BETA_ENABLED: 'EXPERT_COMMS_BETA_ENABLED' + FF_FEATURE_FLAGS: 'FF_FEATURE_FLAGS' } export const useAccountSettingsStore = defineStore('account-settings', { @@ -49,7 +48,6 @@ export const useAccountSettingsStore = defineStore('account-settings', { checks.isExternalMqttBrokerFeatureEnabledForPlatform && checks.isMqttBrokerFeatureEnabledForTeam // adding in PostHog Feature Flags - checks.isExpertCommsBetaEnabled = !!state.posthogFlags[POSTHOG_FLAGS.EXPERT_COMMS_BETA_ENABLED] checks.isPostHogFeatureFlagsEnabled = !!state.posthogFlags[POSTHOG_FLAGS.FF_FEATURE_FLAGS] return checks diff --git a/frontend/src/stores/context.js b/frontend/src/stores/context.js index c29925a01e..153b6fa7eb 100644 --- a/frontend/src/stores/context.js +++ b/frontend/src/stores/context.js @@ -4,6 +4,7 @@ import teamApi from '../api/team.js' import product from '../services/product.js' import { useAccountAuthStore } from './account-auth.js' +import { useAccountSettingsStore } from './account-settings.js' import { useProductAssistantStore } from './product-assistant.js' import { useProductExpertStore } from './product-expert.js' @@ -108,6 +109,8 @@ export const useContextStore = defineStore('context', { rawRoute, selectedNodes, scope, + supportsPlatformAutomation: useAccountSettingsStore().featuresCheck?.isExpertPlatformAutomationFeatureEnabled ?? false, + supportsPlatformUIAutomation: useAccountSettingsStore().featuresCheck?.isExpertPlatformAutomationFeatureEnabled ?? false, questionCadence: useProductExpertStore().questionCadence } } diff --git a/frontend/src/stores/product-expert.js b/frontend/src/stores/product-expert.js index 29dffd4635..28bb4119e8 100644 --- a/frontend/src/stores/product-expert.js +++ b/frontend/src/stores/product-expert.js @@ -71,7 +71,7 @@ export const useProductExpertStore = defineStore('product-expert', { sessionId () { return this._agentStore.sessionId }, shouldUseMqtt () { const accountSettingsStore = useAccountSettingsStore() - return accountSettingsStore.featuresCheck?.isExpertCommsBetaEnabled && this.isSupportAgent + return accountSettingsStore.featuresCheck?.isExternalMqttBrokerFeatureEnabled && this.isSupportAgent } }, actions: { @@ -189,9 +189,14 @@ export const useProductExpertStore = defineStore('product-expert', { agentStore.sessionId = uuidv4() } - // Start session timing on first message (if not already running) + // Start session timing on first message, or reset the clock on subsequent + // messages so the session stays alive while the user is actively chatting if (!agentStore.sessionStartTime) { this.startSessionTimer() + } else { + agentStore.sessionStartTime = Date.now() + agentStore.sessionWarningShown = false + agentStore.sessionExpiredShown = false } // Add user message @@ -354,7 +359,53 @@ export const useProductExpertStore = defineStore('product-expert', { } }) break + case parsedTopic.inflightType === 'automation-ui:mcp-get-features': { + // handle UI MCP features request + try { + const automationsService = servicesOrchestrator.$serviceInstances.automations + const tools = automationsService.getToolDefinitions() + + await mqttService.publishMessage(this.mqttConnectionKey, { + qos: 2, + topic: responseTopic, + payload: JSON.stringify({ tools }), + correlationData: transactionId, + userProperties: { + sessionId, + transactionId: chatTransactionId, + origin: window.origin || window.location.origin + } + }) + } catch (e) { + this._onMqttError(e) + } + break + } + case parsedTopic.inflightType === 'automation-ui:mcp-call-tool': { + // handle UI MCP tool invocation request + try { + const automationsService = servicesOrchestrator.$serviceInstances.automations + const { name, input } = payload?.data || {} + const result = await automationsService.dispatch(name, input) + + await mqttService.publishMessage(this.mqttConnectionKey, { + qos: 2, + topic: responseTopic, + payload: JSON.stringify(result), + correlationData: transactionId, + userProperties: { + sessionId, + transactionId: chatTransactionId, + origin: window.origin || window.location.origin + } + }) + } catch (e) { + this._onMqttError(e) + } + break + } case parsedTopic.inflightType.startsWith('automation:'): + // passes automation requests to the Assistant try { const result = await assistantStore.invokeActionAwaitResponse({ action: `automation/${parsedTopic.inflightType.replace('automation:', '')}`, diff --git a/frontend/src/types/common/index.ts b/frontend/src/types/common/index.ts new file mode 100644 index 0000000000..fdc633235f --- /dev/null +++ b/frontend/src/types/common/index.ts @@ -0,0 +1 @@ +export * from './types.js' diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 5f0f5b8ec2..a866fdae00 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -11,13 +11,13 @@ // Re-export all auto-generated OpenAPI types. // Run `npm run generate:types` to produce this file. -export * from './generated.js' - -export * from './services/index.js' +export * from './common' +export * from './mcp' +export * from './services' +export * from './subscribers' +export * from './transport' -export * from './transport/transport.types.js' - -export * from './subscribers/subscriber.types.js' +export * from './generated.js' // --------------------------------------------------------------------------- // Roles diff --git a/frontend/src/types/mcp/index.ts b/frontend/src/types/mcp/index.ts new file mode 100644 index 0000000000..7c42d3b96a --- /dev/null +++ b/frontend/src/types/mcp/index.ts @@ -0,0 +1 @@ +export * from './mcp.types.js' diff --git a/frontend/src/types/mcp/mcp.types.ts b/frontend/src/types/mcp/mcp.types.ts new file mode 100644 index 0000000000..1a059f45d1 --- /dev/null +++ b/frontend/src/types/mcp/mcp.types.ts @@ -0,0 +1,33 @@ +import type { Router } from 'vue-router' + +export interface McpToolAnnotations { + readOnlyHint?: boolean + destructiveHint?: boolean + idempotentHint?: boolean + openWorldHint?: boolean +} + +export interface McpToolInputSchema { + type: 'object' + properties: Record + required?: string[] +} + +export interface McpToolHandlerContext { + router: Router +} + +export interface McpToolDefinition { + name: string + description: string + annotations: McpToolAnnotations + inputSchema: McpToolInputSchema + handler: (args: unknown, context: McpToolHandlerContext) => unknown | Promise +} + +export interface McpToolWireDefinition { + name: string + description: string + annotations: McpToolAnnotations + inputSchema: McpToolInputSchema +} diff --git a/frontend/src/types/services/automations.types.ts b/frontend/src/types/services/automations.types.ts new file mode 100644 index 0000000000..453c58c9e1 --- /dev/null +++ b/frontend/src/types/services/automations.types.ts @@ -0,0 +1,6 @@ +import { McpToolWireDefinition } from '@/types' + +export interface AutomationsServiceI { + getToolDefinitions(): McpToolWireDefinition[] + dispatch(toolName: string, args?: unknown): Promise +} diff --git a/frontend/src/types/services/index.ts b/frontend/src/types/services/index.ts index 130d91b51e..33f4940600 100644 --- a/frontend/src/types/services/index.ts +++ b/frontend/src/types/services/index.ts @@ -2,3 +2,4 @@ export * from './service.types.js' export * from './mqtt.types.js' export * from './post-message.types.js' export * from './bootstrap.types.js' +export * from './automations.types.js' diff --git a/frontend/src/types/services/service.types.ts b/frontend/src/types/services/service.types.ts index 5972a71b50..a2cc99069b 100644 --- a/frontend/src/types/services/service.types.ts +++ b/frontend/src/types/services/service.types.ts @@ -1,7 +1,7 @@ import type { App } from 'vue' import type { Router } from 'vue-router' -import type { BootstrapServiceI, MqttServiceI, PostMessageServiceI } from '@/types' +import type { AutomationsServiceI, BootstrapServiceI, MqttServiceI, PostMessageServiceI } from '@/types' /** * Minimal lifecycle contract for app services. @@ -15,6 +15,7 @@ export type ServiceInstances = { bootstrap: BootstrapServiceI | null postMessage: PostMessageServiceI | null mqtt: MqttServiceI | null + automations: AutomationsServiceI | null } export interface CreateServiceOptions { diff --git a/frontend/src/types/subscribers/index.ts b/frontend/src/types/subscribers/index.ts new file mode 100644 index 0000000000..27c1517115 --- /dev/null +++ b/frontend/src/types/subscribers/index.ts @@ -0,0 +1 @@ +export * from './subscriber.types' diff --git a/frontend/src/types/transport/index.ts b/frontend/src/types/transport/index.ts new file mode 100644 index 0000000000..e412ad0051 --- /dev/null +++ b/frontend/src/types/transport/index.ts @@ -0,0 +1 @@ +export * from './transport.types.js' diff --git a/package-lock.json b/package-lock.json index c83a79f9ee..c690698f57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,6 @@ "@heroicons/vue": "2.1.5", "@levminer/speakeasy": "^1.4.2", "@node-red/util": "^5.0.0", - "@modelcontextprotocol/sdk": "^1.29.0", "@node-saml/passport-saml": "^5.0.0", "@redis/client": "^6.0.0", "@sentry/node": "^10.54.0", diff --git a/package.json b/package.json index 96bd73e8c9..7c00cdde9a 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,6 @@ "@heroicons/vue": "2.1.5", "@levminer/speakeasy": "^1.4.2", "@node-red/util": "^5.0.0", - "@modelcontextprotocol/sdk": "^1.29.0", "@node-saml/passport-saml": "^5.0.0", "@redis/client": "^6.0.0", "@sentry/node": "^10.54.0", diff --git a/test/unit/forge/ee/routes/mcp/server_spec.js b/test/unit/forge/ee/routes/mcp/server_spec.js index 5562b42798..37f7be9f38 100644 --- a/test/unit/forge/ee/routes/mcp/server_spec.js +++ b/test/unit/forge/ee/routes/mcp/server_spec.js @@ -26,62 +26,25 @@ describe('MCP Platform Tools Server', function () { await app.close() }) - /** - * Parses an SSE response from the MCP transport. - * Extracts JSON-RPC messages from `data:` lines. - */ - function parseSSEResponse (response) { - const body = response.body - if (response.headers['content-type']?.includes('application/json')) { - return { statusCode: response.statusCode, result: JSON.parse(body) } - } - const messages = [] - const lines = body.split('\n') - for (const line of lines) { - if (line.startsWith('data: ')) { - try { - messages.push(JSON.parse(line.slice(6))) - } catch (e) { - // skip non-JSON data lines - } - } - } - if (messages.length === 1) { - return { statusCode: response.statusCode, result: messages[0] } - } - return { statusCode: response.statusCode, messages } - } - describe('Feature flag', function () { it('should register the expertPlatformAutomation feature flag', async function () { app.config.features.enabled('expertPlatformAutomation').should.equal(true) }) }) - describe('Authentication', function () { - it('should return 401 without auth', async function () { - const response = await app.inject({ - method: 'POST', - url: '/api/v1/mcp', - payload: { jsonrpc: '2.0', method: 'initialize', id: 1 } - }) - response.statusCode.should.equal(401) - }) - - it('should return 401 with invalid token', async function () { + describe('HTTP endpoints are disabled', function () { + it('should return 405 for POST', async function () { const response = await app.inject({ method: 'POST', url: '/api/v1/mcp', headers: { - authorization: 'Bearer invalid-token' + authorization: `Bearer ${TestObjects.alicePAT.token}` }, payload: { jsonrpc: '2.0', method: 'initialize', id: 1 } }) - response.statusCode.should.equal(401) + response.statusCode.should.equal(405) }) - }) - describe('Transport', function () { it('should return 405 for GET', async function () { const response = await app.inject({ method: 'GET', @@ -105,207 +68,6 @@ describe('MCP Platform Tools Server', function () { }) }) - describe('Initialize', function () { - it('should respond with server info and capabilities', async function () { - const response = await app.inject({ - method: 'POST', - url: '/api/v1/mcp', - headers: { - authorization: `Bearer ${TestObjects.alicePAT.token}`, - 'content-type': 'application/json', - accept: 'application/json, text/event-stream' - }, - payload: { - jsonrpc: '2.0', - method: 'initialize', - id: 1, - params: { - protocolVersion: '2025-03-26', - capabilities: {}, - clientInfo: { name: 'test-client', version: '1.0.0' } - } - } - }) - response.statusCode.should.equal(200) - const { result } = parseSSEResponse(response) - result.should.have.property('result') - result.result.should.have.property('serverInfo') - result.result.serverInfo.name.should.equal('FlowFuse Platform') - result.result.serverInfo.version.should.equal('1.0.0') - result.result.should.have.property('capabilities') - result.result.capabilities.should.have.property('tools') - }) - }) - - describe('Tool listing', function () { - it('should list registered tools with annotations', async function () { - const response = await app.inject({ - method: 'POST', - url: '/api/v1/mcp', - headers: { - authorization: `Bearer ${TestObjects.alicePAT.token}`, - 'content-type': 'application/json', - accept: 'application/json, text/event-stream' - }, - payload: [ - { jsonrpc: '2.0', method: 'notifications/initialized' }, - { jsonrpc: '2.0', method: 'tools/list', id: 2 } - ] - }) - response.statusCode.should.equal(200) - const parsed = parseSSEResponse(response) - const messages = parsed.messages || [parsed.result] - const toolsResponse = messages.find(m => m.id === 2) - toolsResponse.should.have.property('result') - toolsResponse.result.should.have.property('tools') - toolsResponse.result.tools.should.be.an.Array() - toolsResponse.result.tools.length.should.be.greaterThan(0) - - const listTeams = toolsResponse.result.tools.find(t => t.name === 'list-teams') - listTeams.should.be.an.Object() - listTeams.should.have.property('description') - listTeams.annotations.readOnlyHint.should.equal(true) - listTeams.annotations.destructiveHint.should.equal(false) - - const getTeam = toolsResponse.result.tools.find(t => t.name === 'get-team') - getTeam.should.be.an.Object() - getTeam.should.have.property('inputSchema') - }) - }) - - describe('Tool execution', function () { - it('list-teams should return teams for the authenticated user', async function () { - const response = await app.inject({ - method: 'POST', - url: '/api/v1/mcp', - headers: { - authorization: `Bearer ${TestObjects.alicePAT.token}`, - 'content-type': 'application/json', - accept: 'application/json, text/event-stream' - }, - payload: [ - { jsonrpc: '2.0', method: 'notifications/initialized' }, - { jsonrpc: '2.0', method: 'tools/call', id: 2, params: { name: 'list-teams', arguments: {} } } - ] - }) - const parsed = parseSSEResponse(response) - const messages = parsed.messages || [parsed.result] - const toolResult = messages.find(m => m.id === 2) - toolResult.should.have.property('result') - toolResult.result.should.have.property('content') - toolResult.result.content[0].type.should.equal('text') - - const data = JSON.parse(toolResult.result.content[0].text) - data.should.have.property('teams') - data.teams.should.be.an.Array() - data.teams.length.should.be.greaterThan(0) - data.teams[0].should.have.property('name', 'ATeam') - }) - - it('get-team should return team details by ID', async function () { - const teamId = app.team.hashid - const response = await app.inject({ - method: 'POST', - url: '/api/v1/mcp', - headers: { - authorization: `Bearer ${TestObjects.alicePAT.token}`, - 'content-type': 'application/json', - accept: 'application/json, text/event-stream' - }, - payload: [ - { jsonrpc: '2.0', method: 'notifications/initialized' }, - { jsonrpc: '2.0', method: 'tools/call', id: 2, params: { name: 'get-team', arguments: { teamId } } } - ] - }) - const parsed = parseSSEResponse(response) - const messages = parsed.messages || [parsed.result] - const toolResult = messages.find(m => m.id === 2) - toolResult.should.have.property('result') - - const data = JSON.parse(toolResult.result.content[0].text) - data.should.have.property('name', 'ATeam') - }) - - it('get-team should return error for non-existent team', async function () { - const response = await app.inject({ - method: 'POST', - url: '/api/v1/mcp', - headers: { - authorization: `Bearer ${TestObjects.alicePAT.token}`, - 'content-type': 'application/json', - accept: 'application/json, text/event-stream' - }, - payload: [ - { jsonrpc: '2.0', method: 'notifications/initialized' }, - { jsonrpc: '2.0', method: 'tools/call', id: 2, params: { name: 'get-team', arguments: { teamId: 'nonexistent' } } } - ] - }) - const parsed = parseSSEResponse(response) - const messages = parsed.messages || [parsed.result] - const toolResult = messages.find(m => m.id === 2) - toolResult.should.have.property('result') - toolResult.result.isError.should.equal(true) - }) - }) - - describe('Stateless behavior', function () { - it('should not leak state between sequential requests', async function () { - // First request: initialize - const res1 = await app.inject({ - method: 'POST', - url: '/api/v1/mcp', - headers: { - authorization: `Bearer ${TestObjects.alicePAT.token}`, - 'content-type': 'application/json', - accept: 'application/json, text/event-stream' - }, - payload: { - jsonrpc: '2.0', - method: 'initialize', - id: 1, - params: { - protocolVersion: '2025-03-26', - capabilities: {}, - clientInfo: { name: 'test-client', version: '1.0.0' } - } - } - }) - res1.statusCode.should.equal(200) - - // Second request: independent initialize (no session carry-over) - const res2 = await app.inject({ - method: 'POST', - url: '/api/v1/mcp', - headers: { - authorization: `Bearer ${TestObjects.alicePAT.token}`, - 'content-type': 'application/json', - accept: 'application/json, text/event-stream' - }, - payload: { - jsonrpc: '2.0', - method: 'initialize', - id: 1, - params: { - protocolVersion: '2025-03-26', - capabilities: {}, - clientInfo: { name: 'test-client-2', version: '2.0.0' } - } - } - }) - res2.statusCode.should.equal(200) - - // Both should have succeeded independently - const parsed1 = parseSSEResponse(res1) - const parsed2 = parseSSEResponse(res2) - parsed1.result.result.serverInfo.name.should.equal('FlowFuse Platform') - parsed2.result.result.serverInfo.name.should.equal('FlowFuse Platform') - - // No Mcp-Session-Id header (stateless) - should(res1.headers['mcp-session-id']).be.undefined() - should(res2.headers['mcp-session-id']).be.undefined() - }) - }) - describe('Existing registration routes', function () { it('should not break existing registration routes', async function () { const { token } = await app.instance.refreshAuthTokens() @@ -355,20 +117,12 @@ describe('MCP Platform Tools Server', function () { describe('Feature flag disabled', function () { let app - const TestObjects = {} before(async function () { app = await setup({ license: 'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImZkNDFmNmRjLTBmM2QtNGFmNy1hNzk0LWIyNWFhNGJmYTliZCIsInZlciI6IjIwMjQtMDMtMDQiLCJpc3MiOiJGbG93Rm9yZ2UgSW5jLiIsInN1YiI6IkZsb3dGdXNlIERldmVsb3BtZW50IiwibmJmIjoxNzMwNjc4NDAwLCJleHAiOjIwNzc3NDcyMDAsIm5vdGUiOiJEZXZlbG9wbWVudC1tb2RlIE9ubHkuIE5vdCBmb3IgcHJvZHVjdGlvbiIsInVzZXJzIjoxMCwidGVhbXMiOjEwLCJpbnN0YW5jZXMiOjEwLCJtcXR0Q2xpZW50cyI6NiwidGllciI6ImVudGVycHJpc2UiLCJkZXYiOnRydWUsImlhdCI6MTczMDcyMTEyNH0.02KMRf5kogkpH3HXHVSGprUm0QQFLn21-3QIORhxFgRE9N5DIE8YnTH_f8W_21T6TlYbDUmf4PtWyj120HTM2w', ai: { enabled: false } }) - - TestObjects.alicePAT = await app.db.controllers.AccessToken.createPersonalAccessToken( - app.user, - '', - null, - 'alice-pat' - ) }) after(async function () { @@ -378,17 +132,5 @@ describe('MCP Platform Tools Server', function () { it('should not register the expertPlatformAutomation feature flag when AI is disabled', async function () { should(app.config.features.enabled('expertPlatformAutomation')).not.equal(true) }) - - it('should return 404 for POST /api/v1/mcp when feature is disabled', async function () { - const response = await app.inject({ - method: 'POST', - url: '/api/v1/mcp', - headers: { - authorization: `Bearer ${TestObjects.alicePAT.token}` - }, - payload: { jsonrpc: '2.0', method: 'initialize', id: 1 } - }) - response.statusCode.should.equal(404) - }) }) }) diff --git a/test/unit/forge/routes/auth/permissions_spec.js b/test/unit/forge/routes/auth/permissions_spec.js index 6688c0eff4..a15ff14989 100644 --- a/test/unit/forge/routes/auth/permissions_spec.js +++ b/test/unit/forge/routes/auth/permissions_spec.js @@ -56,6 +56,20 @@ describe('Permissions API', async () => { } } + // Dedicated platform-automation token: ownerType 'user:expert-mcp' + ff-expert:platform scope + const EXPERT_PLATFORM_TOKEN_TEAM_MEMBER = { + session: { User: { id: 'u123' }, ownerType: 'user:expert-mcp', scope: ['ff-expert:platform'] }, + teamMembership: { role: Roles.Member } + } + const EXPERT_PLATFORM_TOKEN_TEAM_OWNER = { + session: { User: { id: 'u123' }, ownerType: 'user:expert-mcp', scope: ['ff-expert:platform'] }, + teamMembership: { role: Roles.Owner } + } + // A plain user token must not gain broad access by carrying the platform scope + const USER_TOKEN_EXPERT_PLATFORM_SCOPE_TEAM_MEMBER = { + session: { User: { id: 'u123' }, ownerType: 'user', scope: ['ff-expert:platform'] }, + teamMembership: { role: Roles.Member } + } let rbacApplicationEnabled = false @@ -223,6 +237,19 @@ describe('Permissions API', async () => { expectFail(await sendRequest('team:delete', PROJECT_TOKEN_NO_SCOPE)) }) }) + + describe('expert platform token', () => { + it('Allows a role-permitted route despite the scope not listing it', async () => { + expectPass(await sendRequest('team:read', EXPERT_PLATFORM_TOKEN_TEAM_MEMBER)) + }) + it('Stays bounded by the user role', async () => { + expectFail(await sendRequest('team:edit', EXPERT_PLATFORM_TOKEN_TEAM_MEMBER)) + expectPass(await sendRequest('team:edit', EXPERT_PLATFORM_TOKEN_TEAM_OWNER)) + }) + it('Does not grant broad access to a plain user token carrying the scope', async () => { + expectFail(await sendRequest('team:read', USER_TOKEN_EXPERT_PLATFORM_SCOPE_TEAM_MEMBER)) + }) + }) }) }) }) diff --git a/test/unit/frontend/services/app.orchestrator.spec.js b/test/unit/frontend/services/app.orchestrator.spec.js index 256f1c9684..26ea53e8f8 100644 --- a/test/unit/frontend/services/app.orchestrator.spec.js +++ b/test/unit/frontend/services/app.orchestrator.spec.js @@ -1,11 +1,18 @@ import { beforeEach, describe, expect, test, vi } from 'vitest' +const mockCreateAutomationsService = vi.fn() const mockCreateBootstrapService = vi.fn() const mockCreateMessagingService = vi.fn() const mockCreateMqttService = vi.fn() const mockCreateTeamChannelSubscriber = vi.fn() const mockCreateMqttTransport = vi.fn() +vi.mock('../../../../frontend/src/services/automations.service.js', () => { + return { + createAutomationsService: mockCreateAutomationsService + } +}) + vi.mock('../../../../frontend/src/services/bootstrap.service.js', () => { return { createBootstrapService: mockCreateBootstrapService @@ -42,23 +49,26 @@ async function loadOrchestratorModule () { } function seedServices () { + const automationsService = { name: 'automations' } const bootstrapService = { name: 'bootstrap', init: vi.fn(), destroy: vi.fn().mockResolvedValue() } const postMessageService = { name: 'postMessage', destroy: vi.fn().mockResolvedValue() } const mqttService = { name: 'mqtt', destroy: vi.fn().mockResolvedValue() } const teamChannelSubscriber = { name: 'teamChannel', destroy: vi.fn().mockResolvedValue() } const transport = { name: 'mqtt-transport' } + mockCreateAutomationsService.mockReturnValue(automationsService) mockCreateBootstrapService.mockReturnValue(bootstrapService) mockCreateMessagingService.mockReturnValue(postMessageService) mockCreateMqttService.mockReturnValue(mqttService) mockCreateMqttTransport.mockReturnValue(transport) mockCreateTeamChannelSubscriber.mockReturnValue(teamChannelSubscriber) - return { bootstrapService, postMessageService, mqttService, teamChannelSubscriber, transport } + return { automationsService, bootstrapService, postMessageService, mqttService, teamChannelSubscriber, transport } } describe('AppOrchestrator', () => { beforeEach(() => { + mockCreateAutomationsService.mockReset() mockCreateBootstrapService.mockReset() mockCreateMessagingService.mockReset() mockCreateMqttService.mockReset() @@ -129,7 +139,8 @@ describe('AppOrchestrator', () => { expect(orchestrator.$serviceInstances).toEqual({ bootstrap: null, postMessage: null, - mqtt: null + mqtt: null, + automations: null }) expect(orchestrator.$subscriberInstances).toEqual({ teamChannel: null }) expect(orchestrator.$app).toBeNull()