diff --git a/forge/ee/lib/mcp/tools/instances.js b/forge/ee/lib/mcp/tools/instances.js index 5f0f14045f..37bf1f92f7 100644 --- a/forge/ee/lib/mcp/tools/instances.js +++ b/forge/ee/lib/mcp/tools/instances.js @@ -623,6 +623,28 @@ module.exports = [ const response = await inject({ method: 'DELETE', url: `/api/v1/projects/${args.instanceId}/files/_/${encodeURIComponent(segments.join('/'))}` }) return emptySuccessAsOkay(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. 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. 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 dbcf075c95..2dd555a37a 100644 --- a/forge/ee/lib/mcp/tools/shared-instances-devices.js +++ b/forge/ee/lib/mcp/tools/shared-instances-devices.js @@ -184,5 +184,36 @@ module.exports = [ // The route answers a successful delete with an empty 201. return emptySuccessAsOkay(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 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. + 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 }) => { + // 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/instances_spec.js b/test/unit/forge/ee/lib/mcp/tools/instances_spec.js index 67b33f2a50..be7bc98534 100644 --- a/test/unit/forge/ee/lib/mcp/tools/instances_spec.js +++ b/test/unit/forge/ee/lib/mcp/tools/instances_spec.js @@ -973,4 +973,32 @@ describe('MCP Instances Tools', function () { inject.called.should.be.false() }) }) + + 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 9ba48f32bf..6fac179a1d 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 @@ -264,4 +264,64 @@ describe('MCP Shared Instance/Device Tools', function () { inject.called.should.be.false() }) }) + + 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) + }) + + 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/${hostedId}` }).resolves(routeResponse) + + const response = await tool.handler({ instanceId: hostedId, 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: 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() + }) + }) })