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