diff --git a/forge/ee/lib/mcp/tools/instances.js b/forge/ee/lib/mcp/tools/instances.js index ab07806c12..5f0f14045f 100644 --- a/forge/ee/lib/mcp/tools/instances.js +++ b/forge/ee/lib/mcp/tools/instances.js @@ -3,6 +3,7 @@ const { randomUUID } = require('crypto') const { z } = require('zod') const { teamId, applicationId, hostedInstanceId, searchQuery, sortParams, limitParam, pageParam, toolError } = require('../schemas') +const { emptySuccessAsOkay } = require('../utils') // Mirrors the runningStates/errorStates/stoppedStates groups in frontend/src/composables/InstanceStates.js, // the same grouping the dashboard's own Running/Error/Not Running status filter uses (frontend/src/pages/team/Instances.vue). @@ -594,6 +595,34 @@ module.exports = [ }) return response } + }, + { + name: 'platform_delete_instance_file', + title: 'Delete Hosted Instance File', + description: `FlowFuse platform automation tool: + Permanently deletes a file or a directory from a hosted instance's file store. This cannot be undone. + A directory is deleted together with everything inside it, and any public sharing set up on it or on a directory beneath it is removed as well. + Flows that read or serve these files will stop finding them. Before calling this, confirm with the user, and use platform_list_hosted_instance_files to check what the path contains. + The root of the file store cannot be deleted, and the instance must be running. + Replies { status: "okay" } on success. A path that does not exist returns an error. + Static file storage is a plan-gated feature: a team without it enabled gets a 404 error.`, + // destructiveHint: the file, or a whole directory tree, is gone for good. + // idempotentHint: a repeat call has no further effect, it just fails as missing. + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, + inputSchema: { + instanceId: hostedInstanceId.describe('The id (UUID) of the hosted instance'), + path: z.string().min(1).describe('Path of the file or directory to delete, relative to the file-store root. Use "/" separators, as returned by platform_list_hosted_instance_files') + }, + handler: async (args, { inject }) => { + // The launcher's root guard misses a trailing slash, so "//" would delete the whole + // file store. Only pass on a path that names something below the root. + const segments = args.path.split('/').filter(Boolean) + if (segments.length === 0 || segments.some(s => s === '.' || s === '..')) { + return toolError(400, 'invalid_request', 'path must name a file or directory below the file-store root, without "." or ".." segments') + } + const response = await inject({ method: 'DELETE', url: `/api/v1/projects/${args.instanceId}/files/_/${encodeURIComponent(segments.join('/'))}` }) + return emptySuccessAsOkay(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..dbcf075c95 100644 --- a/forge/ee/lib/mcp/tools/shared-instances-devices.js +++ b/forge/ee/lib/mcp/tools/shared-instances-devices.js @@ -1,6 +1,7 @@ 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') +const { emptySuccessAsOkay } = require('../utils') // Tools that work against both hosted instances and remote instances (devices), // selected with an instanceType discriminator. @@ -151,5 +152,37 @@ module.exports = [ const response = await inject({ method: 'PUT', url: `/api/v1/${base}/${args.instanceId}/httpTokens/${args.tokenId}`, payload }) return response } + }, + { + name: 'platform_delete_instance_http_token', + title: 'Delete Instance HTTP Token', + description: `FlowFuse platform automation tool: + Deletes an HTTP bearer token from an instance (hosted instance or remote instance/device). This cannot be undone. + External callers presenting the token to the instance's Node-RED HTTP endpoints are then refused, but not necessarily straight away: an instance caches tokens it has recently accepted, so a caller already using it can keep getting through for up to about 5 minutes. A replacement has to be created with platform_create_instance_http_token, which issues a new value. + Before calling this, confirm with the user and check what still uses the token. Find token ids with platform_list_instance_http_tokens. + Replies { status: "okay" } on success. A token that does not exist on that instance returns a 404. + HTTP bearer tokens are a plan-gated feature; a team without it enabled gets a 404 error.`, + // destructiveHint: the token is revoked for good, cutting off whoever used it. + // 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 (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")'), + tokenId: z.string().describe('The hashid of the token to delete, as returned by platform_list_instance_http_tokens') + }, + handler: async (args, { inject }) => { + // Both ids go 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 validInstanceId = args.instanceType === 'remote' + ? /^[A-Za-z0-9]+$/.test(args.instanceId) + : hostedInstanceId.safeParse(args.instanceId).success + if (!validInstanceId || !/^[A-Za-z0-9]+$/.test(args.tokenId)) { + return toolError(400, 'invalid_request', 'instanceId must be a hosted instance UUID or a remote instance (device) hashid matching instanceType, and tokenId a token hashid') + } + const base = args.instanceType === 'remote' ? 'devices' : 'projects' + const response = await inject({ method: 'DELETE', url: `/api/v1/${base}/${args.instanceId}/httpTokens/${args.tokenId}` }) + // The route answers a successful delete with an empty 201. + return emptySuccessAsOkay(response) + } } ] diff --git a/forge/ee/lib/mcp/utils.js b/forge/ee/lib/mcp/utils.js index 86485c807b..efd93a5c72 100644 --- a/forge/ee/lib/mcp/utils.js +++ b/forge/ee/lib/mcp/utils.js @@ -20,4 +20,13 @@ function blankHiddenEnvValues (env) { return result } -module.exports = { redactDatabaseCredentials, blankHiddenEnvValues } +// Some delete routes answer success with an empty body, which formatResponse +// cannot parse. Report those as { status: "okay" } so the call reads as done. +function emptySuccessAsOkay (response) { + if (response.statusCode < 400 && !response.body) { + return { statusCode: response.statusCode, json: () => ({ status: 'okay' }) } + } + return response +} + +module.exports = { redactDatabaseCredentials, blankHiddenEnvValues, emptySuccessAsOkay } 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..67b33f2a50 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,53 @@ describe('MCP Instances Tools', function () { response.should.equal(errorResponse) }) }) + + describe('platform_delete_instance_file', function () { + const tool = getTool('platform_delete_instance_file') + const instanceId = '6c1f3e2a-8b4d-4f10-9a2e-3d5c7b9e1f00' + + 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 the encoded path through the instance files route', async function () { + inject.withArgs({ method: 'DELETE', url: `/api/v1/projects/${instanceId}/files/_/${encodeURIComponent('logs/old.txt')}` }).resolves({ statusCode: 200, body: '' }) + + const response = await tool.handler({ instanceId, path: 'logs/old.txt' }, { inject }) + + inject.calledOnce.should.be.true() + response.statusCode.should.equal(200) + response.json().should.eql({ status: 'okay' }) + }) + + it('rejects an empty path, which would target the file-store root', function () { + tool.inputSchema.path.safeParse('').success.should.be.false() + }) + + it('passes through an error response', async function () { + const errorResponse = { statusCode: 400, body: '{}', json: () => ({ code: 'invalid_request', error: 'HTTPError: Response code 404 (Not Found)' }) } + inject.resolves(errorResponse) + + const response = await tool.handler({ instanceId, path: 'missing.txt' }, { inject }) + response.should.equal(errorResponse) + }) + + it('drops empty segments, so a trailing slash still names the directory', async function () { + inject.resolves({ statusCode: 200, body: '' }) + + await tool.handler({ instanceId, path: '/logs//old/' }, { inject }) + + inject.firstCall.args[0].url.should.equal(`/api/v1/projects/${instanceId}/files/_/${encodeURIComponent('logs/old')}`) + }) + + it('refuses a path that resolves to the root or climbs out of it', async function () { + for (const path of ['/', '//', '.', 'logs/..', '../other', 'logs/./old']) { + const response = await tool.handler({ instanceId, path }, { inject }) + response.statusCode.should.equal(400) + response.json().should.have.property('code', 'invalid_request') + } + inject.called.should.be.false() + }) + }) }) 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..9ba48f32bf 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,56 @@ describe('MCP Shared Instance/Device Tools', function () { response.should.equal(errorResponse) }) }) + + describe('platform_delete_instance_http_token', function () { + const tool = getTool('platform_delete_instance_http_token') + const hostedId = '6c1f3e2a-8b4d-4f10-9a2e-3d5c7b9e1f00' + + 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 token through the projects route', async function () { + inject.withArgs({ method: 'DELETE', url: `/api/v1/projects/${hostedId}/httpTokens/token1` }).resolves({ statusCode: 201, body: '' }) + + const response = await tool.handler({ instanceId: hostedId, instanceType: 'hosted', tokenId: 'token1' }, { inject }) + + inject.calledOnce.should.be.true() + response.json().should.eql({ status: 'okay' }) + }) + + it('deletes a remote instance token through the devices route', async function () { + inject.withArgs({ method: 'DELETE', url: '/api/v1/devices/device1/httpTokens/token1' }).resolves({ statusCode: 201, body: '' }) + + const response = await tool.handler({ instanceId: 'device1', instanceType: 'remote', tokenId: 'token1' }, { inject }) + + inject.calledOnce.should.be.true() + response.json().should.eql({ status: 'okay' }) + }) + + it('passes through an error response', async function () { + const errorResponse = { statusCode: 404, body: '{}', json: () => ({ code: 'not_found' }) } + inject.resolves(errorResponse) + + const response = await tool.handler({ instanceId: hostedId, instanceType: 'hosted', tokenId: 'token1' }, { inject }) + response.should.equal(errorResponse) + }) + + it('refuses ids that would reach another route', async function () { + for (const [instanceId, instanceType, tokenId] of [ + [hostedId, 'hosted', '../../../applications/app1'], + [hostedId, 'hosted', 'token1?x=1'], + ['../teams/team1', 'remote', 'token1'], + // right kind of id, wrong instanceType + ['device1', 'hosted', 'token1'], + [hostedId, 'remote', 'token1'] + ]) { + const response = await tool.handler({ instanceId, instanceType, tokenId }, { inject }) + response.statusCode.should.equal(400) + response.json().should.have.property('code', 'invalid_request') + } + inject.called.should.be.false() + }) + }) }) diff --git a/test/unit/forge/ee/lib/mcp/utils_spec.js b/test/unit/forge/ee/lib/mcp/utils_spec.js index cdd563bdba..7601c80fb7 100644 --- a/test/unit/forge/ee/lib/mcp/utils_spec.js +++ b/test/unit/forge/ee/lib/mcp/utils_spec.js @@ -1,6 +1,6 @@ const should = require('should') // eslint-disable-line no-unused-vars -const { redactDatabaseCredentials } = require('../../../../../../forge/ee/lib/mcp/utils') +const { redactDatabaseCredentials, emptySuccessAsOkay } = require('../../../../../../forge/ee/lib/mcp/utils') describe('MCP utils', function () { describe('redactDatabaseCredentials', function () { @@ -14,4 +14,22 @@ describe('MCP utils', function () { should(redactDatabaseCredentials(undefined)).equal(undefined) }) }) + + describe('emptySuccessAsOkay', function () { + it('turns an empty success body into { status: "okay" }', function () { + const result = emptySuccessAsOkay({ statusCode: 201, body: '' }) + result.statusCode.should.equal(201) + result.json().should.eql({ status: 'okay' }) + }) + + it('leaves a success with a body unchanged', function () { + const response = { statusCode: 200, body: '{"id":"a"}' } + emptySuccessAsOkay(response).should.equal(response) + }) + + it('leaves an error unchanged, even with an empty body', function () { + const response = { statusCode: 404, body: '' } + emptySuccessAsOkay(response).should.equal(response) + }) + }) })