diff --git a/forge/comms/aclManager.js b/forge/comms/aclManager.js index 85a49a3a4a..153e0e3598 100644 --- a/forge/comms/aclManager.js +++ b/forge/comms/aclManager.js @@ -8,10 +8,13 @@ */ module.exports = function (app) { const expertRbacToolCheck = async (teamMembership, application, toolName) => { - const applicationHash = typeof application === 'object' ? application.hashid : application + const applicationHash = (application && typeof application === 'object') ? application.hashid : application if (toolName === 'expert:status-message') { return true } + if (toolName.startsWith('platform:')) { + return app.hasPermission(teamMembership, 'expert:insights:mcp:tool:allow') + } // TODO: Understand all automations and which permissions they should require. // For now, basic starter automations are added here, any not matching this list will require project:flows:edit permission const toolAccessPermission = { @@ -230,14 +233,15 @@ module.exports = function (app) { throw ValidationError('user is not a member of the team that owns this project') } - // check expert assistant feature is enabled for the team (support agent uses MQTT) + // check expert feature is enabled for the team (support agent uses MQTT) if (acl.isClient) { const team = await app.db.models.Team.byId(teamId) if (team) { await team.ensureTeamTypeExists() const isAiEnabled = !!(app.config.features.enabled('ai') && team.getFeatureProperty('ai', true)) const isExpertAssistantEnabled = !!(app.config.features.enabled('expertAssistant') && team.getFeatureProperty('expertAssistant', true)) - if (!isAiEnabled || !isExpertAssistantEnabled) { + const isExpertPlatformAutomationEnabled = !!(app.config.features.enabled('expertPlatformAutomation') && team.getFeatureProperty('expertPlatformAutomation', true)) + if (!isAiEnabled || (!isExpertAssistantEnabled && !isExpertPlatformAutomationEnabled)) { throw ValidationError('expert assistant feature is not enabled for this team') } } @@ -285,10 +289,15 @@ module.exports = function (app) { { topic: /^ff\/v1\/[^/]+\/d\/[^/]+\/logs\/heartbeat$/ }, // ff/v1//d//resources/heartbeat { topic: /^ff\/v1\/[^/]+\/d\/[^/]+\/resources\/heartbeat$/ }, + // ff/v1//p//editor/heartbeat + { topic: /^ff\/v1\/[^/]+\/p\/[^/]+\/editor\/heartbeat$/ }, // ff/v1/platform/sync { topic: /^ff\/v1\/platform\/sync$/ }, // ff/v1/platform/leader - { topic: /^ff\/v1\/platform\/leader$/ } + { topic: /^ff\/v1\/platform\/leader$/ }, + // Receive MCP dispatch inflight responses from the user's browser + // - ff/v1/mcp/////support/inflight//response + { topic: /^ff\/v1\/mcp\/[^/]+\/[^/]+\/[^/]+\/[^/]+\/support\/inflight\/[^/]+\/response$/ } ], pub: [ // Send commands to project launchers @@ -306,7 +315,10 @@ module.exports = function (app) { // ff/v1/platform/sync { topic: /^ff\/v1\/platform\/sync$/ }, // ff/v1/platform/leader - { topic: /^ff\/v1\/platform\/leader$/ } + { topic: /^ff\/v1\/platform\/leader$/ }, + // Send MCP dispatch inflight requests to the user's browser + // - ff/v1/mcp/////support/inflight//request + { topic: /^ff\/v1\/mcp\/[^/]+\/[^/]+\/[^/]+\/[^/]+\/support\/inflight\/[^/]+\/request$/ } ] }, project: { @@ -357,7 +369,9 @@ module.exports = function (app) { // - ff/v1//d//d//p//editor/heartbeat + { topic: /^ff\/v1\/([^/]+)\/p\/([^/]+)\/editor\/heartbeat$/, verify: 'checkTeamId' } ] }, // frontend client (user) @@ -367,14 +381,20 @@ module.exports = function (app) { // topic captures, 0 = full topic, 1 = userid, 2 = sessionid, 3 = entity type (a|p|d|t), 4 = entity id, 5 = inflight type (only for inflight topics) // example topic: ff/v1/expert/user123/session123/p/abc-123-456-789/support/chat/response { topic: /^ff\/v1\/expert\/([^/]+)\/([^/]+)\/([^/]+)\/([^/]+)\/support\/chat\/response$/, verify: 'checkExpertTopic', channel: 'chat', allowWildcard: { entity: true }, isClient: true, isSub: true }, - { topic: /^ff\/v1\/expert\/([^/]+)\/([^/]+)\/([^/]+)\/([^/]+)\/support\/inflight\/([^/]+)\/request$/, verify: 'checkExpertTopic', channel: 'inflight', allowWildcard: { entity: true, inflightType: true }, isClient: true, isSub: true } + { topic: /^ff\/v1\/expert\/([^/]+)\/([^/]+)\/([^/]+)\/([^/]+)\/support\/inflight\/([^/]+)\/request$/, verify: 'checkExpertTopic', channel: 'inflight', allowWildcard: { entity: true, inflightType: true }, isClient: true, isSub: true }, + // MCP dispatch inflight requests (from forge platform to user's browser) + { topic: /^ff\/v1\/mcp\/([^/]+)\/([^/]+)\/([^/]+)\/([^/]+)\/support\/inflight\/([^/]+)\/request$/, verify: 'checkExpertTopic', channel: 'inflight', allowWildcard: { entity: true, inflightType: true }, isClient: true, isSub: true } ], pub: [ // topic: ff/v1/expert/////support/chat/request // topic captures, 0 = full topic, 1 = userid, 2 = sessionid, 3 = entity type (a|p|d|t), 4 = entity id, 5 = inflight type (only for inflight topics) // example topic: ff/v1/expert/user123/session123/p/abc-111-222-333/support/chat/request { topic: /^ff\/v1\/expert\/([^/]+)\/([^/]+)\/([tapd])\/([^/]+)\/support\/chat\/request$/, verify: 'checkExpertTopic', channel: 'chat', isClient: true, isPub: true }, - { topic: /^ff\/v1\/expert\/([^/]+)\/([^/]+)\/([tapd])\/([^/]+)\/support\/inflight\/([^/]+)\/response$/, verify: 'checkExpertTopic', channel: 'inflight', isClient: true, isPub: true } + { topic: /^ff\/v1\/expert\/([^/]+)\/([^/]+)\/([tapd])\/([^/]+)\/support\/inflight\/([^/]+)\/response$/, verify: 'checkExpertTopic', channel: 'inflight', isClient: true, isPub: true }, + // MCP dispatch inflight responses (from user's browser back to forge platform) + { topic: /^ff\/v1\/mcp\/([^/]+)\/([^/]+)\/([tapd])\/([^/]+)\/support\/inflight\/([^/]+)\/response$/, verify: 'checkExpertTopic', channel: 'inflight', isClient: true, isPub: true }, + // - ff/v1//p//editor/heartbeat + { topic: /^ff\/v1\/[^/]+\/p\/[^/]+\/editor\/heartbeat$/ } ] }, // backend client (agent) diff --git a/forge/comms/commsClient.js b/forge/comms/commsClient.js index a5922cc24a..ac8c87f1fd 100644 --- a/forge/comms/commsClient.js +++ b/forge/comms/commsClient.js @@ -51,10 +51,33 @@ class CommsClient extends EventEmitter { const ownerId = topicParts[4] const messageType = topicParts[5] if (ownerType === 'p') { - this.emit('status/project', { - id: ownerId, - status: message.toString() - }) + if (messageType === 'editor' && topicParts[6] === 'heartbeat') { + try { + const payload = JSON.parse(message.toString()) + const teamId = topicParts[2] + if (payload.action === 'alive') { + this.emit('editor/heartbeat', { + projectId: ownerId, + teamId, + sessionId: payload.sessionId, + userId: payload.userId, + timestamp: Date.now() + }) + } else if (payload.action === 'leaving') { + this.emit('editor/leaving', { + projectId: ownerId, + teamId + }) + } + } catch (_) { + // ignore malformed payloads + } + } else { + this.emit('status/project', { + id: ownerId, + status: message.toString() + }) + } } else if (ownerType === 'd') { if (messageType === 'status') { this.emit('status/device', { @@ -123,6 +146,8 @@ class CommsClient extends EventEmitter { 'ff/v1/+/d/+/logs/heartbeat', // Device response heartbeat 'ff/v1/+/d/+/resources/heartbeat', + // Editor session heartbeat + 'ff/v1/+/p/+/editor/heartbeat', // Platform sync messages 'ff/v1/platform/sync' ]) diff --git a/forge/comms/editorSessions.js b/forge/comms/editorSessions.js new file mode 100644 index 0000000000..585a9ba65f --- /dev/null +++ b/forge/comms/editorSessions.js @@ -0,0 +1,77 @@ +/** + * This module tracks which browser sessions have the NR editor open + * for which instances. It follows the same heartbeat pattern as device + * log heartbeats in devices.js. + * + * Heartbeats arrive via MQTT on: ff/v1//p//editor/heartbeat + * Payload: JSON { sessionId, userId, action: 'alive'|'leaving' } + */ + +class EditorSessionHandler { + // Browser background tabs throttle timers to ~60s; allow 2 missed + // intervals before considering a session stale. + static STALE_THRESHOLD_MS = 120_000 + + /** + * @param {import('../forge').ForgeApplication} app Fastify app + * @param {import('./commsClient').CommsClient} client Comms Client + */ + constructor (app, client) { + this.app = app + this.client = client + + // editorSessions[projectId] = { sessionId, userId, teamId, lastHeartbeat } + this.editorSessions = {} + this.sweepInterval = -1 + + // Listen for editor heartbeat events from the comms client + client.on('editor/heartbeat', (beat) => { + this.editorSessions[beat.projectId] = { + sessionId: beat.sessionId, + userId: beat.userId, + teamId: beat.teamId, + lastHeartbeat: beat.timestamp + } + }) + + client.on('editor/leaving', (beat) => { + delete this.editorSessions[beat.projectId] + }) + + // Sweep stale entries every 30 seconds + this.sweepInterval = setInterval(() => { + const now = Date.now() + for (const [projectId, session] of Object.entries(this.editorSessions)) { + if (now - session.lastHeartbeat > EditorSessionHandler.STALE_THRESHOLD_MS) { + delete this.editorSessions[projectId] + } + } + }, 30_000) + } + + /** + * Get the active editor session for a project, if any. + * @param {string} projectId - The project/instance ID + * @returns {object|null} Session info { sessionId, userId, teamId, lastHeartbeat } or null + */ + getActiveSession (projectId) { + const session = this.editorSessions[projectId] + if (!session) return null + if (Date.now() - session.lastHeartbeat > EditorSessionHandler.STALE_THRESHOLD_MS) { + delete this.editorSessions[projectId] + return null + } + return session + } + + /** + * Stop the sweep interval (for clean shutdown) + */ + stop () { + clearInterval(this.sweepInterval) + } +} + +module.exports = { + EditorSessionHandler: (app, client) => new EditorSessionHandler(app, client) +} diff --git a/forge/comms/index.js b/forge/comms/index.js index 1bb6a6fdac..cb39c48cae 100644 --- a/forge/comms/index.js +++ b/forge/comms/index.js @@ -3,6 +3,7 @@ const fp = require('fastify-plugin') const ACLManager = require('./aclManager') const { CommsClient } = require('./commsClient') const { DeviceCommsHandler } = require('./devices') +const { EditorSessionHandler } = require('./editorSessions') /** * This module represents the real-time comms component of the platform. @@ -31,6 +32,9 @@ module.exports = fp(async function (app, _opts) { // Create the handler for any device-related messages const deviceCommsHandler = DeviceCommsHandler(app, client) + // Create the handler for editor session heartbeats + const editorSessionHandler = EditorSessionHandler(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` // client.on('status/project', (status) => { @@ -40,6 +44,7 @@ module.exports = fp(async function (app, _opts) { // Setup the platform API for the comms component app.decorate('comms', { devices: deviceCommsHandler, + editorSessions: editorSessionHandler, aclManager: ACLManager(app), platform: { settings: { @@ -73,6 +78,7 @@ module.exports = fp(async function (app, _opts) { app.addHook('onClose', async (_) => { app.log.info('Comms shutdown') await deviceCommsHandler.stopLogWatcher() + editorSessionHandler.stop() client.publish('ff/v1/platform/leader', JSON.stringify({ id: client.platformId, vote: -1 })) await client.disconnect() }) diff --git a/forge/db/controllers/AccessToken.js b/forge/db/controllers/AccessToken.js index 8141845e7c..6cf5fbf9ec 100644 --- a/forge/db/controllers/AccessToken.js +++ b/forge/db/controllers/AccessToken.js @@ -281,6 +281,37 @@ module.exports = { result.token = token return result }, + createMCPToken: async function (app, user, scope, expiresAt, name) { + const userId = typeof user === 'number' ? user : user.id + const token = generateToken(32, 'ffmcp') + const tok = await app.db.models.AccessToken.create({ + name, + token, + scope, + expiresAt, + ownerId: '' + userId, + ownerType: 'user' + }) + const result = app.db.views.AccessToken.mcpTokenSummary(tok) + result.token = token + return result + }, + updateMCPToken: async function (app, user, tokenId, scope, expiresAt) { + const userId = typeof user === 'number' ? user : user.id + const token = await app.db.models.AccessToken.byId(tokenId, 'user', userId) + if (token) { + token.scope = scope + if (expiresAt === undefined) { + token.expiresAt = null + } else { + token.expiresAt = expiresAt + } + await token.save() + } else { + throw new Error('Not Found') + } + return token + }, updatePersonalAccessToken: async function (app, user, tokenId, scope, expiresAt) { const userId = typeof user === 'number' ? user : user.id const token = await app.db.models.AccessToken.byId(tokenId, 'user', userId) diff --git a/forge/db/models/AccessToken.js b/forge/db/models/AccessToken.js index 5d4aea7f4a..ffddc4a1a0 100644 --- a/forge/db/models/AccessToken.js +++ b/forge/db/models/AccessToken.js @@ -116,6 +116,18 @@ module.exports = { }) return tokens }, + getMCPTokens: async (user) => { + const tokens = this.findAll({ + where: { + ownerType: 'user', + ownerId: '' + user.id, + scope: { [Op.like]: '%mcp:platform%' } + }, + order: [['id', 'ASC']], + attributes: ['id', 'name', 'scope', 'expiresAt'] + }) + return tokens + }, getProjectHTTPTokens: async (project) => { const tokens = this.findAll({ where: { diff --git a/forge/db/views/AccessToken.js b/forge/db/views/AccessToken.js index d0da1c3853..3d0b1bd415 100644 --- a/forge/db/views/AccessToken.js +++ b/forge/db/views/AccessToken.js @@ -96,6 +96,46 @@ module.exports = function (app) { return tokenArray.map(token => personalAccessTokenSummary(token)) } + app.addSchema({ + $id: 'MCPTokenSummary', + type: 'object', + // Composed via `allOf` elsewhere — keep open. + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + expiresAt: { type: 'string', nullable: true } + }, + required: ['id', 'name', 'expiresAt'] + }) + app.addSchema({ + $id: 'MCPToken', + type: 'object', + allOf: [{ $ref: 'MCPTokenSummary' }], + properties: { + token: { type: 'string' } + }, + required: ['token'] + }) + + function mcpTokenSummary (token) { + const tokenSummary = { + id: token.hashid, + name: token.name, + expiresAt: token.expiresAt ?? null + } + return tokenSummary + } + app.addSchema({ + $id: 'MCPTokenSummaryList', + type: 'array', + items: { + $ref: 'MCPTokenSummary' + } + }) + function mcpTokenSummaryList (tokenArray) { + return tokenArray.map(token => mcpTokenSummary(token)) + } + app.addSchema({ $id: 'InstanceHTTPTokenSummary', type: 'object', @@ -141,6 +181,8 @@ module.exports = function (app) { provisioningTokenSummary, personalAccessTokenSummary, personalAccessTokenSummaryList, + mcpTokenSummary, + mcpTokenSummaryList, instanceHTTPTokenSummary, instanceHTTPTokenSummaryList } diff --git a/forge/ee/lib/index.js b/forge/ee/lib/index.js index 8b979786d9..fba4a2b6d8 100644 --- a/forge/ee/lib/index.js +++ b/forge/ee/lib/index.js @@ -52,6 +52,9 @@ module.exports = fp(async function (app, opts) { // Set the expert assistant Feature Flag app.config.features.register('expertAssistant', isAiEnabled && (app.config?.expert?.enabled ?? false), true) + // Set the expert platform automation Feature Flag (platform tools like create-instance, list-teams, etc.) + app.config.features.register('expertPlatformAutomation', isAiEnabled && (app.config?.expert?.enabled ?? false), true) + // temporary until FF Expert Insights can be enabled on Self Hosted EE instance const isInsightsEnabled = isAiEnabled && app.config?.expert?.enabled && app.config?.expert?.insights?.enabled app.config.features.register('expertInsights', isInsightsEnabled ?? false, false) diff --git a/forge/ee/lib/mcp/flowBuildingBridge.js b/forge/ee/lib/mcp/flowBuildingBridge.js new file mode 100644 index 0000000000..3e686c70f6 --- /dev/null +++ b/forge/ee/lib/mcp/flowBuildingBridge.js @@ -0,0 +1,162 @@ +'use strict' + +const { Client } = require('@modelcontextprotocol/sdk/client/index.js') +const { StreamableHTTPClientTransport } = require('@modelcontextprotocol/sdk/client/streamableHttp.js') +const { z } = require('zod') + +/** + * Convert a JSON Schema property to a Zod schema. + * Handles the common types returned by the flow building MCP server. + */ +function tryParseJSON (val) { + if (typeof val !== 'string') return val + try { return JSON.parse(val) } catch (_) { return val } +} + +function jsonSchemaPropertyToZod (prop) { + if (!prop || typeof prop !== 'object') return z.any() + + switch (prop.type) { + case 'string': + if (prop.enum) return z.enum(prop.enum) + return z.string() + case 'number': + case 'integer': + return z.number() + case 'boolean': + return z.boolean() + case 'array': + // Preprocess: MCP clients may serialize arrays as JSON strings + return z.preprocess(tryParseJSON, + z.array(prop.items ? jsonSchemaPropertyToZod(prop.items) : z.any())) + case 'object': + if (prop.properties) { + return z.preprocess(tryParseJSON, jsonSchemaToZod(prop)) + } + return z.preprocess(tryParseJSON, z.record(z.any())) + default: + return z.any() + } +} + +/** + * Convert a JSON Schema object to a Zod object schema. + */ +function jsonSchemaToZod (schema) { + if (!schema || schema.type !== 'object') return z.object({}).passthrough() + + const shape = {} + const props = schema.properties || {} + const required = schema.required || [] + + for (const [key, prop] of Object.entries(props)) { + let field = jsonSchemaPropertyToZod(prop) + if (prop.description) field = field.describe(prop.description) + if (!required.includes(key)) field = field.optional() + shape[key] = field + } + + return schema.additionalProperties === false + ? z.object(shape) + : z.object(shape).passthrough() +} + +class FlowBuildingBridge { + constructor (app) { + this.app = app + this.cachedTools = null + this.cacheExpiry = 0 + this.CACHE_TTL = 5 * 60 * 1000 // 5 minutes + } + + get mcpUrl () { + return this.app.config.expert?.flowBuildingMcpUrl + } + + get isConfigured () { + return !!this.mcpUrl + } + + /** + * Get the list of flow building tools from the MCP server. + * Caches results for 5 minutes. + * @returns {Promise} Array of tool definitions with `flow.` prefix + */ + async getTools () { + if (this.cachedTools && Date.now() < this.cacheExpiry) { + return this.cachedTools + } + if (!this.isConfigured) return [] + + try { + const tools = await this._fetchTools() + this.cachedTools = tools + this.cacheExpiry = Date.now() + this.CACHE_TTL + return tools + } catch (err) { + this.app.log.warn(`Failed to fetch flow building tools: ${err.message}`) + // Return stale cache if available, otherwise empty + return this.cachedTools || [] + } + } + + /** + * Call a tool on the flow building MCP server. + * @param {string} toolName - The original tool name (without flow. prefix) + * @param {object} args - Tool arguments + * @returns {Promise} Tool result + */ + async callTool (toolName, args) { + if (!this.isConfigured) { + throw new Error('Flow building MCP server not configured') + } + + const client = new Client({ name: 'flowfuse-forge', version: '1.0.0' }) + const transport = new StreamableHTTPClientTransport(new URL(this.mcpUrl)) + + try { + await client.connect(transport) + const result = await client.callTool({ name: toolName, arguments: args }) + return result + } finally { + try { await client.close() } catch (_) {} + } + } + + async _fetchTools () { + const client = new Client({ name: 'flowfuse-forge', version: '1.0.0' }) + const transport = new StreamableHTTPClientTransport(new URL(this.mcpUrl)) + + try { + await client.connect(transport) + const { tools } = await client.listTools() + + // Wrap each tool with flow. prefix and bridge metadata + return tools.map(tool => ({ + name: `flow.${tool.name}`, + originalName: tool.name, + description: tool.description || '', + inputSchema: jsonSchemaToZod(tool.inputSchema), + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false + }, + _bridge: true + })) + } finally { + try { await client.close() } catch (_) {} + } + } + + /** + * Invalidate the tool cache (e.g., when config changes) + */ + invalidateCache () { + this.cachedTools = null + this.cacheExpiry = 0 + } +} + +module.exports = { FlowBuildingBridge } diff --git a/forge/ee/lib/mcp/mqttDispatch.js b/forge/ee/lib/mcp/mqttDispatch.js new file mode 100644 index 0000000000..9f2dd7cbf9 --- /dev/null +++ b/forge/ee/lib/mcp/mqttDispatch.js @@ -0,0 +1,192 @@ +'use strict' + +const { v4: uuidv4 } = require('uuid') + +/** + * MqttDispatch - Dispatches flow-building actions to a user's browser + * via MQTT inflight topics and waits for the response. + * + * Used when an external MCP client calls a flow-building tool. The forge + * platform acts as the "agent", publishing inflight requests and + * subscribing to inflight responses using the same protocol the expert + * agent uses. + * + * The MQTT v5 correlationData property is used to correlate requests + * with responses (same pattern as devices.js ResponseMonitor). + */ +class MqttDispatch { + /** + * @param {import('../../../forge').ForgeApplication} app + */ + constructor (app) { + this.app = app + /** @type {Map} */ + this.pendingRequests = new Map() + this.TIMEOUT = 30000 // 30 seconds + this._boundMessageHandler = null + this._subscribedTopics = new Set() + } + + /** + * Get the CommsClient instance from the app. + * @returns {import('../../../comms/commsClient').CommsClient|null} + */ + _getCommsClient () { + return this.app.comms?.devices?.client || null + } + + /** + * Get the raw MQTT client for subscribe/unsubscribe operations. + * @returns {import('mqtt').MqttClient|null} + */ + _getMqttClient () { + const commsClient = this._getCommsClient() + return commsClient?.client || null + } + + /** + * Ensure the global message handler is attached to the raw MQTT client. + * This handler routes incoming messages to any pending request that + * matches the response topic. + */ + _ensureMessageHandler () { + if (this._boundMessageHandler) return + + const mqttClient = this._getMqttClient() + if (!mqttClient) return + + this._boundMessageHandler = (topic, message, packet) => { + // Only process topics we are watching + if (!this._subscribedTopics.has(topic)) return + + try { + const correlationId = packet?.properties?.correlationData + ? Buffer.from(packet.properties.correlationData).toString() + : null + + // Try to match by correlationId first, then by topic + let pending = null + let pendingKey = null + + if (correlationId && this.pendingRequests.has(correlationId)) { + pending = this.pendingRequests.get(correlationId) + pendingKey = correlationId + } else { + // Fallback: match by response topic (for clients that don't + // echo correlationData) + for (const [key, req] of this.pendingRequests) { + if (req.responseTopic === topic) { + pending = req + pendingKey = key + break + } + } + } + + if (pending && pendingKey) { + clearTimeout(pending.timer) + this.pendingRequests.delete(pendingKey) + + const result = JSON.parse(message.toString()) + pending.resolve(result) + + // Unsubscribe from this response topic if no other + // pending requests need it + this._maybeUnsubscribe(pending.responseTopic) + } + } catch (err) { + this.app.log.warn(`MqttDispatch: error processing response: ${err.message}`) + } + } + + mqttClient.on('message', this._boundMessageHandler) + } + + /** + * Unsubscribe from a response topic if no pending requests reference it. + * @param {string} responseTopic + */ + _maybeUnsubscribe (responseTopic) { + // Check if any other pending request uses this topic + for (const req of this.pendingRequests.values()) { + if (req.responseTopic === responseTopic) return + } + + const mqttClient = this._getMqttClient() + if (mqttClient) { + mqttClient.unsubscribe(responseTopic) + } + this._subscribedTopics.delete(responseTopic) + } + + /** + * Dispatch an action to the user's browser via MQTT and wait for the result. + * + * @param {object} session - Editor session { sessionId, userId, teamId } + * @param {string} projectId - The instance ID + * @param {string} actionName - e.g. 'automation:add-nodes' + * @param {object} params - Action parameters + * @returns {Promise} Result from the browser's nr-assistant + */ + async dispatchAndWait (session, projectId, actionName, params) { + const mqttClient = this._getMqttClient() + const commsClient = this._getCommsClient() + if (!mqttClient || !commsClient) { + throw new Error('MQTT comms not available') + } + + this._ensureMessageHandler() + + const correlationId = uuidv4() + + // Build MQTT topics using a dedicated mcp namespace so the expert + // agent's NR instance (subscribed to ff/v1/expert/...) never sees these. + // Frontend subscribes to: ff/v1/mcp/{userId}/{sessionId}/p/{projectId}/support/inflight/{inflightType}/request + // Frontend publishes to: ff/v1/mcp/{userId}/{sessionId}/p/{projectId}/support/inflight/{inflightType}/response + const baseTopic = `ff/v1/mcp/${session.userId}/${session.sessionId}/p/${projectId}/support/inflight` + const requestTopic = `${baseTopic}/${actionName}/request` + const responseTopic = `${baseTopic}/${actionName}/response` + + // Subscribe to the response topic before publishing the request + if (!this._subscribedTopics.has(responseTopic)) { + await new Promise((resolve, reject) => { + mqttClient.subscribe(responseTopic, { qos: 2 }, (err) => { + if (err) return reject(err) + this._subscribedTopics.add(responseTopic) + resolve() + }) + }) + } + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pendingRequests.delete(correlationId) + this._maybeUnsubscribe(responseTopic) + reject(new Error(`Timeout waiting for action '${actionName}' response from browser`)) + }, this.TIMEOUT) + + this.pendingRequests.set(correlationId, { + resolve, + reject, + timer, + responseTopic + }) + + // Build the payload matching the inflight protocol. + // Include correlationId and sessionId in the payload body because + // the forge_platform MQTT client uses protocol v3.1.1 (no v5 properties). + const payload = JSON.stringify({ + status: actionName, + toolname: actionName, + params, + source: 'forge-mcp', + correlationId, + sessionId: session.sessionId + }) + + commsClient.publish(requestTopic, payload, { qos: 2 }) + }) + } +} + +module.exports = { MqttDispatch } diff --git a/forge/ee/lib/mcp/platformActionsInterface.js b/forge/ee/lib/mcp/platformActionsInterface.js new file mode 100644 index 0000000000..779751dc7d --- /dev/null +++ b/forge/ee/lib/mcp/platformActionsInterface.js @@ -0,0 +1,31 @@ +/** + * Abstract base class for platform action providers. + * Implementations define supported actions and their handlers. + */ +class PlatformActionsInterface { + /** @type {import('../../../forge').ForgeApplication} */ + app = null + + init (app) { + this.app = app + } + + get supportedActions () { + throw new Error('supportedActions getter not implemented') + } + + hasAction (actionName) { + throw new Error('hasAction method not implemented') + } + + /** + * @param {string} actionName + * @param {object} context + * @param {object} [result] + */ + async invokeAction (actionName, context, result = {}) { + throw new Error('invokeAction method not implemented') + } +} + +module.exports = PlatformActionsInterface diff --git a/forge/ee/lib/mcp/platformAutomations.js b/forge/ee/lib/mcp/platformAutomations.js new file mode 100644 index 0000000000..bbbbce3c9b --- /dev/null +++ b/forge/ee/lib/mcp/platformAutomations.js @@ -0,0 +1,601 @@ +'use strict' + +const { z } = require('zod') + +const PlatformActionsInterface = require('./platformActionsInterface') + +class PlatformAutomations extends PlatformActionsInterface { + _tools = { + // ----------------------------------------------------------------- + // Read-only tools + // ----------------------------------------------------------------- + + 'platform.list-teams': { + description: 'List teams the authenticated user belongs to', + inputSchema: z.object({}), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + handler: async (ctx) => { + const response = await this._inject({ method: 'GET', url: '/api/v1/user/teams', ...ctx }) + this._assertOk(response, 'list teams') + const body = response.json() + return { teams: body.teams } + } + }, + + 'platform.list-applications': { + description: 'List applications within a team', + inputSchema: z.object({ + teamId: z.string().describe('The hashid of the team') + }), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + handler: async (ctx) => { + const { teamId } = ctx.params + const response = await this._inject({ + method: 'GET', + url: `/api/v1/teams/${teamId}/applications?includeApplicationSummary=true`, + ...ctx + }) + this._assertOk(response, 'list applications') + const body = response.json() + return { applications: body.applications } + } + }, + + 'platform.list-instances': { + description: 'List instances within an application', + inputSchema: z.object({ + applicationId: z.string().describe('The hashid of the application') + }), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + handler: async (ctx) => { + const { applicationId } = ctx.params + const response = await this._inject({ + method: 'GET', + url: `/api/v1/applications/${applicationId}/instances`, + ...ctx + }) + this._assertOk(response, 'list instances') + const body = response.json() + return { instances: body.instances || [] } + } + }, + + 'platform.get-instance': { + description: 'Get the details of a specific instance', + inputSchema: z.object({ + instanceId: z.string().describe('The ID of the instance') + }), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + handler: async (ctx) => { + const { instanceId } = ctx.params + const response = await this._inject({ + method: 'GET', + url: `/api/v1/projects/${instanceId}`, + ...ctx + }) + this._assertOk(response, 'get instance') + return response.json() + } + }, + + 'platform.list-instance-types': { + description: 'List available instance types for a team', + inputSchema: z.object({ + teamId: z.string().describe('The hashid of the team') + }), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + handler: async (ctx) => { + const response = await this._inject({ + method: 'GET', + url: '/api/v1/project-types', + ...ctx + }) + this._assertOk(response, 'list instance types') + const body = response.json() + return { instanceTypes: body.types || [] } + } + }, + + 'platform.list-stacks': { + description: 'List available Node-RED stacks for a given instance type', + inputSchema: z.object({ + instanceTypeId: z.string().describe('The hashid of the instance type') + }), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + handler: async (ctx) => { + const { instanceTypeId } = ctx.params + const response = await this._inject({ + method: 'GET', + url: `/api/v1/stacks?projectType=${instanceTypeId}`, + ...ctx + }) + this._assertOk(response, 'list stacks') + const body = response.json() + return { stacks: body.stacks || [] } + } + }, + + 'platform.list-blueprints': { + description: 'List flow blueprints available for a team', + inputSchema: z.object({ + teamId: z.string().describe('The hashid of the team') + }), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + handler: async (ctx) => { + const { teamId } = ctx.params + const response = await this._inject({ + method: 'GET', + url: `/api/v1/flow-blueprints?team=${teamId}`, + ...ctx + }) + this._assertOk(response, 'list blueprints') + const body = response.json() + return { blueprints: body.blueprints || [] } + } + }, + + 'platform.get-instance-status': { + description: 'Get the live status of a specific instance', + inputSchema: z.object({ + instanceId: z.string().describe('The ID of the instance') + }), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + handler: async (ctx) => { + const { instanceId } = ctx.params + const response = await this._inject({ + method: 'GET', + url: `/api/v1/projects/${instanceId}`, + ...ctx + }) + this._assertOk(response, 'get instance status') + const body = response.json() + return { + id: body.id, + name: body.name, + state: body.state || null, + meta: body.meta || null + } + } + }, + + 'platform.check-name-availability': { + description: 'Check whether a name is available for a new instance or application', + inputSchema: z.object({ + name: z.string().describe('The name to check'), + type: z.enum(['instance', 'application']).describe('The resource type to check the name for'), + teamId: z.string().optional().describe('The hashid of the team (required when type is "application")') + }), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + handler: async (ctx) => { + const { name, type } = ctx.params + if (type === 'instance') { + const response = await this._inject({ + method: 'POST', + url: '/api/v1/projects/check-name', + payload: { name }, + ...ctx + }) + if (response.statusCode === 409) { + const body = response.json() + return { available: false, reason: body.error || 'name not available' } + } + this._assertOk(response, 'check name availability') + return { available: true } + } else if (type === 'application') { + const trimmedName = name?.trim() + if (!trimmedName) { + return { available: false, reason: 'Name must not be empty' } + } + return { available: true } + } + return { available: false, reason: `Unknown type: ${type}` } + } + }, + + // ----------------------------------------------------------------- + // Write tools + // ----------------------------------------------------------------- + + 'platform.create-application': { + description: 'Create a new application within a team', + inputSchema: z.object({ + name: z.string().describe('The name of the new application'), + teamId: z.string().describe('The hashid of the team to create the application in'), + description: z.string().optional().describe('An optional description for the application') + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + handler: async (ctx) => { + const { teamId, name, description } = ctx.params + const response = await this._inject({ + method: 'POST', + url: '/api/v1/applications', + payload: { name, description, teamId }, + ...ctx + }) + this._assertOk(response, 'create application') + return response.json() + } + }, + + 'platform.create-instance': { + description: 'Create a new Node-RED instance within an application. The instance starts automatically after creation.', + inputSchema: z.object({ + name: z.string().describe('The name of the new instance'), + applicationId: z.string().describe('The hashid of the application to create the instance in'), + projectType: z.string().describe('The hashid of the instance type to use'), + stack: z.string().describe('The hashid of the stack to use'), + template: z.string().optional().describe('The hashid of the template to use. If omitted, the platform default template is used.'), + flowBlueprintId: z.string().optional().describe('The hashid of an optional flow blueprint to apply') + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + handler: async (ctx) => { + const { applicationId, projectType, stack, flowBlueprintId, name } = ctx.params + let { template } = ctx.params + + // The POST /api/v1/projects route requires template. Auto-select if omitted. + if (!template) { + const defaultTemplate = await this.app.db.models.ProjectTemplate.findAll({ + where: { active: true }, + order: [['id', 'ASC']], + limit: 1 + }) + if (!defaultTemplate[0]) { + throw new Error('No active template found. Please specify a template.') + } + template = this.app.db.models.ProjectTemplate.encodeHashid(defaultTemplate[0].id) + } + + const payload = { name, applicationId, projectType, stack, template } + if (flowBlueprintId) payload.flowBlueprintId = flowBlueprintId + + const response = await this._inject({ + method: 'POST', + url: '/api/v1/projects', + payload, + ...ctx + }) + this._assertOk(response, 'create instance') + const body = response.json() + + return { + ...body, + navigation: { + suggestion: 'open-editor', + target: `/instance/${body.id}/editor`, + label: 'Open Editor' + } + } + } + }, + + // ----------------------------------------------------------------- + // Instance lifecycle tools + // ----------------------------------------------------------------- + + 'platform.manage-instance': { + description: 'Manage an instance lifecycle: start, stop, restart, or suspend', + inputSchema: z.object({ + instanceId: z.string().describe('The ID of the instance'), + action: z.enum(['start', 'stop', 'restart', 'suspend']).describe('The lifecycle action to perform') + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + handler: async (ctx) => { + const { instanceId, action } = ctx.params + const response = await this._inject({ + method: 'POST', + url: `/api/v1/projects/${instanceId}/actions/${action}`, + ...ctx + }) + this._assertOk(response, `${action} instance`) + return { status: 'okay', instanceId, action } + } + }, + + // ----------------------------------------------------------------- + // Navigation tools + // ----------------------------------------------------------------- + + 'platform.open-editor': { + description: 'Get the URL to open the Node-RED editor for an instance. The user must open this URL in their browser before flow tools can be used.', + inputSchema: z.object({ + instanceId: z.string().describe('The ID of the instance') + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + handler: async (ctx) => { + const { instanceId } = ctx.params + const response = await this._inject({ + method: 'GET', + url: `/api/v1/projects/${instanceId}`, + ...ctx + }) + this._assertOk(response, 'get instance') + const body = response.json() + const baseUrl = this.app.config.base_url + return { + url: `${baseUrl}/instance/${body.id}/editor`, + target: `/instance/${body.id}/editor`, + message: 'Open this URL in the browser to access the editor' + } + } + }, + + 'platform.open-instance': { + description: 'Get the URL to open an instance in the FlowFuse dashboard', + inputSchema: z.object({ + instanceId: z.string().describe('The ID of the instance') + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + handler: async (ctx) => { + const { instanceId } = ctx.params + const response = await this._inject({ + method: 'GET', + url: `/api/v1/projects/${instanceId}`, + ...ctx + }) + this._assertOk(response, 'get instance') + const body = response.json() + const baseUrl = this.app.config.base_url + return { + url: `${baseUrl}/instance/${body.id}`, + target: `/instance/${body.id}`, + message: 'Open this URL to access the instance' + } + } + }, + + // ----------------------------------------------------------------- + // Destructive tools + // ----------------------------------------------------------------- + + 'platform.delete-instance': { + description: 'Permanently delete an instance and all its data. This action cannot be undone.', + inputSchema: z.object({ + instanceId: z.string().describe('The ID of the instance to delete') + }), + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, + handler: async (ctx) => { + const { instanceId } = ctx.params + const response = await this._inject({ + method: 'DELETE', + url: `/api/v1/projects/${instanceId}`, + ...ctx + }) + this._assertOk(response, 'delete instance') + return { status: 'okay', message: 'Instance has been deleted' } + } + }, + + 'platform.delete-application': { + description: 'Permanently delete an application. The application must have no instances. This action cannot be undone.', + inputSchema: z.object({ + applicationId: z.string().describe('The hashid of the application to delete') + }), + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, + handler: async (ctx) => { + const { applicationId } = ctx.params + const response = await this._inject({ + method: 'DELETE', + url: `/api/v1/applications/${applicationId}`, + ...ctx + }) + this._assertOk(response, 'delete application') + return { status: 'okay', message: 'Application has been deleted' } + } + }, + + // ----------------------------------------------------------------- + // Settings and snapshot tools + // ----------------------------------------------------------------- + + 'platform.update-instance-settings': { + description: "Update an instance's environment variables and settings", + inputSchema: z.object({ + instanceId: z.string().describe('The ID of the instance to update'), + env: z.record(z.string()).optional().describe('An object of environment variable key/value pairs to set on the instance'), + settings: z.record(z.unknown()).optional().describe('An object of settings to merge into the instance settings') + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + handler: async (ctx) => { + const { instanceId, env, settings } = ctx.params + + const bodySettings = {} + if (env) { + bodySettings.env = Object.entries(env).map(([name, value]) => ({ name, value })) + } + if (settings) { + Object.assign(bodySettings, settings) + } + + if (Object.keys(bodySettings).length === 0) { + return { status: 'okay', message: 'No changes to apply', instanceId } + } + + const response = await this._inject({ + method: 'PUT', + url: `/api/v1/projects/${instanceId}`, + payload: { settings: bodySettings }, + ...ctx + }) + this._assertOk(response, 'update instance settings') + return { status: 'okay', instanceId, message: 'Instance settings updated' } + } + }, + + 'platform.create-snapshot': { + description: 'Create a snapshot of the current state of an instance', + inputSchema: z.object({ + instanceId: z.string().describe('The ID of the instance to snapshot'), + name: z.string().describe('The name for the snapshot'), + description: z.string().optional().describe('An optional description for the snapshot') + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + handler: async (ctx) => { + const { instanceId, name, description } = ctx.params + const response = await this._inject({ + method: 'POST', + url: `/api/v1/projects/${instanceId}/snapshots`, + payload: { name, description: description || '' }, + ...ctx + }) + this._assertOk(response, 'create snapshot') + const body = response.json() + return { + id: body.id, + name: body.name, + description: body.description || null, + createdAt: body.createdAt + } + } + } + } + + get supportedActions () { + const actions = {} + for (const [name, def] of Object.entries(this._tools)) { + actions[name] = { + description: def.description, + inputSchema: def.inputSchema, + annotations: def.annotations + } + } + return Object.freeze(actions) + } + + hasAction (actionName) { + return !!this._tools[actionName] + } + + /** + * @param {string} actionName + * @param {{ user: import('sequelize').Model, params: object, app: import('../../../forge').ForgeApplication, viaMCPToken?: boolean, headers?: object, cookies?: object }} context + */ + async invokeAction (actionName, context) { + const tool = this._tools[actionName] + if (!tool) { + throw new Error(`Unknown action: ${actionName}`) + } + + await this._checkMCPToolAccess(context.user, actionName, context.params) + + if (context.viaMCPToken) { + this.app.log.info({ + trigger: 'mcp-agent', + action: actionName, + userId: context.user?.id, + userHashid: context.user?.hashid + }, `MCP tool invoked via mcp-agent token: ${actionName}`) + } + + return tool.handler(context) + } + + // --------------------------------------------------------------------- + // MCP RBAC + // --------------------------------------------------------------------- + + /** + * Check that the user has sufficient MCP RBAC permissions to execute the given tool. + * Admin users bypass all checks. For tools with no team context (list-teams), the + * team-scoped permission check is skipped because list-teams only returns the user's + * own teams and requires no additional privilege beyond authentication. + */ + async _checkMCPToolAccess (user, actionName, params) { + if (user.admin) { + return + } + + const tool = this._tools[actionName] + if (!tool) { + return + } + + const annotations = tool.annotations || {} + const isReadonly = annotations.readOnlyHint === true + const isDestructive = annotations.destructiveHint === true + const isIdempotent = annotations.idempotentHint === true + + let team = null + const { teamId, applicationId, instanceId } = params || {} + + if (teamId) { + team = await this.app.db.models.Team.byId(teamId) + } else if (applicationId) { + const application = await this.app.db.models.Application.byId(applicationId) + if (application) { + team = application.Team || await application.getTeam() + } + } else if (instanceId) { + const instance = await this.app.db.models.Project.byId(instanceId) + if (instance) { + team = instance.Team + } + } + + if (!team) { + return + } + + const membership = await user.getTeamMembership(team.id) + if (!membership) { + return + } + + if (!this.app.hasPermission(membership, 'expert:insights:mcp:tool:allow')) { + throw new Error('Access denied: insufficient permissions to use MCP tools (requires expert:insights:mcp:tool:allow)') + } + + if (!isReadonly) { + if (!this.app.hasPermission(membership, 'expert:insights:mcp:tool:write')) { + throw new Error('Access denied: insufficient permissions to use write MCP tools (requires expert:insights:mcp:tool:write)') + } + } + + if (isDestructive) { + if (!this.app.hasPermission(membership, 'expert:insights:mcp:tool:destructive')) { + throw new Error('Access denied: insufficient permissions to use destructive MCP tools (requires expert:insights:mcp:tool:destructive)') + } + } + + if (!isReadonly && !isIdempotent) { + if (!this.app.hasPermission(membership, 'expert:insights:mcp:tool:non-idempotent')) { + throw new Error('Access denied: insufficient permissions to use non-idempotent MCP tools (requires expert:insights:mcp:tool:non-idempotent)') + } + } + } + + // --------------------------------------------------------------------- + // Internal helpers + // --------------------------------------------------------------------- + + /** + * Make an in-process HTTP request via Fastify's inject(), forwarding the + * caller's auth credentials (Bearer token or signed session cookie). + */ + async _inject ({ method, url, payload, headers, cookies }) { + const opts = { method, url } + if (headers?.authorization) { + opts.headers = { authorization: headers.authorization } + } else if (cookies?.sid) { + opts.cookies = { sid: cookies.sid } + } + if (payload) { + opts.payload = payload + } + return this.app.inject(opts) + } + + /** + * Throw if the inject response indicates an error. + */ + _assertOk (response, action) { + if (response.statusCode >= 400) { + const body = response.json() + const err = new Error(body.error || `${action} failed`) + err.statusCode = response.statusCode + err.code = body.code + throw err + } + } +} + +module.exports = PlatformAutomations diff --git a/forge/ee/routes/expert/index.js b/forge/ee/routes/expert/index.js index 99e78bb9a8..e160ed1f41 100644 --- a/forge/ee/routes/expert/index.js +++ b/forge/ee/routes/expert/index.js @@ -8,8 +8,10 @@ */ const { default: axios } = require('axios') const { v4: uuidv4 } = require('uuid') +const { zodToJsonSchema } = require('zod-to-json-schema') const { filterAccessibleMCPServerFeatures } = require('../../../services/expert.js') +const PlatformAutomations = require('../../lib/mcp/platformAutomations') /** * @param {import('../../forge.js').ForgeApplication} app @@ -69,11 +71,13 @@ module.exports = async function (app) { } const isExpertAssistantEnabled = !!(app.config.features.enabled('expertAssistant') && request.team.getFeatureProperty('expertAssistant', true)) const isExpertInsightsEnabled = !!(app.config.features.enabled('expertInsights') && request.team.getFeatureProperty('expertInsights', true)) - if (!isExpertAssistantEnabled && !isExpertInsightsEnabled) { + const isExpertPlatformAutomationEnabled = !!(app.config.features.enabled('expertPlatformAutomation') && request.team.getFeatureProperty('expertPlatformAutomation', true)) + if (!isExpertAssistantEnabled && !isExpertInsightsEnabled && !isExpertPlatformAutomationEnabled) { return reply.status(404).send({ code: 'not_found', error: 'Not Found' }) } request.isExpertAssistantEnabled = isExpertAssistantEnabled request.isExpertInsightsEnabled = isExpertInsightsEnabled + request.isExpertPlatformAutomationEnabled = isExpertPlatformAutomationEnabled } }) @@ -227,6 +231,16 @@ module.exports = async function (app) { context.selectedCapabilities = filteredAccessibleServers?.length > 0 ? filteredAccessibleServers : undefined } + // Inject platform automation tool definitions into the context so the + // expert agent can create DynamicStructuredTools that dispatch back via + // MQTT inflight → frontend → POST /api/v1/expert/platform-tool. + if (request.isExpertPlatformAutomationEnabled) { + const platformToolDefs = buildPlatformToolDefinitions(app, request.team, request.teamMembership) + if (platformToolDefs.length > 0) { + context.platformTools = platformToolDefs + } + } + let query = request.body.query if (request.body.history) { query = '' @@ -255,6 +269,60 @@ 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 }) } }) + /** + * Execute a platform automation tool on behalf of the authenticated user. + * Tool names map to PlatformAutomations.supportedActions keys (e.g. 'list-teams'). + */ + app.post('/platform-tool', { + schema: { + hide: true, + body: { + type: 'object', + required: ['tool', 'arguments', 'context'], + properties: { + tool: { type: 'string' }, + arguments: { type: 'object', additionalProperties: true }, + context: { + type: 'object', + properties: { + teamId: { type: 'string', minLength: 1 } + }, + required: ['teamId'], + additionalProperties: true + } + } + } + } + }, async (request, reply) => { + const user = request.user // set by preHandler + if (!user) { + return reply.code(401).send({ error: 'Unauthorized' }) + } + + const { tool, arguments: args } = request.body + + const platformAutomations = new PlatformAutomations() + platformAutomations.init(app) + + if (!platformAutomations.hasAction(tool)) { + return reply.code(400).send({ error: `Unknown platform tool: ${tool}` }) + } + + try { + const result = await platformAutomations.invokeAction(tool, { + user, + params: args, + app, + headers: request.headers, + cookies: request.cookies + }) + reply.send({ result }) + } catch (err) { + app.log.error(`Expert platform tool ${tool} error: ${err.message}`) + reply.code(400).send({ error: err.message }) + } + }) + /** * an endpoint to retrieve MCP features (prompts/resources/tools) for the users team */ @@ -289,6 +357,10 @@ module.exports = async function (app) { type: 'object', properties: { transactionId: { type: 'string' }, + platformTools: { + type: 'array', + items: { type: 'object', additionalProperties: true } + }, servers: { type: 'array', items: { @@ -327,161 +399,295 @@ module.exports = async function (app) { * @param {import('fastify').FastifyReply} reply */ async (request, reply) => { - if (!request.isExpertInsightsEnabled) { + // Platform tools are gated on expertPlatformAutomation; NR MCP servers on expertInsights. + // At least one must be enabled to access this endpoint. + if (!request.isExpertInsightsEnabled && !request.isExpertPlatformAutomationEnabled) { return reply.status(404).send({ code: 'not_found', error: 'Not Found' }) } try { - // Premise: - // In order to get the MCP features (prompts/resources/tools) available to the user for their - // team / application RBACs, in order of computational cost, we need do the following: - // 1. Get the MCP servers registered for this team - // 2. For each MCP server - // 1. Ensure the instance is supposed to be running (i.e. state is 'running') - // 2. Get the application and check RBAC permits access - // 3. Ensure the instance is actually running (live state check) - // 5. For each running instance with MCP server, get/create access token as needed - // based on the instance's node security settings - // 6. Call to backend expert service to get the MCP features for the accessible MCP servers - // 7. Filter the MCP features based on user RBACs (e.g. destructive tool access) - // 3. Return the filtered MCP features to the client - - /** @type {MCPServerItem[]} */ - const runningInstancesWithMCPServer = [] const transactionId = request.headers['x-chat-transaction-id'] - const mcpCapabilitiesUrl = `${app.expert.expertUrl.split('/').slice(0, -1).join('/')}/mcp/features` + let filteredServers = [] + + // NR instance MCP servers — only when insights is enabled + if (request.isExpertInsightsEnabled) { + // Premise: + // In order to get the MCP features (prompts/resources/tools) available to the user for their + // team / application RBACs, in order of computational cost, we need do the following: + // 1. Get the MCP servers registered for this team + // 2. For each MCP server + // 1. Ensure the instance is supposed to be running (i.e. state is 'running') + // 2. Get the application and check RBAC permits access + // 3. Ensure the instance is actually running (live state check) + // 5. For each running instance with MCP server, get/create access token as needed + // based on the instance's node security settings + // 6. Call to backend expert service to get the MCP features for the accessible MCP servers + // 7. Filter the MCP features based on user RBACs (e.g. destructive tool access) + // 3. Return the filtered MCP features to the client + + /** @type {MCPServerItem[]} */ + const runningInstancesWithMCPServer = [] + const mcpCapabilitiesUrl = `${app.expert.expertUrl.split('/').slice(0, -1).join('/')}/mcp/features` + + // Get the MCP servers registered for this team + const mcpServers = await app.db.models.MCPRegistration.byTeam(request.team.id, { includeInstance: true }) || [] + + // Scan each MCP server and ensure the user has access to the associated application and that the instance is running + // then collect the MCP server info for the running instances MCP servers + // filter out any that the user doesn't have access to + const applicationCache = {} + for (const server of mcpServers) { + const { name, protocol, endpointRoute, TeamId, Project, Device, title, version, description } = server + if (TeamId !== request.team.id) { + // shouldn't happen due to byTeam filter, but just in case + continue + } + let instance, instanceId, instanceType + if (Device) { + // instanceType = 'device' + // instance = Device + // instanceId = Device.hashid + continue // Devices are not yet supported for MCP servers + } else if (Project) { + instanceType = 'instance' + instance = Project + instanceId = Project.id + } else { + continue + } - // Get the MCP servers registered for this team - const mcpServers = await app.db.models.MCPRegistration.byTeam(request.team.id, { includeInstance: true }) || [] + // if instance is not expected to be running, skip it (avoids unnecessary timeouts) + if (instance?.state !== 'running') { + continue + } - // Scan each MCP server and ensure the user has access to the associated application and that the instance is running - // then collect the MCP server info for the running instances MCP servers - // filter out any that the user doesn't have access to - const applicationCache = {} - for (const server of mcpServers) { - const { name, protocol, endpointRoute, TeamId, Project, Device, title, version, description } = server - if (TeamId !== request.team.id) { - // shouldn't happen due to byTeam filter, but just in case - continue - } - let instance, instanceId, instanceType - if (Device) { - // instanceType = 'device' - // instance = Device - // instanceId = Device.hashid - continue // Devices are not yet supported for MCP servers - } else if (Project) { - instanceType = 'instance' - instance = Project - instanceId = Project.id - } else { - continue - } + // Ensure instance has an associated application + if (!instance?.ApplicationId) { + continue // e.g. skip devices without an application as they can't be validated for access + } - // if instance is not expected to be running, skip it (avoids unnecessary timeouts) - if (instance?.state !== 'running') { - continue - } + // Get the application from local cache or db (an application can appear multiple times if multiple instances are registered) + const applicationHashid = app.db.models.Application.encodeHashid(instance.ApplicationId) + if (!Object.hasOwnProperty.call(applicationCache, applicationHashid)) { + applicationCache[applicationHashid] = await app.db.models.Application.byId(applicationHashid) + } + const application = applicationCache[applicationHashid] + if (!application) { + continue // skip - application not found + } - // Ensure instance has an associated application - if (!instance?.ApplicationId) { - continue // e.g. skip devices without an application as they can't be validated for access - } + // Now we have the application & know the instance is supposed to be running, check user actually + // has access before bothering to check instance live state or calling backend for MCP features! + if (!app.hasPermission(request.teamMembership, 'expert:insights:mcp:allow', { application })) { + continue // user doesn't have access to this instance + } - // Get the application from local cache or db (an application can appear multiple times if multiple instances are registered) - const applicationHashid = app.db.models.Application.encodeHashid(instance.ApplicationId) - if (!Object.hasOwnProperty.call(applicationCache, applicationHashid)) { - applicationCache[applicationHashid] = await app.db.models.Application.byId(applicationHashid) - } - const application = applicationCache[applicationHashid] - if (!application) { - continue // skip - application not found - } + // Now we have confirmed access is allowed, check instance is actually running before offering + // MCP features (querying a non-running instance would cause timeouts) + if (instance.liveState) { + const liveState = await instance.liveState({ omitStorageFlows: true }) + if (liveState?.meta?.state !== 'running') { + continue + } + } - // Now we have the application & know the instance is supposed to be running, check user actually - // has access before bothering to check instance live state or calling backend for MCP features! - if (!app.hasPermission(request.teamMembership, 'expert:insights:mcp:allow', { application })) { - continue // user doesn't have access to this instance + // Check instance settings for node security. If FlowFuse auth is enabled, generate a short-lived (5 mins) + // auth token for the instance with a scope limited to MCP access and cache it in memory for subsequent requests + const mcpAccessToken = await app.expert.mcp.getOrCreateToken(instance, instanceType, instanceId, request.teamHttpSecurityFeature) + + runningInstancesWithMCPServer.push({ + team: request.team.hashid, + application: application.hashid, + instance: instanceId, + instanceType, + instanceName: instance.name, + instanceUrl: instance.url, + mcpServerName: name, + mcpEndpoint: endpointRoute, + mcpProtocol: protocol, + mcpAccessToken, + title, + version, + description + }) } - // Now we have confirmed access is allowed, check instance is actually running before offering - // MCP features (querying a non-running instance would cause timeouts) - if (instance.liveState) { - const liveState = await instance.liveState({ omitStorageFlows: true }) - if (liveState?.meta?.state !== 'running') { - continue + // Call to backend to request MCP capabilities from expert service (NR instances only) + if (runningInstancesWithMCPServer.length > 0) { + try { + const response = await axios.post(mcpCapabilitiesUrl, { + teamId: request.team.hashid, + servers: runningInstancesWithMCPServer + }, { + headers: { + Origin: request.headers.origin, + 'X-Chat-Transaction-ID': transactionId, + ...(app.expert.serviceToken ? { Authorization: `Bearer ${app.expert.serviceToken}` } : {}) + }, + timeout: app.expert.requestTimeout + }) + + if (response.data.transactionId !== transactionId) { + throw new Error('Transaction ID mismatch') + } + const mcpServersResponse = response.data.servers || [] + const serverList = [] + // load the associate application models so that we can filter features based on user access + for (const serverItem of mcpServersResponse) { + const application = applicationCache[serverItem.application] + if (application) { + serverList.push({ + server: serverItem, + application + }) + } + } + // check tools/resources/prompts access per server based on team membership + filteredServers = filterAccessibleMCPServerFeatures(app, serverList, request.team, request.teamMembership) + } catch (mcpError) { + app.log.warn(`Failed to fetch NR instance MCP features: ${mcpError.message}`) } } - - // Check instance settings for node security. If FlowFuse auth is enabled, generate a short-lived (5 mins) - // auth token for the instance with a scope limited to MCP access and cache it in memory for subsequent requests - const mcpAccessToken = await app.expert.mcp.getOrCreateToken(instance, instanceType, instanceId, request.teamHttpSecurityFeature) - - runningInstancesWithMCPServer.push({ - team: request.team.hashid, - application: application.hashid, - instance: instanceId, - instanceType, - instanceName: instance.name, - instanceUrl: instance.url, - mcpServerName: name, - mcpEndpoint: endpointRoute, - mcpProtocol: protocol, - mcpAccessToken, - title, - version, - description - }) - } - - // if no running instances with MCP server, return early - if (runningInstancesWithMCPServer.length === 0) { - return reply.send({ servers: [], transactionId }) } - // Call to backend to request MCP capabilities from expert service - // For reference - this POST: - // * calls the backend expert service endpoint /mcp/features - // * it connects to each MCP server registered - // * retrieves the prompts/resources/tools - // * adds them to the response along with the MCP server info - const response = await axios.post(mcpCapabilitiesUrl, { - teamId: request.team.hashid, - servers: runningInstancesWithMCPServer - }, { - headers: { - Origin: request.headers.origin, - 'X-Chat-Transaction-ID': transactionId, - ...(app.expert.serviceToken ? { Authorization: `Bearer ${app.expert.serviceToken}` } : {}) - }, - timeout: app.expert.requestTimeout - }) + // Platform tools — gated on expertPlatformAutomation, independent of insights + let platformToolDefs = [] + if (request.isExpertPlatformAutomationEnabled) { + const platformServer = buildPlatformMCPServer(app, request.team, request.teamMembership) + platformToolDefs = buildPlatformToolDefinitions(app, request.team, request.teamMembership) - if (response.data.transactionId !== transactionId) { - throw new Error('Transaction ID mismatch') - } - const mcpServersResponse = response.data.servers || [] - const serverList = [] - // load the associate application models so that we can filter features based on user access - for (const serverItem of mcpServersResponse) { - const application = applicationCache[serverItem.application] - if (application) { - serverList.push({ - server: serverItem, - application - }) + if (platformServer) { + filteredServers.push(platformServer) } } - // now check tools/resources/prompts access per server based on team membership - response.data.servers = filterAccessibleMCPServerFeatures(app, serverList, request.team, request.teamMembership) - reply.send(response.data) + reply.send({ servers: filteredServers, platformTools: platformToolDefs, transactionId }) } catch (error) { reply.code(error.response?.status || 500).send({ code: error.response?.data?.code || 'unexpected_error', error: error.response?.data?.error || error.message }) } }) } +/** + * Build an array of platform tool definitions for injection into the expert + * chat context. Each entry contains enough information for the expert to + * create a DynamicStructuredTool that dispatches the call via MQTT inflight. + * + * Uses the same RBAC filtering as buildPlatformMCPServer so the expert only + * sees tools the user is allowed to execute. + * + * @param {import('../../forge.js').ForgeApplication} app + * @param {import('sequelize').Model} team + * @param {import('sequelize').Model} teamMembership + * @returns {Array<{name: string, description: string, inputSchema: object, annotations: object}>} + */ +function buildPlatformToolDefinitions (app, team, teamMembership) { + const defaultToolPermission = app.hasPermission(teamMembership, 'expert:insights:mcp:tool:allow') + if (!defaultToolPermission) { + return [] + } + + const allowToolWrite = app.hasPermission(teamMembership, 'expert:insights:mcp:tool:write') + const allowToolDestructive = app.hasPermission(teamMembership, 'expert:insights:mcp:tool:destructive') + const allowToolNonIdempotent = app.hasPermission(teamMembership, 'expert:insights:mcp:tool:non-idempotent') + + const platformAutomations = new PlatformAutomations() + platformAutomations.init(app) + + return Object.entries(platformAutomations.supportedActions) + .filter(([, def]) => { + const annotations = def.annotations || {} + const isReadonly = annotations.readOnlyHint === true + const isDestructive = annotations.destructiveHint !== false + const isIdempotent = annotations.idempotentHint === true + if (isReadonly && isDestructive) return false + const writeAccessRequired = isDestructive === true || isReadonly === false + if (writeAccessRequired) { + if (isDestructive === true && !allowToolDestructive) return false + if (isReadonly === false && !allowToolWrite) return false + if (isIdempotent === false && !allowToolNonIdempotent) return false + } + return true + }) + .map(([name, def]) => ({ + name, + description: def.description, + inputSchema: zodToJsonSchema(def.inputSchema, { target: 'openApi3' }), + annotations: def.annotations + })) +} + +/** + * Build a virtual MCP server entry for FlowFuse platform automation tools. + * Applies the same RBAC tool-filtering logic as NR instance MCP servers, but + * without an application context (platform tools are team-scoped). + * + * @param {import('../../forge.js').ForgeApplication} app + * @param {import('sequelize').Model} team + * @param {import('sequelize').Model} teamMembership + * @returns {object|null} A server-shaped object to append to the features response, or null if the user has no tool access. + */ +function buildPlatformMCPServer (app, team, teamMembership) { + // Gate: user must have general MCP tool access at the team level + const defaultToolPermission = app.hasPermission(teamMembership, 'expert:insights:mcp:tool:allow') + if (!defaultToolPermission) { + return null + } + + const allowToolWrite = app.hasPermission(teamMembership, 'expert:insights:mcp:tool:write') + const allowToolDestructive = app.hasPermission(teamMembership, 'expert:insights:mcp:tool:destructive') + const allowToolNonIdempotent = app.hasPermission(teamMembership, 'expert:insights:mcp:tool:non-idempotent') + + const platformAutomations = new PlatformAutomations() + platformAutomations.init(app) + + const allTools = Object.entries(platformAutomations.supportedActions).map(([name, def]) => ({ + name, + description: def.description, + annotations: def.annotations + })) + + // Filter tools by RBAC (mirrors filterAccessibleMCPServerFeatures tool logic) + const tools = allTools.filter(tool => { + const annotations = tool.annotations || {} + const isReadonly = annotations.readOnlyHint === true + const isDestructive = annotations.destructiveHint !== false + const isIdempotent = annotations.idempotentHint === true + + if (isReadonly && isDestructive) return false // invalid combination + + const writeAccessRequired = isDestructive === true || isReadonly === false + if (writeAccessRequired) { + if (isDestructive === true && !allowToolDestructive) return false + if (isReadonly === false && !allowToolWrite) return false + if (isIdempotent === false && !allowToolNonIdempotent) return false + } + + return true + }) + + if (tools.length === 0) { + return null + } + + return { + team: team.hashid, + instance: 'platform', + instanceType: 'instance', + instanceName: 'FlowFuse Platform', + mcpServerName: 'platform', + mcpEndpoint: '/api/v1/mcp', + mcpProtocol: 'http', + title: 'FlowFuse Platform', + version: '1.0.0', + description: 'FlowFuse platform automation tools', + prompts: [], + resources: [], + resourceTemplates: [], + tools + } +} + /** * @typedef {Object} MCPServerItem MCP server info for a team * @property {string} team diff --git a/forge/ee/routes/index.js b/forge/ee/routes/index.js index 421b3b1545..b77f29b4d8 100644 --- a/forge/ee/routes/index.js +++ b/forge/ee/routes/index.js @@ -42,6 +42,7 @@ module.exports = async function (app) { await app.register(require('./mcp'), { prefix: '/api/v1/teams/:teamId/mcp', logLevel: app.config.logging.http }) await app.register(require('./autoUpdateStacks'), { prefix: '/api/v1/projects/:projectId/autoUpdateStack', logLevel: app.config.logging.http }) await app.register(require('./expert'), { prefix: '/api/v1/expert', logLevel: app.config.logging.http }) + await app.register(require('./mcpServer'), { prefix: '/api/v1/mcp', logLevel: app.config.logging.http }) // Important: keep SSO last to avoid its error handling polluting other routes. await app.register(require('./sso'), { logLevel: app.config.logging.http }) diff --git a/forge/ee/routes/mcpServer/index.js b/forge/ee/routes/mcpServer/index.js new file mode 100644 index 0000000000..4956f6136d --- /dev/null +++ b/forge/ee/routes/mcpServer/index.js @@ -0,0 +1,324 @@ +'use strict' + +const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js') +const { StreamableHTTPServerTransport } = require('@modelcontextprotocol/sdk/server/streamableHttp.js') +const { z } = require('zod') + +const { FlowBuildingBridge } = require('../../lib/mcp/flowBuildingBridge') +const { MqttDispatch } = require('../../lib/mcp/mqttDispatch') +const PlatformAutomations = require('../../lib/mcp/platformAutomations') + +/** + * MCP Server route — exposes the FlowFuse platform as an MCP-compatible endpoint. + * + * Mounted at: /api/v1/mcp + * + * The endpoint is stateless: a fresh McpServer and transport are created per request. + * Authentication is enforced by the preHandler hook inherited from the parent EE routes + * (app.verifySession), plus the additional check below that requires a session User. + * + * @param {import('../../../forge').ForgeApplication} app + */ +module.exports = async function (app) { + const platformAutomations = new PlatformAutomations() + platformAutomations.init(app) + + // Initialise the FlowBuildingBridge singleton + if (!app.flowBuildingBridge) { + app.decorate('flowBuildingBridge', new FlowBuildingBridge(app)) + } + + // Initialise the MqttDispatch singleton (for external MCP client flow tool dispatch) + if (!app.mqttDispatch) { + app.decorate('mqttDispatch', new MqttDispatch(app)) + } + + // Require a session user (Bearer token / session cookie) for all MCP requests + app.addHook('preHandler', async (request, reply) => { + if (!request.session?.User) { + const baseUrl = app.config.base_url + reply.header('WWW-Authenticate', `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource"`) + reply.code(401).send({ code: 'unauthorized', error: 'Unauthorized' }) + throw new Error('Unauthorized') + } + // Flag requests from external MCP clients (Bearer token, not cookie session). + // The auth middleware deletes scope for named tokens, so check the Authorization header directly. + const authHeader = request.headers.authorization + request.session.viaMCPToken = !!(authHeader && authHeader.startsWith('Bearer ')) + }) + + /** + * Find an active editor session for the given user across all accessible instances. + * Returns { session, projectId } or null. + * + * @param {import('sequelize').Model} user + * @returns {Promise<{ session: object, projectId: string } | null>} + */ + async function findUserEditorSession (user) { + const editorSessions = app.comms?.editorSessions + if (!editorSessions) return null + + const userHashid = user.hashid || user.id + + // Look up the user's teams, then their instances, and check for an active session + const teamMemberships = await app.db.models.Team.forUser(user) + for (const membership of teamMemberships) { + const team = membership.Team || membership + const teamId = team.hashid || app.db.models.Team.encodeHashid(team.id) + const applications = await app.db.models.Application.byTeam(teamId) + for (const application of applications) { + const instances = await app.db.models.Project.byApplication(application.hashid) + if (!instances) continue + for (const instance of instances) { + const session = editorSessions.getActiveSession(instance.id) + if (session && String(session.userId) === String(userHashid)) { + return { session, projectId: instance.id } + } + } + } + } + return null + } + + /** + * POST / — MCP JSON-RPC endpoint (stateless Streamable HTTP transport) + */ + app.post('/', { + schema: { hide: true } + }, async (request, reply) => { + const user = request.session.User + const isExternalClient = !!request.session.viaMCPToken + + // Create a per-request MCP server instance (stateless mode) + const mcpServer = new McpServer({ + name: 'FlowFuse Platform', + version: '1.0.0' + }) + + // Register all platform tools + for (const [toolName, toolDef] of Object.entries(platformAutomations.supportedActions)) { + mcpServer.registerTool( + toolName, + { + description: toolDef.description, + inputSchema: toolDef.inputSchema, + annotations: toolDef.annotations + }, + async (params) => { + try { + const result = await platformAutomations.invokeAction(toolName, { + user, + params, + app, + viaMCPToken: request.session.viaMCPToken, + headers: request.headers, + cookies: request.cookies + }) + return { + content: [{ type: 'text', text: JSON.stringify(result || {}) }] + } + } catch (err) { + app.log.error(`MCP tool ${toolName} error: ${err.message}`) + return { + content: [{ type: 'text', text: JSON.stringify({ error: err.message }) }], + isError: true + } + } + } + ) + } + + // Register flow-building tools if the bridge is configured. + // Always register them so stateless clients see them in tools/list; + // the editor-session check happens at call time in handleFlowToolCall. + const flowBridge = app.flowBuildingBridge + if (flowBridge?.isConfigured) { + let flowTools = [] + try { + flowTools = await flowBridge.getTools() + } catch (err) { + app.log.warn(`MCP: failed to fetch flow tools: ${err.message}`) + } + for (const flowTool of flowTools) { + // External clients must specify which instance to target + const toolSchema = isExternalClient + ? flowTool.inputSchema.extend({ + instanceId: z.string().describe('The ID of the instance to modify (from platform.list-instances)') + }) + : flowTool.inputSchema + + mcpServer.registerTool( + flowTool.name, + { + description: flowTool.description, + inputSchema: toolSchema, + annotations: flowTool.annotations + }, + async (params) => { + return handleFlowToolCall(flowTool, params, user, isExternalClient) + } + ) + } + } + + // Create a stateless transport (no session ID) + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }) + + await mcpServer.connect(transport) + + // Tell Fastify we are handling the response ourselves + reply.hijack() + + // Delegate the raw Node.js request/response to the MCP transport. + // Fastify has already parsed the JSON body; pass it as parsedBody so the + // transport does not attempt to re-read the consumed stream. + await transport.handleRequest(request.raw, reply.raw, request.body) + }) + + /** + * Handle a flow.* tool call. + * + * 1. Verify the user still has an active editor session + * 2. Call the flow building bridge to get validated actions + * 3. Expert path: return as-is (the expert agent handles MQTT dispatch) + * 4. External client path: dispatch actions to the browser via MQTT, wait for results + * + * @param {object} flowTool - Tool definition from FlowBuildingBridge + * @param {object} params - Tool call parameters + * @param {import('sequelize').Model} user - Authenticated user + * @param {boolean} isExternalClient - Whether this is from an external MCP client + * @returns {Promise} MCP tool result + */ + async function handleFlowToolCall (flowTool, params, user, isExternalClient) { + try { + let editorSession, projectId + + if (isExternalClient) { + // External clients must specify which instance to target + const { instanceId, ...toolParams } = params + if (!instanceId) { + return { + isError: true, + content: [{ type: 'text', text: 'instanceId is required for flow tools' }] + } + } + + const editorSessions = app.comms?.editorSessions + const session = editorSessions?.getActiveSession(instanceId) + if (!session) { + return { + isError: true, + content: [{ type: 'text', text: 'the instance editor must be open in your browser to modify flows' }] + } + } + + const userHashid = user.hashid || user.id + if (String(session.userId) !== String(userHashid)) { + return { + isError: true, + content: [{ type: 'text', text: 'no active editor session found for this user on this instance' }] + } + } + + editorSession = session + projectId = instanceId + params = toolParams + } else { + // Expert path: find any active editor session for this user + const editorInfo = await findUserEditorSession(user) + if (!editorInfo) { + return { + isError: true, + content: [{ type: 'text', text: 'the instance editor must be open in your browser to modify flows' }] + } + } + editorSession = editorInfo.session + projectId = editorInfo.projectId + } + + // MCP clients may serialize array/object params as JSON strings + // when the tool schema lacks explicit property types (the Zod + // passthrough schema produces {"properties":{},"type":"object"}). + // Parse them back before forwarding to the flow building server. + const normalizedParams = {} + for (const [key, value] of Object.entries(params)) { + if (typeof value === 'string' && (value.startsWith('[') || value.startsWith('{'))) { + try { normalizedParams[key] = JSON.parse(value) } catch (_) { normalizedParams[key] = value } + } else { + normalizedParams[key] = value + } + } + + // Call the flow building bridge to get validated actions + const bridgeResult = await app.flowBuildingBridge.callTool(flowTool.originalName, normalizedParams) + + if (!isExternalClient) { + // Expert path: return as-is for the expert agent to handle MQTT dispatch + return bridgeResult + } + + // External client path: check if the result contains actions that + // need to be dispatched to the browser + const resultContent = bridgeResult?.content + let resultData = null + if (Array.isArray(resultContent)) { + const textItem = resultContent.find(c => c.type === 'text') + if (textItem) { + try { resultData = JSON.parse(textItem.text) } catch (_) {} + } + } + + // If the result has actions, dispatch each one to the browser via MQTT + if (resultData?.actions && Array.isArray(resultData.actions)) { + const mqttDispatch = app.mqttDispatch + const results = [] + + for (const action of resultData.actions) { + const actionName = action.action || action.name || flowTool.originalName + const actionParams = action.params || action + // Convert action name format: 'automation/add-nodes' -> 'automation:add-nodes' + const inflightType = actionName.replace(/\//g, ':') + + try { + const actionResult = await mqttDispatch.dispatchAndWait( + editorSession, + projectId, + inflightType, + actionParams + ) + results.push({ action: actionName, success: true, result: actionResult }) + } catch (err) { + results.push({ action: actionName, success: false, error: err.message }) + } + } + + return { + content: [{ type: 'text', text: JSON.stringify({ actions: results }) }] + } + } + + // No actions to dispatch — return the bridge result directly + return bridgeResult + } catch (err) { + app.log.error(`MCP flow tool ${flowTool.name} error: ${err.message}`) + return { + content: [{ type: 'text', text: JSON.stringify({ error: err.message }) }], + isError: true + } + } + } + + /** + * GET / — not supported for stateless MCP; return 405 + */ + app.get('/', { schema: { hide: true } }, async (request, reply) => { + reply.code(405).send({ error: 'Method not allowed. Use POST for MCP requests.' }) + }) + + /** + * DELETE / — not supported for stateless MCP; return 405 + */ + app.delete('/', { schema: { hide: true } }, async (request, reply) => { + reply.code(405).send({ error: 'Method not allowed. Use POST for MCP requests.' }) + }) +} diff --git a/forge/routes/api/user.js b/forge/routes/api/user.js index dd5618b148..23db2a2d45 100644 --- a/forge/routes/api/user.js +++ b/forge/routes/api/user.js @@ -386,6 +386,175 @@ module.exports = async function (app) { } }) + /** + * Get MCP Tokens + * /api/v1/user/mcp-tokens + */ + app.get('/mcp-tokens', { + schema: { + summary: 'List users MCP Tokens', + tags: ['Tokens'], + response: { + 200: { + type: 'object', + properties: { + count: { type: 'number' }, + tokens: { $ref: 'MCPTokenSummaryList' } + } + }, + '4xx': { + $ref: 'APIError' + } + } + } + }, async (request, reply) => { + try { + const tokens = await app.db.models.AccessToken.getMCPTokens(request.session.User) + reply.send({ + tokens: app.db.views.AccessToken.mcpTokenSummaryList(tokens), + count: tokens.length + }) + } catch (err) { + const resp = { code: 'unexpected_error', error: err.toString() } + reply.code(400).send(resp) + } + }) + + /** + * Create MCP Token + * /api/v1/user/mcp-tokens + */ + app.post('/mcp-tokens', { + config: { + rateLimit: app.config.rate_limits ? { max: 5, timeWindow: 30000 } : false + }, + schema: { + summary: 'Create user MCP Token', + tags: ['Tokens'], + body: { + type: 'object', + properties: { + expiresAt: { type: 'number' }, + name: { type: 'string' } + } + }, + response: { + 200: { + $ref: 'MCPToken' + }, + '4xx': { + $ref: 'APIError' + } + } + } + }, async (request, reply) => { + const updates = new app.auditLog.formatters.UpdatesCollection() + try { + const body = request.body + const token = await app.db.controllers.AccessToken.createMCPToken(request.session.User, ['mcp:platform'], body.expiresAt, body.name) + updates.push('id', token.id) + updates.push('name', token.name) + if (token.expiresAt) { + updates.push('expiresAt', token.expiresAt) + } + await app.auditLog.User.user.pat.created(request.session.User, null, updates) + reply.send(token) + } catch (err) { + const resp = { code: 'unexpected_error', error: err.toString() } + reply.code(400).send(resp) + } + }) + + /** + * Delete MCP Token + * /api/v1/user/mcp-tokens/:id + */ + app.delete('/mcp-tokens/:id', { + schema: { + summary: 'Delete user MCP Token', + tags: ['Tokens'], + params: { + type: 'object', + properties: { + id: { type: 'string' } + } + }, + response: { + 204: { + type: 'null', + description: 'empty response' + }, + '4xx': { + $ref: 'APIError' + } + } + } + }, async (request, reply) => { + try { + const token = await app.db.models.AccessToken.byId(request.params.id, 'user', request.session.User.id) + if (token) { + const updates = new app.auditLog.formatters.UpdatesCollection() + updates.push('id', request.params.id) + await app.auditLog.User.user.pat.deleted(request.session.User, null, updates) + await token.destroy() + reply.code(204).send() + return + } + reply.code(404).send({ code: 'not_found', error: 'Not Found' }) + } catch (err) { + const resp = { code: 'unexpected_error', error: err.toString() } + reply.code(400).send(resp) + } + }) + + /** + * Update MCP Token + * /api/v1/user/mcp-tokens/:id + */ + app.put('/mcp-tokens/:id', { + schema: { + summary: 'Update users MCP Token', + tags: ['Tokens'], + params: { + type: 'object', + properties: { + id: { type: 'string' } + } + }, + body: { + type: 'object', + properties: { + expiresAt: { type: 'number' } + } + }, + response: { + 200: { + $ref: 'MCPTokenSummary' + }, + '4xx': { + $ref: 'APIError' + } + } + } + }, async (request, reply) => { + const updates = new app.auditLog.formatters.UpdatesCollection() + try { + const oldToken = await app.db.models.AccessToken.byId(request.params.id, 'user', request.session.User.id) + if (oldToken) { + const body = request.body + const newToken = await app.db.controllers.AccessToken.updateMCPToken(request.session.User, request.params.id, ['mcp:platform'], body.expiresAt) + updates.pushDifferences(oldToken, newToken) + await app.auditLog.User.user.pat.updated(request.session.User, null, updates) + reply.send(app.db.views.AccessToken.mcpTokenSummary(newToken)) + return + } + reply.code(404).send({ code: 'not_found', error: 'Not Found' }) + } catch (err) { + const resp = { code: 'unexpected_error', error: err.toString() } + reply.code(400).send(resp) + } + }) + /** * Initialize expert chat */ diff --git a/forge/routes/auth/oauth.js b/forge/routes/auth/oauth.js index 19e3eb5d4c..04d6713599 100644 --- a/forge/routes/auth/oauth.js +++ b/forge/routes/auth/oauth.js @@ -88,7 +88,15 @@ module.exports = async function (app) { } catch (err) { return badRequest(reply, 'invalid_request', 'Invalid redirect_uri') } - if (client_id !== 'ff-plugin') { + if (client_id === 'mcp-agent') { + const isLocalhost = /^(localhost|127\.0\.0\.1)$/.test(redirectURI.hostname) + if (!isLocalhost) { + return badRequest(reply, 'invalid_request', 'Invalid redirect_uri: only localhost callbacks are supported for MCP agents') + } + if (scope !== 'mcp:platform') { + return redirectInvalidRequest(reply, redirect_uri, 'invalid_request', "Invalid scope '" + scope + "'. Only 'mcp:platform' is supported for MCP agents", state) + } + } else if (client_id !== 'ff-plugin') { // Check client_id is valid. Note - no client_secret provided at this point const authClient = await app.db.controllers.AuthClient.getAuthClient(client_id) if (!authClient) { @@ -159,6 +167,10 @@ module.exports = async function (app) { reply.redirect(`${app.config.base_url}/account/request/${requestId}/editor`) return } + if (client_id === 'mcp-agent') { + reply.redirect(`${app.config.base_url}/account/request/${requestId}/mcp`) + return + } // Redirect to login page with requestId in url - to bounce to an approve page reply.redirect(`${app.config.base_url}/account/request/${requestId}`) }) @@ -176,7 +188,9 @@ module.exports = async function (app) { if (request.sid) { request.session = await app.db.controllers.Session.getOrExpire(request.sid) if (request.session) { - if (requestObject.client_id === 'ff-plugin') { + if (requestObject.client_id === 'mcp-agent') { + // MCP agent — no ownership checks needed + } else if (requestObject.client_id === 'ff-plugin') { // This is the FlowFuse Node-RED plugin. } else { const authClient = await app.db.controllers.AuthClient.getAuthClient(requestObject.client_id) @@ -342,6 +356,24 @@ module.exports = async function (app) { return } + if (client_id === 'mcp-agent') { + const tokenName = `OAuth MCP Agent - ${new Date().toISOString()}` + const accessToken = await app.db.controllers.AccessToken.createMCPToken( + requestObject.userId, + ['mcp:platform'], + new Date(Date.now() + (1000 * 60 * 60 * 24 * 90)), + tokenName + ) + reply.send({ + access_token: accessToken.token, + token_type: 'bearer', + expires_in: Math.floor((new Date(accessToken.expiresAt).getTime() - Date.now()) / 1000), + scope: 'mcp:platform', + state: requestObject.state + }) + return + } + if (client_id !== 'ff-plugin') { const authClient = await app.db.controllers.AuthClient.getAuthClient(client_id, client_secret) if (!authClient) { @@ -444,7 +476,7 @@ module.exports = async function (app) { badRequest(reply, 'invalid_request', 'Invalid refresh_token') return } - if (client_id !== 'ff-plugin') { + if (client_id !== 'ff-plugin' && client_id !== 'mcp-agent') { const authClient = await app.db.controllers.AuthClient.getAuthClient(client_id, client_secret) if (!authClient) { return badRequest(reply, 'invalid_request', 'Invalid client_id') diff --git a/forge/routes/index.js b/forge/routes/index.js index 4f9fb5a982..0d5907e2fb 100644 --- a/forge/routes/index.js +++ b/forge/routes/index.js @@ -77,6 +77,7 @@ module.exports = fp(async function (app, opts) { await app.register(require('@fastify/websocket')) await app.register(require('./auth'), { logLevel: app.config.logging.http }) + await app.register(require('./wellKnown'), { logLevel: app.config.logging.http }) await app.register(require('./api'), { prefix: '/api/v1', logLevel: app.config.logging.http }) await app.register(require('./ui'), { logLevel: app.config.logging.http }) await app.register(require('./setup'), { logLevel: app.config.logging.http }) diff --git a/forge/routes/wellKnown.js b/forge/routes/wellKnown.js new file mode 100644 index 0000000000..43a57f1cb6 --- /dev/null +++ b/forge/routes/wellKnown.js @@ -0,0 +1,105 @@ +'use strict' + +/** + * Well-known endpoint for MCP server discovery. + * + * Exposes `GET /.well-known/mcp-configuration` so that MCP clients can + * discover the FlowFuse MCP server URL and OAuth parameters automatically. + * + * This is registered at the root level (not behind EE gating) so that + * discovery works regardless of license. The MCP server endpoint it points + * to (`/api/v1/mcp`) is EE-only, so unauthenticated or unlicensed requests + * will receive a 401/404 there. + */ +const fp = require('fastify-plugin') + +module.exports = fp(async function (app) { + const handler = async (request, reply) => { + const baseUrl = app.config.base_url + reply.send({ + name: 'FlowFuse', + description: 'Manage FlowFuse instances, applications, and Node-RED flows via MCP', + url: `${baseUrl}/api/v1/mcp`, + auth: { + type: 'oauth2', + authorization_url: `${baseUrl}/account/authorize`, + token_url: `${baseUrl}/account/token`, + client_id: 'mcp-agent', + scope: 'mcp:platform' + } + }) + } + + const routeOptions = { + config: { + allowAnonymous: true, + rateLimit: false + }, + schema: { + tags: ['MCP', 'X-HIDDEN'], + response: { + 200: { + type: 'object', + properties: { + name: { type: 'string' }, + description: { type: 'string' }, + url: { type: 'string' }, + auth: { + type: 'object', + properties: { + type: { type: 'string' }, + authorization_url: { type: 'string' }, + token_url: { type: 'string' }, + client_id: { type: 'string' }, + scope: { type: 'string' } + } + } + } + } + } + } + } + + // Root-level discovery endpoint (spec compliance) + app.get('/.well-known/mcp-configuration', routeOptions, handler) + + // Also available under the MCP prefix (convenience) + app.get('/api/v1/mcp/.well-known/mcp-configuration', routeOptions, handler) + + // RFC 9728 Protected Resource Metadata — lets MCP clients discover the + // authorization server for the MCP endpoint. + const protectedResourceOptions = { + config: { allowAnonymous: true, rateLimit: false }, + schema: { tags: ['MCP', 'X-HIDDEN'] } + } + app.get('/.well-known/oauth-protected-resource', protectedResourceOptions, async (request, reply) => { + const baseUrl = app.config.base_url + reply.send({ + resource: `${baseUrl}/api/v1/mcp`, + authorization_servers: [baseUrl], + scopes_supported: ['mcp:platform'], + bearer_methods_supported: ['header'] + }) + }) + + // RFC 8414 Authorization Server Metadata — provides authorization and + // token endpoint URLs for OAuth 2.0 PKCE flows (used by Claude Code, + // Claude Desktop, Cursor, etc.). + const authServerOptions = { + config: { allowAnonymous: true, rateLimit: false }, + schema: { tags: ['MCP', 'X-HIDDEN'] } + } + app.get('/.well-known/oauth-authorization-server', authServerOptions, async (request, reply) => { + const baseUrl = app.config.base_url + reply.send({ + issuer: baseUrl, + authorization_endpoint: `${baseUrl}/account/authorize`, + token_endpoint: `${baseUrl}/account/token`, + response_types_supported: ['code'], + grant_types_supported: ['authorization_code'], + code_challenge_methods_supported: ['S256'], + token_endpoint_auth_methods_supported: ['none'], + scopes_supported: ['mcp:platform'] + }) + }) +}, { name: 'app.routes.wellKnown' }) diff --git a/frontend/src/api/expert.js b/frontend/src/api/expert.js index 67ef3ce793..40c5e96765 100644 --- a/frontend/src/api/expert.js +++ b/frontend/src/api/expert.js @@ -48,7 +48,12 @@ const getCapabilities = async (payload) => { }) } +const callPlatformTool = async (tool, args, context = {}) => { + return client.post('/api/v1/expert/platform-tool', { tool, arguments: args, context }) +} + export default { chat, - getCapabilities + getCapabilities, + callPlatformTool } diff --git a/frontend/src/api/user.js b/frontend/src/api/user.js index 3f1c21c37d..6c5f5bcdd9 100644 --- a/frontend/src/api/user.js +++ b/frontend/src/api/user.js @@ -230,6 +230,43 @@ const updatePersonalAccessToken = async (id, scope, expiresAt) => { return client.put('/api/v1/user/tokens/' + id, { scope, expiresAt }) } +/** + * Get a User's MCP Access Tokens + * See [routes/api/user.js](../../../forge/routes/api/user.js) + */ +const getMCPTokens = async () => { + return client.get('/api/v1/user/mcp-tokens').then(res => res.data) +} + +/** + * Create new User MCP Access Token + * See [routes/api/user.js](../../../forge/routes/api/user.js) + * @param {string} name + * @param {number} expiresAt + */ +const createMCPToken = async (name, expiresAt) => { + return client.post('/api/v1/user/mcp-tokens', { name, expiresAt }).then(res => res.data) +} + +/** + * Update User MCP Access Token + * See [routes/api/user.js](../../../forge/routes/api/user.js) + * @param {string} id + * @param {number} expiresAt + */ +const updateMCPToken = async (id, expiresAt) => { + return client.put('/api/v1/user/mcp-tokens/' + id, { expiresAt }).then(res => res.data) +} + +/** + * Delete User MCP Access Token + * See [routes/api/user.js](../../../forge/routes/api/user.js) + * @param {string} id + */ +const deleteMCPToken = async (id) => { + return client.delete('/api/v1/user/mcp-tokens/' + id).then(res => {}) +} + const enableMFA = async () => { return client.put('/api/v1/user/mfa', {}).then(res => res.data) } @@ -268,6 +305,10 @@ export default { createPersonalAccessToken, deletePersonalAccessToken, updatePersonalAccessToken, + getMCPTokens, + createMCPToken, + updateMCPToken, + deleteMCPToken, enableMFA, verifyMFA, disableMFA, diff --git a/frontend/src/components/expert/Expert.vue b/frontend/src/components/expert/Expert.vue index 451de4d4db..984f6ce4f5 100644 --- a/frontend/src/components/expert/Expert.vue +++ b/frontend/src/components/expert/Expert.vue @@ -33,7 +33,6 @@ import UpdateBanner from './components/UpdateBanner.vue' import { useAccountSettingsStore } from '@/stores/account-settings.js' import { useProductAssistantStore } from '@/stores/product-assistant.js' -import { useProductExpertInsightsAgentStore } from '@/stores/product-expert-insights-agent.js' import { useProductExpertStore } from '@/stores/product-expert.js' import { useUxDrawersStore } from '@/stores/ux-drawers.js' @@ -106,9 +105,7 @@ export default { agentMode: { immediate: true, async handler () { - if (this.isInsightsAgent) { - await this.getCapabilities() - } + await this.fetchCapabilities() this.addWelcomeMessageIfNeeded() } }, @@ -149,9 +146,9 @@ export default { 'setAbortController', 'resetSessionTimer', 'addWelcomeMessageIfNeeded', + 'fetchCapabilities', 'stopInflightChat' ]), - ...mapActions(useProductExpertInsightsAgentStore, ['getCapabilities']), ...mapActions(useProductAssistantStore, ['reset']), handleStopGeneration () { if (this.abortController) { diff --git a/frontend/src/components/expert/components/ExpertChatInput.vue b/frontend/src/components/expert/components/ExpertChatInput.vue index df62403c08..5d0f043818 100644 --- a/frontend/src/components/expert/components/ExpertChatInput.vue +++ b/frontend/src/components/expert/components/ExpertChatInput.vue @@ -16,6 +16,7 @@ Start over
+
@@ -67,6 +68,7 @@ import { mapActions, mapState } from 'pinia' import ResizeBar from '../../ResizeBar.vue' import CapabilitiesSelector from './CapabilitiesSelector.vue' +import ToolPermissionsSelector from './ToolPermissionsSelector.vue' import ContextSelector from './context-selection/index.vue' import { useResizingHelper } from '@/composables/ResizingHelper.js' @@ -80,7 +82,8 @@ export default { components: { CapabilitiesSelector, ContextSelector, - ResizeBar + ResizeBar, + ToolPermissionsSelector }, inject: { togglePinWithWidth: { diff --git a/frontend/src/components/expert/components/ToolPermissionsSelector.vue b/frontend/src/components/expert/components/ToolPermissionsSelector.vue new file mode 100644 index 0000000000..6a97575baf --- /dev/null +++ b/frontend/src/components/expert/components/ToolPermissionsSelector.vue @@ -0,0 +1,328 @@ + + + + + diff --git a/frontend/src/components/expert/components/messages/AiMessage.vue b/frontend/src/components/expert/components/messages/AiMessage.vue index 0fc8817324..7e97bfa8d2 100644 --- a/frontend/src/components/expert/components/messages/AiMessage.vue +++ b/frontend/src/components/expert/components/messages/AiMessage.vue @@ -67,7 +67,7 @@ export default { toolCalls () { // todo we need to reconsider how we serve data to the tool calls component. This is ok for now as it // only impacts the immediate child component (was hacked out of the expert store) - const mcpItems = this.answer.filter(answer => ['mcp_tool', 'mcp_resource', 'mcp_resource_template', 'mcp_prompt'].includes(answer.kind)) + const mcpItems = this.answer.filter(answer => ['mcp_tool', 'mcp_resource', 'mcp_resource_template', 'mcp_prompt', 'platform_tool'].includes(answer.kind)) // Handle MCP calls if present - includes tools, resources, and prompts if (mcpItems.length > 0) { @@ -98,7 +98,9 @@ export default { return null }, filteredAnswers () { - return this.answer.filter(answer => !['mcp_tool', 'mcp_resource', 'mcp_resource_template', 'mcp_prompt'].includes(answer.kind)) + // Keep all answer kinds except raw MCP call records (those are rendered by ToolCalls). + // 'mcp-tool-result' is intentionally included — it carries structured UI to render inline. + return this.answer.filter(answer => !['mcp_tool', 'mcp_resource', 'mcp_resource_template', 'mcp_prompt', 'platform_tool'].includes(answer.kind)) } }, async mounted () { diff --git a/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue b/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue index ce26544b8c..e68cf5bf15 100644 --- a/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue +++ b/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue @@ -66,6 +66,13 @@ :should-stream="shouldStream" @streaming-complete="onComponentComplete('suggestions-list')" /> + + @@ -78,6 +85,7 @@ import useTimerHelper from '../../../../../composables/TimerHelper.js' import AnswerBadge from './AnswerBadge.vue' import GuideHeader from './GuideHeader.vue' import MessageBubble from './MessageBubble.vue' +import McpToolResult from './mcp/McpToolResult.vue' import FlowsList from './resources/FlowsList.vue' import GuideStepsList from './resources/GuideStepsList.vue' import IssuesList from './resources/IssuesList.vue' @@ -101,7 +109,8 @@ export default { GuideStepsList, MessageBubble, GuideHeader, - IssuesList + IssuesList, + McpToolResult }, props: { answer: { @@ -153,7 +162,14 @@ export default { return this.answer.content && this.answer.content.length > 0 }, isChatAnswer () { - return !Object.hasOwnProperty.call(this.answer, 'kind') || this.answer.kind === 'chat' + // 'mcp-tool-result' is treated as chat-like (no badge) since it renders its own UI + return !Object.hasOwnProperty.call(this.answer, 'kind') || this.answer.kind === 'chat' || this.answer.kind === 'mcp-tool-result' + }, + isMcpToolResult () { + return this.answer.kind === 'mcp-tool-result' + }, + hasMcpUI () { + return this.isMcpToolResult && !!this.answer.ui }, isEditorContext () { // In editor context, the route name includes 'editor' @@ -215,6 +231,12 @@ export default { if (this.componentStreamingOrder.indexOf(key) === 0) return true return this.streamedComponents.length >= this.componentStreamingOrder.indexOf(key) }, + shouldShowMcpToolResult () { + // Rendered after markdown content (if any) finishes streaming + if (!this.hasMcpUI) return false + const priorComponents = this.componentStreamingOrder.filter(k => k !== 'mcp-tool-result') + return this.streamedComponents.length >= priorComponents.length + }, shouldStream () { return !this.answer._streamed } @@ -248,6 +270,11 @@ export default { if (this.isEditorContext) { this.$refs.messageBubble.$el.addEventListener('click', this.handleClick) } + // Items with nothing to stream (e.g. mcp-tool-result with no content) must + // immediately signal completion so the streaming list can advance. + if (this.componentStreamingOrder.length === 0) { + this.$nextTick(() => this.$emit('streaming-complete')) + } }, methods: { ...mapActions(useProductExpertStore, ['updateAnswerStreamedState']), @@ -269,6 +296,29 @@ export default { this.streamedComponents.push(key) }, + /** + * Handle actions emitted by MCP UI components (e.g. confirmation, selection, navigation). + * Navigation actions (result-card links) use Vue Router; other actions are forwarded to the + * expert store for transmission back to the agent. + */ + handleMcpAction (event) { + if (event && event.navigation) { + this.$router.push(event.navigation) + } + if (event && event.actionId) { + const match = /^(approve|deny|allow-always):(.+)$/.exec(event.actionId) + if (match) { + const expertStore = useProductExpertStore() + const action = match[1] + const id = match[2] + if (action === 'allow-always') { + expertStore.resolveToolConfirmation(id, true, true) + } else { + expertStore.resolveToolConfirmation(id, action === 'approve') + } + } + } + }, handleClick (e) { const target = e.target // - Must be in the immersive editor diff --git a/frontend/src/components/expert/components/messages/components/mcp/McpConfirmation.vue b/frontend/src/components/expert/components/messages/components/mcp/McpConfirmation.vue new file mode 100644 index 0000000000..815060b0b0 --- /dev/null +++ b/frontend/src/components/expert/components/messages/components/mcp/McpConfirmation.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/frontend/src/components/expert/components/messages/components/mcp/McpProgress.vue b/frontend/src/components/expert/components/messages/components/mcp/McpProgress.vue new file mode 100644 index 0000000000..c03cbd12ac --- /dev/null +++ b/frontend/src/components/expert/components/messages/components/mcp/McpProgress.vue @@ -0,0 +1,117 @@ + + + + + diff --git a/frontend/src/components/expert/components/messages/components/mcp/McpResultCard.vue b/frontend/src/components/expert/components/messages/components/mcp/McpResultCard.vue new file mode 100644 index 0000000000..bfbd0498d7 --- /dev/null +++ b/frontend/src/components/expert/components/messages/components/mcp/McpResultCard.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/frontend/src/components/expert/components/messages/components/mcp/McpSelection.vue b/frontend/src/components/expert/components/messages/components/mcp/McpSelection.vue new file mode 100644 index 0000000000..78291cca0e --- /dev/null +++ b/frontend/src/components/expert/components/messages/components/mcp/McpSelection.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/frontend/src/components/expert/components/messages/components/mcp/McpStatusBadge.vue b/frontend/src/components/expert/components/messages/components/mcp/McpStatusBadge.vue new file mode 100644 index 0000000000..68a2b0e627 --- /dev/null +++ b/frontend/src/components/expert/components/messages/components/mcp/McpStatusBadge.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/frontend/src/components/expert/components/messages/components/mcp/McpTextInput.vue b/frontend/src/components/expert/components/messages/components/mcp/McpTextInput.vue new file mode 100644 index 0000000000..c8be03bb7c --- /dev/null +++ b/frontend/src/components/expert/components/messages/components/mcp/McpTextInput.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/frontend/src/components/expert/components/messages/components/mcp/McpToolResult.vue b/frontend/src/components/expert/components/messages/components/mcp/McpToolResult.vue new file mode 100644 index 0000000000..9cdf61beba --- /dev/null +++ b/frontend/src/components/expert/components/messages/components/mcp/McpToolResult.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/frontend/src/composables/FeatureChecks.ts b/frontend/src/composables/FeatureChecks.ts index 1487d07f82..86ded2bf51 100644 --- a/frontend/src/composables/FeatureChecks.ts +++ b/frontend/src/composables/FeatureChecks.ts @@ -110,6 +110,7 @@ export const FEATURE_CONFIGS: FeatureConfig[] = [ { output: 'isAiFeatureEnabled', platformKey: 'ai', teamKey: 'ai' }, { output: 'isExpertAssistantFeatureEnabled', platformKey: 'expertAssistant', teamKey: 'expertAssistant', optOut: true, dependsOnPlatform: 'ai', dependsOnTeam: 'ai', dependsOnTeamOptOut: true }, { output: 'isExpertInsightsFeatureEnabled', platformKey: 'expertInsights', teamKey: 'expertInsights', optOut: true, dependsOnPlatform: 'ai', dependsOnTeam: 'ai', dependsOnTeamOptOut: true }, + { output: 'isExpertPlatformAutomationFeatureEnabled', platformKey: 'expertPlatformAutomation', teamKey: 'expertPlatformAutomation', optOut: true, dependsOnPlatform: 'ai', dependsOnTeam: 'ai', dependsOnTeamOptOut: true }, { output: 'isGeneratedSnapshotDescriptionFeatureEnabled', platformKey: 'generatedSnapshotDescription', diff --git a/frontend/src/pages/account/AccessRequestMCP.vue b/frontend/src/pages/account/AccessRequestMCP.vue new file mode 100644 index 0000000000..73737d1e12 --- /dev/null +++ b/frontend/src/pages/account/AccessRequestMCP.vue @@ -0,0 +1,56 @@ + + + diff --git a/frontend/src/pages/account/Security.vue b/frontend/src/pages/account/Security.vue index a16304858a..24e6a94778 100644 --- a/frontend/src/pages/account/Security.vue +++ b/frontend/src/pages/account/Security.vue @@ -25,7 +25,8 @@ export default { sideNavigation () { const navigation = [ { name: 'Password', path: '/account/security/password' }, - { name: 'Tokens', path: '/account/security/tokens' } + { name: 'Tokens', path: '/account/security/tokens' }, + { name: 'MCP Tokens', path: '/account/security/mcp-tokens' } // { name: "Sessions", path: "/account/security/sessions" } ] if (this.features.mfa) { diff --git a/frontend/src/pages/account/Security/MCPTokens.vue b/frontend/src/pages/account/Security/MCPTokens.vue new file mode 100644 index 0000000000..db1027233c --- /dev/null +++ b/frontend/src/pages/account/Security/MCPTokens.vue @@ -0,0 +1,38 @@ + + + diff --git a/frontend/src/pages/account/Security/Tokens.vue b/frontend/src/pages/account/Security/Tokens.vue index 560f50f451..a9fbdcf989 100644 --- a/frontend/src/pages/account/Security/Tokens.vue +++ b/frontend/src/pages/account/Security/Tokens.vue @@ -1,93 +1,37 @@ diff --git a/frontend/src/pages/account/Security/dialogs/TokenDialog.vue b/frontend/src/pages/account/Security/dialogs/TokenDialog.vue index 7497db9cdb..d71a3217c2 100644 --- a/frontend/src/pages/account/Security/dialogs/TokenDialog.vue +++ b/frontend/src/pages/account/Security/dialogs/TokenDialog.vue @@ -16,8 +16,6 @@