diff --git a/forge/ee/lib/mcp/tools/library.js b/forge/ee/lib/mcp/tools/library.js index e0303bd292..bec956d03c 100644 --- a/forge/ee/lib/mcp/tools/library.js +++ b/forge/ee/lib/mcp/tools/library.js @@ -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 === '..')) { + 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 }) + } } ] diff --git a/forge/ee/lib/mcp/tools/tables.js b/forge/ee/lib/mcp/tools/tables.js index 7421f860f4..1c2469f1b8 100644 --- a/forge/ee/lib/mcp/tools/tables.js +++ b/forge/ee/lib/mcp/tools/tables.js @@ -1,5 +1,6 @@ const { z } = require('zod') +const { toolError } = require('../schemas') const { teamIdSchema, databaseIdSchema, @@ -9,7 +10,7 @@ const { countSchema, recordSchema } = require('../tool-schemas/tables') -const { redactDatabaseCredentials } = require('../utils') +const { redactDatabaseCredentials, emptySuccessAsOkay } = require('../utils') module.exports = [ { @@ -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' }) } + } } ] diff --git a/forge/ee/lib/mcp/tools/teams.js b/forge/ee/lib/mcp/tools/teams.js index c2c682c431..ef0f2e26dd 100644 --- a/forge/ee/lib/mcp/tools/teams.js +++ b/forge/ee/lib/mcp/tools/teams.js @@ -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', diff --git a/test/unit/forge/ee/lib/mcp/tools/library_spec.js b/test/unit/forge/ee/lib/mcp/tools/library_spec.js index 55284bb434..13af2b072f 100644 --- a/test/unit/forge/ee/lib/mcp/tools/library_spec.js +++ b/test/unit/forge/ee/lib/mcp/tools/library_spec.js @@ -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) + }) + }) }) diff --git a/test/unit/forge/ee/lib/mcp/tools/tables_spec.js b/test/unit/forge/ee/lib/mcp/tools/tables_spec.js index 8747e1b83c..15465c36ed 100644 --- a/test/unit/forge/ee/lib/mcp/tools/tables_spec.js +++ b/test/unit/forge/ee/lib/mcp/tools/tables_spec.js @@ -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) + }) + }) }) diff --git a/test/unit/forge/ee/lib/mcp/tools/teams_spec.js b/test/unit/forge/ee/lib/mcp/tools/teams_spec.js index b965957af9..75a1af4018 100644 --- a/test/unit/forge/ee/lib/mcp/tools/teams_spec.js +++ b/test/unit/forge/ee/lib/mcp/tools/teams_spec.js @@ -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) + }) + }) })