Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions forge/ee/lib/mcp/tools/instances.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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)
}
}
]

Expand Down
35 changes: 34 additions & 1 deletion forge/ee/lib/mcp/tools/shared-instances-devices.js
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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/<id>").
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)
}
}
]
11 changes: 10 additions & 1 deletion forge/ee/lib/mcp/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
49 changes: 49 additions & 0 deletions test/unit/forge/ee/lib/mcp/tools/instances_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
})
52 changes: 52 additions & 0 deletions test/unit/forge/ee/lib/mcp/tools/shared-instances-devices_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
})
20 changes: 19 additions & 1 deletion test/unit/forge/ee/lib/mcp/utils_spec.js
Original file line number Diff line number Diff line change
@@ -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 () {
Expand All @@ -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)
})
})
})
Loading