Skip to content
Merged
28 changes: 28 additions & 0 deletions forge/ee/lib/mcp/tools/library.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,33 @@ module.exports = [
json: () => ({ entry: { path: args.path, type: args.type } })
}
}
},
{
name: 'platform_delete_library_entry',
title: 'Delete Library Entry',
description: `FlowFuse platform automation tool:
Permanently deletes an entry from a team's shared library, or a whole folder of entries. This cannot be undone and the content cannot be recovered.
If an entry has exactly this path, only that entry is deleted. Otherwise the path is treated as a folder and EVERY entry beneath it, at any depth, is deleted; list the folder first and confirm the exact entries with the user.
CAUTION: flows and snippets that other members of the team load from the library are gone for everyone. Confirm with the user before calling this, naming the entry or the folder.
Entries of different types can share a path. Pass type to delete only the entry of that type; without it one of the entries at that path is deleted, and which one is not defined. A folder delete without type covers all types.
Replies { status: "okay", deleteCount } with the number of entries removed. A path that matches nothing returns 404. Repeating a folder delete also removes anything added under the folder since the first call. A 404 can also mean the shared library is not enabled for the team, the team does not exist, or the caller is not a member of it.`,
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
inputSchema: {
teamId: teamId.describe('The hashid of the team that owns the shared library'),
path: z.string().min(1).max(1024).describe('Path of the entry or folder using "/" to separate folders, for example "utils/retry". No leading or trailing slash, and no empty, "." or ".." segments'),
type: z.string().min(1).optional().describe('Entry type to restrict the delete to, for example "flows" or "functions"')
},
handler: async (args, { inject }) => {
const segments = args.path.split('/')
// The URL resolves "." and ".." segments, even encoded ones, before the route sees the path
if (segments.some(segment => segment === '' || segment === '.' || segment === '..')) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A folder delete goes through byPath, which matches with an unescaped LIKE '<path>%', so deleting my_utils also wipes myXutils/... for the whole team, with no undo. The description warns about it but the call still goes through. Until #8848 lands, might be worth just refusing folder paths with _ or % here, it's one line and it takes the data loss off the table.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fixed in the route by #8878, which matches folder paths literally. I removed the warning from the description, and this merges after #8878.

return toolError(400, 'invalid_request', 'path must not start or end with "/" or contain empty, "." or ".." segments')
}
let url = `/storage/library/${args.teamId}/${segments.map(encodeURIComponent).join('/')}`
if (args.type) {
url += `?type=${encodeURIComponent(args.type)}`
}
return inject({ method: 'DELETE', url })
}
}
]
50 changes: 49 additions & 1 deletion forge/ee/lib/mcp/tools/tables.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const { z } = require('zod')

const { toolError } = require('../schemas')
const {
teamIdSchema,
databaseIdSchema,
Expand All @@ -9,7 +10,7 @@ const {
countSchema,
recordSchema
} = require('../tool-schemas/tables')
const { redactDatabaseCredentials } = require('../utils')
const { redactDatabaseCredentials, emptySuccessAsOkay } = require('../utils')

module.exports = [
{
Expand Down Expand Up @@ -231,5 +232,52 @@ module.exports = [
json: () => ({ table: { name: args.name, schema: args.schema || 'public' } })
}
}
},
{
name: 'platform_delete_database_table',
title: 'Delete Database Table',
description: `FlowFuse platform automation tool:
Permanently deletes a table from a FlowFuse Tables database, including every row it holds. This cannot be undone and there is no backup to restore from.
CAUTION: any flow that reads or writes the table will start failing as soon as it is gone. Confirm with the user before calling this, and use platform_get_database_table and platform_query_database_table_data first so you can tell them what is being lost.
Only the table in the given schema is deleted; a table with the same name in another schema is left alone. Team owners only.
Replies { status: "okay" } on success; a table or database that does not exist returns 404, so repeating a call is harmless. A 404 can also mean FlowFuse Tables is not available to the team.`,
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
inputSchema: {
teamId: teamIdSchema,
databaseId: databaseIdSchema,
tableName: tableNameSchema,
schemaName: schemaNameSchema
},
handler: async (args, { inject }) => {
// An empty or dot segment drops out of the URL, which would delete the table from any schema
if ([args.tableName, args.schemaName].some(segment => segment === '' || segment === '.' || segment === '..')) {
return toolError(400, 'invalid_request', 'tableName and schemaName must not be empty, "." or ".."')
}
const url = `/api/v1/teams/${args.teamId}/databases/${args.databaseId}/tables/${encodeURIComponent(args.tableName)}/${encodeURIComponent(args.schemaName)}`
const response = await inject({ method: 'DELETE', url })
return emptySuccessAsOkay(response)
}
},
{
name: 'platform_delete_team_database',
title: 'Delete Team Database',
description: `FlowFuse platform automation tool:
Permanently deletes a team's FlowFuse Tables database: every table in every schema, all of their rows, and the database login. This cannot be undone and there is no backup to restore from.
CAUTION: every flow and device in the team that uses the database will start failing, and the team is left with no database until a new one is created. Confirm with the user before calling this, and list what is in it first with platform_list_database_tables so you can tell them what will be lost.
The database does not need to be empty. Team owners only.
Replies { status: "okay" } on success; a database that does not exist returns 404, so repeating a call is harmless. A 404 can also mean FlowFuse Tables is not available to the team.
The delete fails with 500 while a client is still connected to the database, for example a running flow, and the database is kept. Retrying once nothing is connected finishes the delete.`,
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
inputSchema: {
teamId: teamIdSchema,
databaseId: databaseIdSchema
},
handler: async (args, { inject }) => {
const response = await inject({ method: 'DELETE', url: `/api/v1/teams/${args.teamId}/databases/${args.databaseId}` })
if (response.statusCode >= 400) {
return response
}
return { statusCode: response.statusCode, json: () => ({ status: 'okay' }) }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because of #8849 the route can answer 200 even when the drop failed, so this okay (and the description saying a repeat gives 404) tells the agent the data is gone when it might not be. Can't fix that here, but maybe a line in the description pointing at #8849 so the agent doesn't promise the user more than we know.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fixed in the route by #8880. A failed drop now returns 500 and keeps the database, and a retry finishes the delete. The description now says this, and this merges after #8880.

}
}
]
21 changes: 21 additions & 0 deletions forge/ee/lib/mcp/tools/teams.js
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,27 @@ module.exports = [
return inject({ method: 'PUT', url: `/api/v1/teams/${args.teamId}/git/tokens/${args.tokenId}`, payload: { name: args.name } })
}
},
{
name: 'platform_delete_git_token',
title: 'Delete Team Git Token',
description: `FlowFuse platform automation tool:
Permanently deletes a team git token. The stored token value is gone and cannot be recovered, so a new token has to be created to restore access.
Confirm with the user before calling this. The token itself is not revoked with the git provider.
A token that a pipeline stage still uses cannot be deleted: the call fails with 409 token_in_use and names the pipelines, which have to be pointed at another token first.
Only team owners can delete git tokens. Replies { status: "okay" } on success; a token that does not exist, or belongs to another team, returns 404, so repeating a call is harmless.`,
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
inputSchema: {
teamId,
tokenId: pathId.describe('The hashid of the git token to delete, as returned by platform_list_team_git_tokens')
},
handler: async (args, { inject }) => {
const response = await inject({ method: 'DELETE', url: `/api/v1/teams/${args.teamId}/git/tokens/${args.tokenId}` })
if (response.statusCode >= 400) {
return response
}
return { statusCode: response.statusCode, json: () => ({ status: 'okay' }) }
}
},
{
name: 'platform_create_team',
title: 'Create Team',
Expand Down
47 changes: 47 additions & 0 deletions test/unit/forge/ee/lib/mcp/tools/library_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,51 @@ describe('MCP Library Tools', function () {
schema.safeParse({ ...valid, type: '' }).success.should.be.false()
})
})

describe('platform_delete_library_entry', function () {
const tool = getTool('platform_delete_library_entry')

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 entry at the path and returns the route reply', async function () {
const routeResponse = { statusCode: 200, json: () => ({ status: 'okay', deleteCount: 1 }) }
inject.resolves(routeResponse)
const response = await tool.handler({ teamId: 'team1', path: 'utilities/formatDate' }, { inject })
inject.calledOnce.should.be.true()
inject.firstCall.args[0].should.eql({ method: 'DELETE', url: '/storage/library/team1/utilities/formatDate' })
response.should.equal(routeResponse)
})

it('sends the type as a query parameter when one is given', async function () {
inject.resolves({ statusCode: 200, json: () => ({ status: 'okay', deleteCount: 1 }) })
await tool.handler({ teamId: 'team1', path: 'formatDate', type: 'functions' }, { inject })
inject.firstCall.args[0].url.should.equal('/storage/library/team1/formatDate?type=functions')
})

it('encodes each path segment but keeps the folder separators', async function () {
inject.resolves({ statusCode: 200, json: () => ({ status: 'okay', deleteCount: 1 }) })
await tool.handler({ teamId: 'team1', path: 'my folder/100%/a?b#c' }, { inject })
inject.firstCall.args[0].url.should.equal('/storage/library/team1/my%20folder/100%25/a%3Fb%23c')
})

it('refuses a path that is empty or would address the whole library or another route', async function () {
for (const path of ['/', '//', '/a', 'a/', 'a//b', '..', 'a/../b', './a']) {
const response = await tool.handler({ teamId: 'team1', path }, { inject })
response.statusCode.should.equal(400)
response.json().should.have.property('code', 'invalid_request')
}
z.object(tool.inputSchema).shape.path.safeParse('').success.should.be.false()
inject.called.should.be.false()
})

it('passes through a rejection for a token without write access', async function () {
const errorResponse = { statusCode: 403, json: () => ({ code: 'unauthorized', error: 'unauthorized' }) }
inject.resolves(errorResponse)
const response = await tool.handler({ teamId: 'team1', path: 'a' }, { inject })
response.should.equal(errorResponse)
})
})
})
89 changes: 89 additions & 0 deletions test/unit/forge/ee/lib/mcp/tools/tables_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -237,4 +237,93 @@ describe('MCP Tables Tools', function () {
response.should.equal(errorResponse)
})
})

describe('platform_delete_database_table', function () {
const tool = getTool('platform_delete_database_table')

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 table in the given schema and reports okay for the empty reply', async function () {
inject.resolves({ statusCode: 204, body: '' })
const response = await tool.handler({ teamId: 'team1', databaseId: 'db1', tableName: 'orders', schemaName: 'reports' }, { inject })
inject.calledOnce.should.be.true()
inject.firstCall.args[0].should.eql({ method: 'DELETE', url: '/api/v1/teams/team1/databases/db1/tables/orders/reports' })
response.statusCode.should.equal(204)
response.json().should.eql({ status: 'okay' })
})

it('encodes table and schema names that contain reserved characters', async function () {
inject.resolves({ statusCode: 204, body: '' })
await tool.handler({ teamId: 'team1', databaseId: 'db1', tableName: 'my table/1', schemaName: 'a b' }, { inject })
inject.firstCall.args[0].url.should.equal('/api/v1/teams/team1/databases/db1/tables/my%20table%2F1/a%20b')
})

it('rejects empty and dot table or schema names, which would drop out of the URL', async function () {
for (const bad of [{ schemaName: '' }, { schemaName: '.' }, { tableName: '..' }, { tableName: '' }]) {
const response = await tool.handler({ teamId: 'team1', databaseId: 'db1', tableName: 'orders', schemaName: 'reports', ...bad }, { inject })
response.statusCode.should.equal(400)
}
inject.called.should.be.false()
})

it('requires the schema name', function () {
z.object(tool.inputSchema).shape.schemaName.safeParse(undefined).success.should.be.false()
})

it('passes through the descriptive error when FlowFuse Tables is not enabled for the team', async function () {
const errorResponse = { statusCode: 404, json: () => ({ code: 'not_found', error: 'Not Found - FlowFuse Tables is not enabled for this team' }) }
inject.resolves(errorResponse)
const response = await tool.handler({ teamId: 'team1', databaseId: 'db1', tableName: 'orders', schemaName: 'public' }, { inject })
response.should.equal(errorResponse)
})

it('passes through a rejection for a token without write access', async function () {
const errorResponse = { statusCode: 403, json: () => ({ code: 'unauthorized', error: 'unauthorized' }) }
inject.resolves(errorResponse)
const response = await tool.handler({ teamId: 'team1', databaseId: 'db1', tableName: 'orders', schemaName: 'public' }, { inject })
response.should.equal(errorResponse)
})

it('passes through a table not found error', async function () {
const errorResponse = { statusCode: 404, json: () => ({ code: 'table_not_found', error: 'Table not found' }) }
inject.resolves(errorResponse)
const response = await tool.handler({ teamId: 'team1', databaseId: 'db1', tableName: 'orders', schemaName: 'public' }, { inject })
response.should.equal(errorResponse)
})
})

describe('platform_delete_team_database', function () {
const tool = getTool('platform_delete_team_database')

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 database and reports okay for the empty object reply', async function () {
inject.resolves({ statusCode: 200, body: '{}', json: () => ({}) })
const response = await tool.handler({ teamId: 'team1', databaseId: 'db1' }, { inject })
inject.calledOnce.should.be.true()
inject.firstCall.args[0].should.eql({ method: 'DELETE', url: '/api/v1/teams/team1/databases/db1' })
response.statusCode.should.equal(200)
response.json().should.eql({ status: 'okay' })
})

it('passes through the descriptive error when FlowFuse Tables is not enabled for the team', async function () {
const errorResponse = { statusCode: 404, json: () => ({ code: 'not_found', error: 'Not Found - FlowFuse Tables is not enabled for this team' }) }
inject.resolves(errorResponse)
const response = await tool.handler({ teamId: 'team1', databaseId: 'db1' }, { inject })
response.should.equal(errorResponse)
})

it('passes through a rejection for a token without write access', async function () {
const errorResponse = { statusCode: 403, json: () => ({ code: 'unauthorized', error: 'unauthorized' }) }
inject.resolves(errorResponse)
const response = await tool.handler({ teamId: 'team1', databaseId: 'db1' }, { inject })
response.should.equal(errorResponse)
})
})
})
25 changes: 25 additions & 0 deletions test/unit/forge/ee/lib/mcp/tools/teams_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -415,4 +415,29 @@ describe('MCP Teams Tools', function () {
schema.safeParse({ ...args, name: ' ' }).success.should.be.false()
})
})

describe('platform_delete_git_token', function () {
const tool = getTool('platform_delete_git_token')

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 token and reports okay for the empty object reply', async function () {
inject.resolves({ statusCode: 200, body: '{}', json: () => ({}) })
const response = await tool.handler({ teamId: 'team1', tokenId: 'token1' }, { inject })
inject.calledOnce.should.be.true()
inject.firstCall.args[0].should.eql({ method: 'DELETE', url: '/api/v1/teams/team1/git/tokens/token1' })
response.statusCode.should.equal(200)
response.json().should.eql({ status: 'okay' })
})

it('passes through a rejection for a token without write access', async function () {
const errorResponse = { statusCode: 403, json: () => ({ code: 'unauthorized', error: 'unauthorized' }) }
inject.resolves(errorResponse)
const response = await tool.handler({ teamId: 'team1', tokenId: 'token1' }, { inject })
response.should.equal(errorResponse)
})
})
})
Loading