diff --git a/forge/comms/platformAutomation.js b/forge/comms/platformAutomation.js index 6c5259de85..a2e32e6e5d 100644 --- a/forge/comms/platformAutomation.js +++ b/forge/comms/platformAutomation.js @@ -33,8 +33,9 @@ class PlatformAutomationHandler { if (!this._fullToolDefinitions) { const { loadToolDefinitions } = require('../ee/lib/mcp/toolLoader') this._fullToolDefinitions = loadToolDefinitions() - this._wireToolDefinitions = this._fullToolDefinitions.map(({ name, description, inputSchema, annotations }) => ({ + this._wireToolDefinitions = this._fullToolDefinitions.map(({ name, title, description, inputSchema, annotations }) => ({ name, + title, description, inputSchema: inputSchema && z.toJSONSchema(z.object(inputSchema)), annotations diff --git a/forge/ee/lib/mcp/toolLoader.js b/forge/ee/lib/mcp/toolLoader.js index 4c0ce65ca4..15a41035b1 100644 --- a/forge/ee/lib/mcp/toolLoader.js +++ b/forge/ee/lib/mcp/toolLoader.js @@ -6,7 +6,7 @@ const toolsDir = path.join(__dirname, 'tools') /** * Loads all tool definition files from the tools/ directory. * Each file should export an array of tool definitions with: - * { name, description, inputSchema, annotations, handler } + * { name, title, description, inputSchema, annotations, handler } * * Definitions are loaded once at startup and reused across requests. */ @@ -35,6 +35,7 @@ function registerTools (server, toolDefinitions, inject, checkScope, options = { const { comms } = options for (const tool of toolDefinitions) { const config = { + title: tool.title, description: tool.description, annotations: tool.annotations } diff --git a/forge/ee/lib/mcp/tools/applications.js b/forge/ee/lib/mcp/tools/applications.js index 9f574f6c27..5d3be73a6b 100644 --- a/forge/ee/lib/mcp/tools/applications.js +++ b/forge/ee/lib/mcp/tools/applications.js @@ -3,6 +3,7 @@ const { z } = require('zod') module.exports = [ { name: 'platform_list_applications', + title: '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. @@ -18,6 +19,7 @@ module.exports = [ }, { name: 'platform_get_application', + title: '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: { @@ -30,6 +32,7 @@ module.exports = [ }, { name: 'platform_get_application_hosted_instances', + title: '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. @@ -47,6 +50,7 @@ module.exports = [ }, { name: 'platform_get_application_remote_instances', + title: '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. @@ -81,6 +85,7 @@ module.exports = [ }, { name: 'platform_get_application_instances_status', + title: '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. @@ -97,6 +102,7 @@ module.exports = [ }, { name: 'platform_get_application_audit_log', + title: '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. @@ -138,6 +144,7 @@ module.exports = [ }, { name: 'platform_create_application', + title: '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. diff --git a/forge/ee/lib/mcp/tools/devices.js b/forge/ee/lib/mcp/tools/devices.js index 6b9e5f14d7..6dc0e20d27 100644 --- a/forge/ee/lib/mcp/tools/devices.js +++ b/forge/ee/lib/mcp/tools/devices.js @@ -3,6 +3,7 @@ const { z } = require('zod') module.exports = [ { name: 'platform_list_team_remote_instances', + title: '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. @@ -38,6 +39,7 @@ module.exports = [ }, { name: 'platform_get_remote_instance', + title: '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. @@ -56,6 +58,7 @@ module.exports = [ }, { name: 'platform_get_remote_instance_status', + title: '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.), @@ -87,6 +90,7 @@ module.exports = [ }, { name: 'platform_create_remote_instance', + title: '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. @@ -108,6 +112,7 @@ module.exports = [ }, { name: 'platform_assign_remote_instance_to_application', + title: '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. diff --git a/forge/ee/lib/mcp/tools/instances.js b/forge/ee/lib/mcp/tools/instances.js index 9f15305411..26e498ec89 100644 --- a/forge/ee/lib/mcp/tools/instances.js +++ b/forge/ee/lib/mcp/tools/instances.js @@ -3,6 +3,7 @@ const { z } = require('zod') module.exports = [ { name: 'platform_get_hosted_instance', + title: '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. @@ -21,6 +22,7 @@ module.exports = [ }, { name: 'platform_get_hosted_instance_status', + title: '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, @@ -37,6 +39,7 @@ module.exports = [ }, { name: 'platform_get_hosted_instance_logs', + title: '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. @@ -66,6 +69,7 @@ module.exports = [ }, { name: 'platform_check_hosted_instance_name_availability', + title: '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. @@ -81,6 +85,7 @@ module.exports = [ }, { name: 'platform_create_hosted_instance', + title: '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: diff --git a/forge/ee/lib/mcp/tools/navigation.js b/forge/ee/lib/mcp/tools/navigation.js index 0c5affcee8..6fd7e9f086 100644 --- a/forge/ee/lib/mcp/tools/navigation.js +++ b/forge/ee/lib/mcp/tools/navigation.js @@ -3,6 +3,7 @@ const { z } = require('zod') module.exports = [ { name: 'platform_open_hosted_instance_editor', + title: '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: { @@ -22,6 +23,7 @@ module.exports = [ }, { name: 'platform_open_hosted_instance', + title: '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: { diff --git a/forge/ee/lib/mcp/tools/platform.js b/forge/ee/lib/mcp/tools/platform.js index 8353a3e71f..b5d2e20fe7 100644 --- a/forge/ee/lib/mcp/tools/platform.js +++ b/forge/ee/lib/mcp/tools/platform.js @@ -1,6 +1,7 @@ module.exports = [ { name: 'platform_list_hosted_instance_types', + title: '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: {}, @@ -11,6 +12,7 @@ module.exports = [ }, { name: 'platform_list_stacks', + title: '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: {}, @@ -21,6 +23,7 @@ module.exports = [ }, { name: 'platform_list_templates', + title: '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: {}, @@ -31,6 +34,7 @@ module.exports = [ }, { name: 'platform_list_blueprints', + title: '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: {}, diff --git a/forge/ee/lib/mcp/tools/snapshots.js b/forge/ee/lib/mcp/tools/snapshots.js index 4c46a73488..6743ac8698 100644 --- a/forge/ee/lib/mcp/tools/snapshots.js +++ b/forge/ee/lib/mcp/tools/snapshots.js @@ -3,6 +3,7 @@ const { z } = require('zod') module.exports = [ { name: 'platform_list_hosted_instance_snapshots', + title: '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. @@ -31,6 +32,7 @@ module.exports = [ }, { name: 'platform_create_hosted_instance_snapshot', + title: '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. @@ -55,6 +57,7 @@ module.exports = [ }, { name: 'platform_list_remote_instance_snapshots', + title: '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. @@ -83,6 +86,7 @@ module.exports = [ }, { name: 'platform_create_remote_instance_snapshot', + title: '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. diff --git a/forge/ee/lib/mcp/tools/teams.js b/forge/ee/lib/mcp/tools/teams.js index cec5047e07..ecc053113b 100644 --- a/forge/ee/lib/mcp/tools/teams.js +++ b/forge/ee/lib/mcp/tools/teams.js @@ -3,6 +3,7 @@ const { z } = require('zod') module.exports = [ { name: 'platform_list_teams', + title: '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: {}, @@ -13,6 +14,7 @@ module.exports = [ }, { name: 'platform_get_team', + title: '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: { diff --git a/forge/ee/routes/expert/index.js b/forge/ee/routes/expert/index.js index b83e81ad76..094531bc73 100644 --- a/forge/ee/routes/expert/index.js +++ b/forge/ee/routes/expert/index.js @@ -14,6 +14,31 @@ const { filterAccessibleMCPServerFeatures } = require('../../../services/expert. /** @type {typeof import('../../../comms/devices.js').DeviceCommsHandler} */ const getDeviceComms = (app) => { return app.comms?.devices } +/** + * Maps a platform automation tool's wire definition into a catalog entry for the + * Expert permissions UI (#421). Platform tools carry standard MCP annotations + * (readOnlyHint / destructiveHint), which give the read/write/delete class. They + * run on the platform, not in Node-RED, so they have no nr-assistant version window + * (no minVersion/maxVersion — the UI treats their absence as always-available). + * `group: 'platform'` routes them to the FlowFuse Platform Tools section (groupOf() + * in the product-assistant store). The friendly label is the tool's own `title`; if a + * tool ever lacks one, fall back to deriving it from the name (strip the platform_ + * prefix and title-case the rest). + */ +const curatePlatformTool = (def) => { + const annotations = def.annotations || {} + const readOnly = annotations.readOnlyHint === true + const destructive = annotations.destructiveHint === true + return { + key: def.name, + name: def.title || def.name.replace(/^platform_/, '').replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()), + description: def.description, + toolClass: readOnly ? 'read' : (destructive ? 'delete' : 'write'), + destructive, + group: 'platform' + } +} + /** * @param {import('../../forge.js').ForgeApplication} app */ @@ -46,8 +71,10 @@ module.exports = async function (app) { error: 'unauthorized' }) } - // Ensure users team access is valid - const teamId = request.body.context?.teamId // `context.teamId` is the hash provided in the body context by the client + // Ensure users team access is valid. `teamId` is the team hash provided by the + // client — in the body context for POST routes (/chat, /mcp/features) or as a + // query param for GET routes (/mcp/tools, which has no body). + const teamId = request.body?.context?.teamId || request.query?.teamId if (!teamId) { return reply.status(404).send({ code: 'not_found', error: 'Not Found' }) } @@ -557,6 +584,91 @@ module.exports = async function (app) { reply.code(error.response?.status || 500).send({ code: error.response?.data?.code || 'unexpected_error', error: error.response?.data?.error || error.message }) } }) + + /** + * Retrieve the curated tool catalog for the Expert's human-in-the-loop permissions UI + * (#421). Returns the merged catalog for both sections the UI shows: + * - flow-building tools, proxied from the agent service's /mcp/flow-tools endpoint + * (friendly catalog entries only — raw MCP identifiers never leave the backend); + * - FlowFuse platform tools, curated here from the platform automation handler + * (app.comms.platformAutomation) and tagged group:'platform'. + * A `hash` fingerprint of the flow-building catalog rides along so the browser refetches + * only when it changes. Team access + feature gating are enforced by the shared + * preHandler above; read/write classification on each entry is what the client uses to + * decide which tools a role may enable. + */ + app.get('/mcp/tools', { + schema: { + hide: true, // dont show in swagger + querystring: { + type: 'object', + properties: { + teamId: { type: 'string', minLength: 10 } + }, + required: ['teamId'] + }, + response: { + 200: { + type: 'object', + properties: { + catalog: { + type: 'array', + items: { + type: 'object', + additionalProperties: true + } + }, + hash: { + type: ['string', 'null'] + } + } + }, + '4xx': { + $ref: 'APIError' + } + } + } + }, + async (request, reply) => { + if (!request.isExpertAssistantEnabled) { + return reply.status(404).send({ code: 'not_found', error: 'Not Found' }) + } + try { + const toolsUrl = `${app.expert.expertUrl.split('/').slice(0, -1).join('/')}/mcp/flow-tools` + const response = await axios.get(toolsUrl, { + headers: { + Origin: request.headers.origin, + ...(app.expert.serviceToken ? { Authorization: `Bearer ${app.expert.serviceToken}` } : {}) + }, + timeout: app.expert.requestTimeout + }) + const catalog = response.data?.catalog || [] + + // Merge in the FlowFuse platform tools. They are global (no per-team filtering) + // and served from the handler singleton already constructed on app.comms, so we + // reuse it rather than newing one up — constructing re-registers its MQTT event + // listener. getToolDefinitions() is synchronous and takes no args. + const platformHandler = app.comms?.platformAutomation + if (platformHandler) { + const platformDefs = platformHandler.getToolDefinitions() || [] + catalog.push(...platformDefs.map(curatePlatformTool)) + } + + reply.send({ catalog, hash: response.data?.hash || null }) + } catch (error) { + // TODO: decide with the team whether this belongs on the branch. The tool catalog + // is a non-fatal enhancement (the client swallows failures and gates safely with + // defaults). Never forward an upstream auth failure as our own 401 — the SPA's + // axios interceptor treats any 401 as session-expiry and logs the user out, which + // an unrelated expert-service token rejection must not trigger. + const upstreamStatus = error.response?.status + app.log.warn(`[expert/mcp/tools] upstream tool-catalog fetch failed: status=${upstreamStatus} msg=${error.message}`) + if (upstreamStatus === 401 || upstreamStatus === 403) { + return reply.send({ catalog: [], hash: null }) + } + reply.code(upstreamStatus || 500).send({ code: error.response?.data?.code || 'unexpected_error', error: error.response?.data?.error || error.message }) + } + }) } /** diff --git a/frontend/src/api/expert.js b/frontend/src/api/expert.js index 67ef3ce793..c9a2030180 100644 --- a/frontend/src/api/expert.js +++ b/frontend/src/api/expert.js @@ -48,7 +48,19 @@ const getCapabilities = async (payload) => { }) } +/** + * Fetch the curated tool catalog for the tool-permissions UI (#421). + * @param {{ teamId: string }} params + * @returns {Promise<{ catalog: Array, hash: string|null }>} + */ +const getToolCatalog = async ({ teamId } = {}) => { + return client.get('/api/v1/expert/mcp/tools', { + params: { teamId } + }).then(res => res.data) +} + export default { chat, - getCapabilities + getCapabilities, + getToolCatalog } diff --git a/frontend/src/components/elements/ToggleButtonGroup.vue b/frontend/src/components/elements/ToggleButtonGroup.vue index 1fcc441ae7..ed061f593d 100644 --- a/frontend/src/components/elements/ToggleButtonGroup.vue +++ b/frontend/src/components/elements/ToggleButtonGroup.vue @@ -18,7 +18,7 @@