From acc220dae7b3071c342763b8cf01d2e13d88f50c Mon Sep 17 00:00:00 2001 From: cstns Date: Tue, 29 Sep 2026 13:41:49 +0300 Subject: [PATCH 1/3] Add MCP tools to delete an instance and roll back a hosted instance --- forge/ee/lib/mcp/tools/instances.js | 22 ++++++++++ .../lib/mcp/tools/shared-instances-devices.js | 23 +++++++++++ .../forge/ee/lib/mcp/tools/instances_spec.js | 28 +++++++++++++ .../tools/shared-instances-devices_spec.js | 41 +++++++++++++++++++ 4 files changed, 114 insertions(+) diff --git a/forge/ee/lib/mcp/tools/instances.js b/forge/ee/lib/mcp/tools/instances.js index ab07806c12..013b4b4f18 100644 --- a/forge/ee/lib/mcp/tools/instances.js +++ b/forge/ee/lib/mcp/tools/instances.js @@ -594,6 +594,28 @@ module.exports = [ }) return response } + }, + { + name: 'platform_rollback_hosted_instance', + title: 'Roll Back Hosted Instance', + description: `FlowFuse platform automation tool: + Rolls a hosted instance back to one of its own snapshots. This OVERWRITES the instance's current flows, credentials, settings, environment variables and installed node modules with the ones in the snapshot. Environment variables are replaced, not merged, so any added since the snapshot are lost. + The current state is not saved automatically. If it might be needed again, take a snapshot first with platform_create_instance_snapshot. + If the instance is running, its flows are restarted with the rolled back version before this replies. If it is stopped or suspended, the rolled back version is stored and used the next time it starts. + The snapshot must belong to this hosted instance: a snapshot from another instance or a device, or one that does not exist, is rejected with a 400 "invalid_snapshot". Find snapshot ids with platform_list_instance_snapshots. + Confirm with the user before calling this. + Team members and owners can roll back. Replies { status: "okay" } on success.`, + // destructiveHint: this replaces what the instance is running rather than adding to it. + // idempotentHint: rolling back to the same snapshot again restarts the flows each time. + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, + inputSchema: { + instanceId: hostedInstanceId.describe('The id (UUID) of the hosted instance to roll back'), + snapshotId: z.string().describe('The hashid of the snapshot to roll back to. Must be a snapshot of this hosted instance') + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'POST', url: `/api/v1/projects/${args.instanceId}/actions/rollback`, payload: { snapshot: args.snapshotId } }) + return response + } } ] diff --git a/forge/ee/lib/mcp/tools/shared-instances-devices.js b/forge/ee/lib/mcp/tools/shared-instances-devices.js index 77374ffc4b..6974e1e691 100644 --- a/forge/ee/lib/mcp/tools/shared-instances-devices.js +++ b/forge/ee/lib/mcp/tools/shared-instances-devices.js @@ -151,5 +151,28 @@ module.exports = [ const response = await inject({ method: 'PUT', url: `/api/v1/${base}/${args.instanceId}/httpTokens/${args.tokenId}`, payload }) return response } + }, + { + name: 'platform_delete_instance', + title: 'Delete Instance', + description: `FlowFuse platform automation tool: + Permanently deletes an instance, either a hosted instance or a remote instance (device). This cannot be undone, and there is no way to get the instance back. + Deleting a hosted instance removes its container, flows, credentials, settings and files, and ALL of its snapshots. Remote instances (devices) assigned to it are not deleted, but they are unassigned from it, lose their target snapshot and stop running Node-RED (unless in developer mode) until they are given a new one. + Deleting a remote instance (device) revokes its credentials, so the physical device can no longer connect to the platform or receive updates. Its snapshots stay behind but can no longer be opened. + In both cases, a pipeline stage that deployed to the instance is not deleted, it is left with no deploy target, so edit or remove it afterwards (find it with platform_list_pipelines). + Before calling this, confirm with the user and tell them what goes with it. For a hosted instance, use platform_list_instance_snapshots and platform_list_remote_instances to list the snapshots and assigned devices. If the snapshots might be wanted later, export them first with platform_export_snapshot. + Only team owners can delete instances. Replies { status: "okay" } on success; an instance that does not exist, or that the caller cannot see, returns 404.`, + // destructiveHint: the instance and everything stored with it are gone for good. + // idempotentHint: a repeat call has no further effect, it just answers 404. + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, + inputSchema: { + instanceId: z.string().describe('The ID of the instance to delete (hosted instance UUID, or remote instance/device hashid)'), + instanceType: z.enum(['hosted', 'remote']).describe('Whether instanceId refers to a hosted instance ("hosted") or a remote instance/device ("remote")') + }, + handler: async (args, { inject }) => { + const base = args.instanceType === 'remote' ? 'devices' : 'projects' + const response = await inject({ method: 'DELETE', url: `/api/v1/${base}/${args.instanceId}` }) + return response + } } ] diff --git a/test/unit/forge/ee/lib/mcp/tools/instances_spec.js b/test/unit/forge/ee/lib/mcp/tools/instances_spec.js index 3d556e01d1..76ab5a8af6 100644 --- a/test/unit/forge/ee/lib/mcp/tools/instances_spec.js +++ b/test/unit/forge/ee/lib/mcp/tools/instances_spec.js @@ -924,4 +924,32 @@ describe('MCP Instances Tools', function () { response.should.equal(errorResponse) }) }) + + describe('platform_rollback_hosted_instance', function () { + const tool = getTool('platform_rollback_hosted_instance') + const instanceId = '11111111-1111-1111-1111-111111111111' + + it('is annotated as destructive so it is served as a delete tool', function () { + tool.annotations.should.have.property('readOnlyHint', false) + tool.annotations.should.have.property('destructiveHint', true) + }) + + it('posts the snapshot to the instance rollback action', async function () { + const routeResponse = { statusCode: 200, json: () => ({ status: 'okay' }) } + inject.withArgs({ method: 'POST', url: `/api/v1/projects/${instanceId}/actions/rollback`, payload: { snapshot: 'snapshot1' } }).resolves(routeResponse) + + const response = await tool.handler({ instanceId, snapshotId: 'snapshot1' }, { inject }) + + inject.calledOnce.should.be.true() + response.should.equal(routeResponse) + }) + + it('passes through the 400 for a snapshot from another instance', async function () { + const errorResponse = { statusCode: 400, json: () => ({ code: 'invalid_snapshot' }) } + inject.resolves(errorResponse) + + const response = await tool.handler({ instanceId, snapshotId: 'snapshot1' }, { inject }) + response.should.equal(errorResponse) + }) + }) }) diff --git a/test/unit/forge/ee/lib/mcp/tools/shared-instances-devices_spec.js b/test/unit/forge/ee/lib/mcp/tools/shared-instances-devices_spec.js index 0f3d8b839e..0152d3c506 100644 --- a/test/unit/forge/ee/lib/mcp/tools/shared-instances-devices_spec.js +++ b/test/unit/forge/ee/lib/mcp/tools/shared-instances-devices_spec.js @@ -212,4 +212,45 @@ describe('MCP Shared Instance/Device Tools', function () { response.should.equal(errorResponse) }) }) + + describe('platform_delete_instance', function () { + const tool = getTool('platform_delete_instance') + + it('is annotated as destructive so it is served as a delete tool', function () { + tool.annotations.should.have.property('readOnlyHint', false) + tool.annotations.should.have.property('destructiveHint', true) + }) + + it('deletes a hosted instance through the projects route', async function () { + const routeResponse = { statusCode: 200, json: () => ({ status: 'okay' }) } + inject.withArgs({ method: 'DELETE', url: '/api/v1/projects/instance1' }).resolves(routeResponse) + + const response = await tool.handler({ instanceId: 'instance1', instanceType: 'hosted' }, { inject }) + + inject.calledOnce.should.be.true() + response.should.equal(routeResponse) + }) + + it('deletes a remote instance through the devices route', async function () { + const routeResponse = { statusCode: 200, json: () => ({ status: 'okay' }) } + inject.withArgs({ method: 'DELETE', url: '/api/v1/devices/device1' }).resolves(routeResponse) + + const response = await tool.handler({ instanceId: 'device1', instanceType: 'remote' }, { inject }) + + inject.calledOnce.should.be.true() + response.should.equal(routeResponse) + }) + + it('rejects an unknown instanceType', function () { + tool.inputSchema.instanceType.safeParse('other').success.should.be.false() + }) + + it('passes through an error response', async function () { + const errorResponse = { statusCode: 403, json: () => ({ code: 'unauthorized' }) } + inject.resolves(errorResponse) + + const response = await tool.handler({ instanceId: 'instance1', instanceType: 'hosted' }, { inject }) + response.should.equal(errorResponse) + }) + }) }) From d6bbaa05141460d89d2f3258ca0dba21cd6fc69f Mon Sep 17 00:00:00 2001 From: cstns Date: Wed, 30 Sep 2026 15:43:22 +0300 Subject: [PATCH 2/3] Only accept a plain instance id when deleting an instance, and correct the rollback description The instance id went straight into the inject URL, which resolves dot segments, so an id like ../applications/ deleted an application. The handler now takes only a hosted instance UUID or a device hashid matching instanceType. Rollback replies before the triggered restart finishes, and skips settings the template locks. Devices of a deleted hosted instance are left without an application. --- forge/ee/lib/mcp/tools/instances.js | 4 +-- .../lib/mcp/tools/shared-instances-devices.js | 12 +++++++-- .../tools/shared-instances-devices_spec.js | 25 ++++++++++++++++--- 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/forge/ee/lib/mcp/tools/instances.js b/forge/ee/lib/mcp/tools/instances.js index 013b4b4f18..bd2a847c4a 100644 --- a/forge/ee/lib/mcp/tools/instances.js +++ b/forge/ee/lib/mcp/tools/instances.js @@ -599,9 +599,9 @@ module.exports = [ name: 'platform_rollback_hosted_instance', title: 'Roll Back Hosted Instance', description: `FlowFuse platform automation tool: - Rolls a hosted instance back to one of its own snapshots. This OVERWRITES the instance's current flows, credentials, settings, environment variables and installed node modules with the ones in the snapshot. Environment variables are replaced, not merged, so any added since the snapshot are lost. + Rolls a hosted instance back to one of its own snapshots. This OVERWRITES the instance's current flows, credentials, settings, environment variables and installed node modules with the ones in the snapshot. Environment variables are replaced, not merged, so any added since the snapshot are lost. Settings and environment variables the instance's template does not allow the instance to change are skipped and keep their current value. The current state is not saved automatically. If it might be needed again, take a snapshot first with platform_create_instance_snapshot. - If the instance is running, its flows are restarted with the rolled back version before this replies. If it is stopped or suspended, the rolled back version is stored and used the next time it starts. + If the instance is running, a restart of its flows is triggered, and this replies before the restart finishes, so the instance can still be restarting right after. If it is stopped or suspended, the rolled back version is stored and used the next time it starts. The snapshot must belong to this hosted instance: a snapshot from another instance or a device, or one that does not exist, is rejected with a 400 "invalid_snapshot". Find snapshot ids with platform_list_instance_snapshots. Confirm with the user before calling this. Team members and owners can roll back. Replies { status: "okay" } on success.`, diff --git a/forge/ee/lib/mcp/tools/shared-instances-devices.js b/forge/ee/lib/mcp/tools/shared-instances-devices.js index 6974e1e691..b06744e0c6 100644 --- a/forge/ee/lib/mcp/tools/shared-instances-devices.js +++ b/forge/ee/lib/mcp/tools/shared-instances-devices.js @@ -1,6 +1,6 @@ const { z } = require('zod') -const { basePagination, basePaginationKeys, searchQuery, searchQueryKeys, auditLogFilters, auditLogFilterKeys, appendQuery, toolError } = require('../schemas') +const { basePagination, basePaginationKeys, searchQuery, searchQueryKeys, auditLogFilters, auditLogFilterKeys, appendQuery, toolError, hostedInstanceId } = require('../schemas') // Tools that work against both hosted instances and remote instances (devices), // selected with an instanceType discriminator. @@ -157,7 +157,7 @@ module.exports = [ title: 'Delete Instance', description: `FlowFuse platform automation tool: Permanently deletes an instance, either a hosted instance or a remote instance (device). This cannot be undone, and there is no way to get the instance back. - Deleting a hosted instance removes its container, flows, credentials, settings and files, and ALL of its snapshots. Remote instances (devices) assigned to it are not deleted, but they are unassigned from it, lose their target snapshot and stop running Node-RED (unless in developer mode) until they are given a new one. + Deleting a hosted instance removes its container, flows, credentials, settings and files, and ALL of its snapshots. Remote instances (devices) assigned to it are not deleted, but they are left unassigned (not moved to the application), lose their target snapshot and stop running Node-RED (unless in developer mode) until they are given a new one. Deleting a remote instance (device) revokes its credentials, so the physical device can no longer connect to the platform or receive updates. Its snapshots stay behind but can no longer be opened. In both cases, a pipeline stage that deployed to the instance is not deleted, it is left with no deploy target, so edit or remove it afterwards (find it with platform_list_pipelines). Before calling this, confirm with the user and tell them what goes with it. For a hosted instance, use platform_list_instance_snapshots and platform_list_remote_instances to list the snapshots and assigned devices. If the snapshots might be wanted later, export them first with platform_export_snapshot. @@ -170,6 +170,14 @@ module.exports = [ instanceType: z.enum(['hosted', 'remote']).describe('Whether instanceId refers to a hosted instance ("hosted") or a remote instance/device ("remote")') }, handler: async (args, { inject }) => { + // instanceId goes into the URL path and inject resolves dot segments, so anything + // but a plain id could send this DELETE to another route (e.g. "../applications/"). + const validId = args.instanceType === 'remote' + ? /^[A-Za-z0-9]+$/.test(args.instanceId) + : hostedInstanceId.safeParse(args.instanceId).success + if (!validId) { + return toolError(400, 'invalid_request', 'instanceId must be a hosted instance UUID or a remote instance (device) hashid, matching instanceType') + } const base = args.instanceType === 'remote' ? 'devices' : 'projects' const response = await inject({ method: 'DELETE', url: `/api/v1/${base}/${args.instanceId}` }) return response diff --git a/test/unit/forge/ee/lib/mcp/tools/shared-instances-devices_spec.js b/test/unit/forge/ee/lib/mcp/tools/shared-instances-devices_spec.js index 0152d3c506..0e4efc0cc2 100644 --- a/test/unit/forge/ee/lib/mcp/tools/shared-instances-devices_spec.js +++ b/test/unit/forge/ee/lib/mcp/tools/shared-instances-devices_spec.js @@ -221,11 +221,13 @@ describe('MCP Shared Instance/Device Tools', function () { tool.annotations.should.have.property('destructiveHint', true) }) + const hostedId = '6c1f3e2a-8b4d-4f10-9a2e-3d5c7b9e1f00' + it('deletes a hosted instance through the projects route', async function () { const routeResponse = { statusCode: 200, json: () => ({ status: 'okay' }) } - inject.withArgs({ method: 'DELETE', url: '/api/v1/projects/instance1' }).resolves(routeResponse) + inject.withArgs({ method: 'DELETE', url: `/api/v1/projects/${hostedId}` }).resolves(routeResponse) - const response = await tool.handler({ instanceId: 'instance1', instanceType: 'hosted' }, { inject }) + const response = await tool.handler({ instanceId: hostedId, instanceType: 'hosted' }, { inject }) inject.calledOnce.should.be.true() response.should.equal(routeResponse) @@ -249,8 +251,25 @@ describe('MCP Shared Instance/Device Tools', function () { const errorResponse = { statusCode: 403, json: () => ({ code: 'unauthorized' }) } inject.resolves(errorResponse) - const response = await tool.handler({ instanceId: 'instance1', instanceType: 'hosted' }, { inject }) + const response = await tool.handler({ instanceId: hostedId, instanceType: 'hosted' }, { inject }) response.should.equal(errorResponse) }) + + it('refuses an instanceId that would reach another route', async function () { + for (const [instanceId, instanceType] of [ + ['../applications/app1', 'remote'], + ['../teams/team1', 'hosted'], + ['device1?x=1', 'remote'], + ['..', 'remote'], + // right kind of id, wrong instanceType + ['device1', 'hosted'], + [hostedId, 'remote'] + ]) { + const response = await tool.handler({ instanceId, instanceType }, { inject }) + response.statusCode.should.equal(400) + response.json().should.have.property('code', 'invalid_request') + } + inject.called.should.be.false() + }) }) }) From 012389c0096192816c65495f75e5b27f23951fca Mon Sep 17 00:00:00 2001 From: Costin Serban Date: Fri, 2 Oct 2026 14:55:28 +0300 Subject: [PATCH 3/3] Update forge/ee/lib/mcp/tools/instances.js Co-authored-by: Andrea Palmieri <76187074+andypalmi@users.noreply.github.com> --- forge/ee/lib/mcp/tools/instances.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/forge/ee/lib/mcp/tools/instances.js b/forge/ee/lib/mcp/tools/instances.js index bd2a847c4a..f02cd71792 100644 --- a/forge/ee/lib/mcp/tools/instances.js +++ b/forge/ee/lib/mcp/tools/instances.js @@ -602,7 +602,7 @@ module.exports = [ Rolls a hosted instance back to one of its own snapshots. This OVERWRITES the instance's current flows, credentials, settings, environment variables and installed node modules with the ones in the snapshot. Environment variables are replaced, not merged, so any added since the snapshot are lost. Settings and environment variables the instance's template does not allow the instance to change are skipped and keep their current value. The current state is not saved automatically. If it might be needed again, take a snapshot first with platform_create_instance_snapshot. If the instance is running, a restart of its flows is triggered, and this replies before the restart finishes, so the instance can still be restarting right after. If it is stopped or suspended, the rolled back version is stored and used the next time it starts. - The snapshot must belong to this hosted instance: a snapshot from another instance or a device, or one that does not exist, is rejected with a 400 "invalid_snapshot". Find snapshot ids with platform_list_instance_snapshots. + The snapshot must belong to this hosted instance; a snapshot from another instance or a device, or one that does not exist, is rejected. Find snapshot ids with platform_list_instance_snapshots. Confirm with the user before calling this. Team members and owners can roll back. Replies { status: "okay" } on success.`, // destructiveHint: this replaces what the instance is running rather than adding to it.