From 3afacd836091a3bf0a2503f5561e9b760bb5941b Mon Sep 17 00:00:00 2001 From: andypalmi Date: Wed, 7 Oct 2026 19:45:43 +0200 Subject: [PATCH 1/5] Add MCP tools to delete database tables, team databases, shared library entries and git tokens --- forge/ee/lib/mcp/tools/library.js | 42 ++++++++ forge/ee/lib/mcp/tools/tables.js | 53 +++++++++- forge/ee/lib/mcp/tools/teams.js | 23 +++++ .../forge/ee/lib/mcp/tools/library_spec.js | 83 ++++++++++++++++ .../forge/ee/lib/mcp/tools/tables_spec.js | 99 +++++++++++++++++++ .../unit/forge/ee/lib/mcp/tools/teams_spec.js | 41 ++++++++ 6 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 forge/ee/lib/mcp/tools/library.js create mode 100644 test/unit/forge/ee/lib/mcp/tools/library_spec.js diff --git a/forge/ee/lib/mcp/tools/library.js b/forge/ee/lib/mcp/tools/library.js new file mode 100644 index 0000000000..91ca4dc4db --- /dev/null +++ b/forge/ee/lib/mcp/tools/library.js @@ -0,0 +1,42 @@ +const { z } = require('zod') + +const { teamId, toolError } = require('../schemas') + +const isHashid = (id) => /^[A-Za-z0-9]+$/.test(id) + +module.exports = [ + { + 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. In a folder path, "_" and "%" match any character(s) rather than themselves, so such a path can delete entries in similarly named folders; 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 a single entry at that path is deleted, and a folder delete covers all types. + Replies { status: "okay", deleteCount } with the number of entries removed. A path that matches nothing returns 404, so repeating a call is harmless; the 404 is the same when 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: true, 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 }) => { + if (!isHashid(args.teamId)) { + return toolError(400, 'invalid_request', 'teamId must be a hashid') + } + const segments = args.path.split('/') + 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)}` + } + const response = await inject({ method: 'DELETE', url }) + if (response.statusCode === 404) { + return toolError(404, 'not_found', 'Nothing was deleted. No entry or folder matches that path, or the shared library is not enabled for this team, or the team does not exist, or you are not a member of it') + } + return response + } + } +] diff --git a/forge/ee/lib/mcp/tools/tables.js b/forge/ee/lib/mcp/tools/tables.js index d4feca75cc..2a7834e2bf 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,9 @@ const { countSchema, recordSchema } = require('../tool-schemas/tables') -const { redactDatabaseCredentials } = require('../utils') +const { redactDatabaseCredentials, emptySuccessAsOkay } = require('../utils') + +const isHashid = (id) => /^[A-Za-z0-9]+$/.test(id) module.exports = [ { @@ -206,5 +209,53 @@ 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 }) => { + if (!isHashid(args.teamId) || !isHashid(args.databaseId)) { + return toolError(400, 'invalid_request', 'teamId and databaseId must be hashids') + } + 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.`, + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, + inputSchema: { + teamId: teamIdSchema, + databaseId: databaseIdSchema + }, + handler: async (args, { inject }) => { + if (!isHashid(args.teamId) || !isHashid(args.databaseId)) { + return toolError(400, 'invalid_request', 'teamId and databaseId must be hashids') + } + 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 ca9ca92f17..abdabcba09 100644 --- a/forge/ee/lib/mcp/tools/teams.js +++ b/forge/ee/lib/mcp/tools/teams.js @@ -178,6 +178,29 @@ module.exports = [ return response } }, + { + 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. + CAUTION: pipeline stages that use this token are neither removed nor blocked. They keep their repository settings but lose their token, and every push to or pull from their repository fails from then on, with an unhelpful error, until each stage is pointed at another token. Confirm with the user before calling this, and check which pipelines have git-repository stages with platform_list_pipelines first so you can tell them what will stop working. + 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. A 404 can also mean git integration is not available to the team.`, + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, + inputSchema: { + teamId, + tokenId: z.string().describe('The hashid of the git token to delete, as returned by platform_list_team_git_tokens') + }, + handler: async (args, { inject }) => { + if (!isHashid(args.teamId) || !isHashid(args.tokenId)) { + return toolError(400, 'invalid_request', 'teamId and tokenId must be hashids') + } + 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 new file mode 100644 index 0000000000..7a7668a3b7 --- /dev/null +++ b/test/unit/forge/ee/lib/mcp/tools/library_spec.js @@ -0,0 +1,83 @@ +const should = require('should') // eslint-disable-line no-unused-vars +const sinon = require('sinon') +const { z } = require('zod') + +const tools = require('../../../../../../../forge/ee/lib/mcp/tools/library') + +function getTool (name) { + return tools.find(tool => tool.name === name) +} + +describe('MCP Library Tools', function () { + let inject + + beforeEach(function () { + inject = sinon.stub() + }) + + 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('refuses a teamId that would reach another route', async function () { + for (const teamId of ['../teams/team2', 'team1/../team2', 'team1?x=1']) { + const response = await tool.handler({ teamId, path: 'a' }, { inject }) + response.statusCode.should.equal(400) + response.json().should.have.property('code', 'invalid_request') + } + inject.called.should.be.false() + }) + + it('explains the route\'s bare 404, which is also what a disabled library or a non-member gets', async function () { + inject.resolves({ statusCode: 404, json: () => ({ code: 'not_found', error: 'Not Found' }) }) + const response = await tool.handler({ teamId: 'team1', path: 'missing' }, { inject }) + response.statusCode.should.equal(404) + const body = response.json() + body.should.have.property('code', 'not_found') + body.error.should.match(/Nothing was deleted/) + body.error.should.match(/shared library is not enabled/) + }) + + 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 578829ca35..e0cbfef7ea 100644 --- a/test/unit/forge/ee/lib/mcp/tools/tables_spec.js +++ b/test/unit/forge/ee/lib/mcp/tools/tables_spec.js @@ -209,4 +209,103 @@ 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('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) + }) + + it('refuses ids that would reach another route', async function () { + for (const [teamId, databaseId] of [['../teams/team2', 'db1'], ['team1', 'db1/../..'], ['team1', '..']]) { + const response = await tool.handler({ teamId, databaseId, tableName: 'orders', schemaName: 'public' }, { inject }) + response.statusCode.should.equal(400) + response.json().should.have.property('code', 'invalid_request') + } + inject.called.should.be.false() + }) + }) + + 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) + }) + + it('refuses ids that would reach another route', async function () { + for (const [teamId, databaseId] of [['team1/../team2', 'db1'], ['team1', '../..'], ['team1', 'db1?x=1']]) { + const response = await tool.handler({ teamId, databaseId }, { 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/teams_spec.js b/test/unit/forge/ee/lib/mcp/tools/teams_spec.js index aa95d8c32e..ddc676f221 100644 --- a/test/unit/forge/ee/lib/mcp/tools/teams_spec.js +++ b/test/unit/forge/ee/lib/mcp/tools/teams_spec.js @@ -408,4 +408,45 @@ describe('MCP Teams Tools', function () { inject.called.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 not found error', async function () { + const errorResponse = { statusCode: 404, json: () => ({ code: 'not_found', error: 'Not Found' }) } + inject.resolves(errorResponse) + const response = await tool.handler({ teamId: 'team1', tokenId: 'token1' }, { 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', tokenId: 'token1' }, { inject }) + response.should.equal(errorResponse) + }) + + it('refuses ids that would reach another route', async function () { + for (const [teamId, tokenId] of [['team1/../team2', 'token1'], ['team1', '../..'], ['team1', 'token1?x=1']]) { + const response = await tool.handler({ teamId, tokenId }, { inject }) + response.statusCode.should.equal(400) + response.json().should.have.property('code', 'invalid_request') + } + inject.called.should.be.false() + }) + }) }) From 350d5f2d611aa3c6255761910d2588e345b71637 Mon Sep 17 00:00:00 2001 From: andypalmi Date: Wed, 7 Oct 2026 19:57:36 +0200 Subject: [PATCH 2/5] Reject table and schema names that drop out of the URL, and explain a disabled git integration on token delete --- forge/ee/lib/mcp/tools/library.js | 2 +- forge/ee/lib/mcp/tools/tables.js | 4 ++++ forge/ee/lib/mcp/tools/teams.js | 13 +++++++++++-- test/unit/forge/ee/lib/mcp/tools/tables_spec.js | 8 ++++++++ test/unit/forge/ee/lib/mcp/tools/teams_spec.js | 13 +++++++++++-- 5 files changed, 35 insertions(+), 5 deletions(-) diff --git a/forge/ee/lib/mcp/tools/library.js b/forge/ee/lib/mcp/tools/library.js index 91ca4dc4db..ebc2b53349 100644 --- a/forge/ee/lib/mcp/tools/library.js +++ b/forge/ee/lib/mcp/tools/library.js @@ -12,7 +12,7 @@ module.exports = [ 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. In a folder path, "_" and "%" match any character(s) rather than themselves, so such a path can delete entries in similarly named folders; 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 a single entry at that path is deleted, and a folder delete covers all types. + 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, so repeating a call is harmless; the 404 is the same when 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: true, openWorldHint: false }, inputSchema: { diff --git a/forge/ee/lib/mcp/tools/tables.js b/forge/ee/lib/mcp/tools/tables.js index 2a7834e2bf..4fb3e75df1 100644 --- a/forge/ee/lib/mcp/tools/tables.js +++ b/forge/ee/lib/mcp/tools/tables.js @@ -229,6 +229,10 @@ module.exports = [ if (!isHashid(args.teamId) || !isHashid(args.databaseId)) { return toolError(400, 'invalid_request', 'teamId and databaseId must be hashids') } + // 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) diff --git a/forge/ee/lib/mcp/tools/teams.js b/forge/ee/lib/mcp/tools/teams.js index abdabcba09..2a1f80d325 100644 --- a/forge/ee/lib/mcp/tools/teams.js +++ b/forge/ee/lib/mcp/tools/teams.js @@ -184,7 +184,7 @@ module.exports = [ 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. CAUTION: pipeline stages that use this token are neither removed nor blocked. They keep their repository settings but lose their token, and every push to or pull from their repository fails from then on, with an unhelpful error, until each stage is pointed at another token. Confirm with the user before calling this, and check which pipelines have git-repository stages with platform_list_pipelines first so you can tell them what will stop working. - 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. A 404 can also mean git integration is not available to the team.`, + 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, @@ -194,7 +194,16 @@ module.exports = [ if (!isHashid(args.teamId) || !isHashid(args.tokenId)) { return toolError(400, 'invalid_request', 'teamId and tokenId must be hashids') } - const response = await inject({ method: 'DELETE', url: `/api/v1/teams/${args.teamId}/git/tokens/${args.tokenId}` }) + const url = `/api/v1/teams/${args.teamId}/git/tokens` + const response = await inject({ method: 'DELETE', url: `${url}/${args.tokenId}` }) + if (response.statusCode === 404) { + // The route answers 404 for a missing token and for a team without git integration alike + const probe = await inject({ method: 'GET', url }) + if (probe.statusCode === 404) { + return toolError(404, 'not_found', 'Git integration is not enabled for this team, or the team does not exist') + } + return response + } if (response.statusCode >= 400) { return response } 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 e0cbfef7ea..71bf3eb9f9 100644 --- a/test/unit/forge/ee/lib/mcp/tools/tables_spec.js +++ b/test/unit/forge/ee/lib/mcp/tools/tables_spec.js @@ -233,6 +233,14 @@ describe('MCP Tables Tools', function () { 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() }) 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 ddc676f221..a1aa6c4b87 100644 --- a/test/unit/forge/ee/lib/mcp/tools/teams_spec.js +++ b/test/unit/forge/ee/lib/mcp/tools/teams_spec.js @@ -426,13 +426,22 @@ describe('MCP Teams Tools', function () { response.json().should.eql({ status: 'okay' }) }) - it('passes through a not found error', async function () { + it('keeps the route 404 when the team has git integration but the token is missing', async function () { const errorResponse = { statusCode: 404, json: () => ({ code: 'not_found', error: 'Not Found' }) } - inject.resolves(errorResponse) + inject.onFirstCall().resolves(errorResponse) + inject.onSecondCall().resolves({ statusCode: 200, json: () => ({ tokens: [] }) }) const response = await tool.handler({ teamId: 'team1', tokenId: 'token1' }, { inject }) + inject.secondCall.args[0].should.eql({ method: 'GET', url: '/api/v1/teams/team1/git/tokens' }) response.should.equal(errorResponse) }) + it('explains a 404 as git integration being unavailable when the token list is also 404', async function () { + inject.callsFake(async () => ({ statusCode: 404, json: () => ({ code: 'not_found', error: 'Not Found' }) })) + const response = await tool.handler({ teamId: 'team1', tokenId: 'token1' }, { inject }) + response.statusCode.should.equal(404) + response.json().error.should.match(/Git integration is not enabled/) + }) + 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) From 4d5f1901c4e5fa341d2ec39a06e6c4d68d516077 Mon Sep 17 00:00:00 2001 From: andypalmi Date: Fri, 9 Oct 2026 13:44:20 +0200 Subject: [PATCH 3/5] Rely on the shared id guard in the table, database, library and git token delete tools --- forge/ee/lib/mcp/tools/library.js | 5 ----- forge/ee/lib/mcp/tools/tables.js | 8 -------- forge/ee/lib/mcp/tools/teams.js | 5 +---- .../forge/ee/lib/mcp/tools/library_spec.js | 9 --------- .../unit/forge/ee/lib/mcp/tools/tables_spec.js | 18 ------------------ test/unit/forge/ee/lib/mcp/tools/teams_spec.js | 9 --------- 6 files changed, 1 insertion(+), 53 deletions(-) diff --git a/forge/ee/lib/mcp/tools/library.js b/forge/ee/lib/mcp/tools/library.js index ebc2b53349..3977aa15d9 100644 --- a/forge/ee/lib/mcp/tools/library.js +++ b/forge/ee/lib/mcp/tools/library.js @@ -2,8 +2,6 @@ const { z } = require('zod') const { teamId, toolError } = require('../schemas') -const isHashid = (id) => /^[A-Za-z0-9]+$/.test(id) - module.exports = [ { name: 'platform_delete_library_entry', @@ -21,9 +19,6 @@ module.exports = [ type: z.string().min(1).optional().describe('Entry type to restrict the delete to, for example "flows" or "functions"') }, handler: async (args, { inject }) => { - if (!isHashid(args.teamId)) { - return toolError(400, 'invalid_request', 'teamId must be a hashid') - } const segments = args.path.split('/') if (segments.some(segment => segment === '' || segment === '.' || segment === '..')) { return toolError(400, 'invalid_request', 'path must not start or end with "/" or contain empty, "." or ".." segments') diff --git a/forge/ee/lib/mcp/tools/tables.js b/forge/ee/lib/mcp/tools/tables.js index 564ca10f25..b9d1f63be5 100644 --- a/forge/ee/lib/mcp/tools/tables.js +++ b/forge/ee/lib/mcp/tools/tables.js @@ -12,8 +12,6 @@ const { } = require('../tool-schemas/tables') const { redactDatabaseCredentials, emptySuccessAsOkay } = require('../utils') -const isHashid = (id) => /^[A-Za-z0-9]+$/.test(id) - module.exports = [ { name: 'platform_list_team_databases', @@ -251,9 +249,6 @@ module.exports = [ schemaName: schemaNameSchema }, handler: async (args, { inject }) => { - if (!isHashid(args.teamId) || !isHashid(args.databaseId)) { - return toolError(400, 'invalid_request', 'teamId and databaseId must be hashids') - } // 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 ".."') @@ -277,9 +272,6 @@ module.exports = [ databaseId: databaseIdSchema }, handler: async (args, { inject }) => { - if (!isHashid(args.teamId) || !isHashid(args.databaseId)) { - return toolError(400, 'invalid_request', 'teamId and databaseId must be hashids') - } const response = await inject({ method: 'DELETE', url: `/api/v1/teams/${args.teamId}/databases/${args.databaseId}` }) if (response.statusCode >= 400) { return response diff --git a/forge/ee/lib/mcp/tools/teams.js b/forge/ee/lib/mcp/tools/teams.js index 788723fc2e..d6e4f731c0 100644 --- a/forge/ee/lib/mcp/tools/teams.js +++ b/forge/ee/lib/mcp/tools/teams.js @@ -188,12 +188,9 @@ module.exports = [ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, inputSchema: { teamId, - tokenId: z.string().describe('The hashid of the git token to delete, as returned by platform_list_team_git_tokens') + tokenId: pathId.describe('The hashid of the git token to delete, as returned by platform_list_team_git_tokens') }, handler: async (args, { inject }) => { - if (!isHashid(args.teamId) || !isHashid(args.tokenId)) { - return toolError(400, 'invalid_request', 'teamId and tokenId must be hashids') - } const url = `/api/v1/teams/${args.teamId}/git/tokens` const response = await inject({ method: 'DELETE', url: `${url}/${args.tokenId}` }) if (response.statusCode === 404) { 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 7a7668a3b7..1fa2b38098 100644 --- a/test/unit/forge/ee/lib/mcp/tools/library_spec.js +++ b/test/unit/forge/ee/lib/mcp/tools/library_spec.js @@ -54,15 +54,6 @@ describe('MCP Library Tools', function () { inject.called.should.be.false() }) - it('refuses a teamId that would reach another route', async function () { - for (const teamId of ['../teams/team2', 'team1/../team2', 'team1?x=1']) { - const response = await tool.handler({ teamId, path: 'a' }, { inject }) - response.statusCode.should.equal(400) - response.json().should.have.property('code', 'invalid_request') - } - inject.called.should.be.false() - }) - it('explains the route\'s bare 404, which is also what a disabled library or a non-member gets', async function () { inject.resolves({ statusCode: 404, json: () => ({ code: 'not_found', error: 'Not Found' }) }) const response = await tool.handler({ teamId: 'team1', path: 'missing' }, { inject }) 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 58fa842061..15465c36ed 100644 --- a/test/unit/forge/ee/lib/mcp/tools/tables_spec.js +++ b/test/unit/forge/ee/lib/mcp/tools/tables_spec.js @@ -293,15 +293,6 @@ describe('MCP Tables Tools', function () { const response = await tool.handler({ teamId: 'team1', databaseId: 'db1', tableName: 'orders', schemaName: 'public' }, { inject }) response.should.equal(errorResponse) }) - - it('refuses ids that would reach another route', async function () { - for (const [teamId, databaseId] of [['../teams/team2', 'db1'], ['team1', 'db1/../..'], ['team1', '..']]) { - const response = await tool.handler({ teamId, databaseId, tableName: 'orders', schemaName: 'public' }, { inject }) - response.statusCode.should.equal(400) - response.json().should.have.property('code', 'invalid_request') - } - inject.called.should.be.false() - }) }) describe('platform_delete_team_database', function () { @@ -334,14 +325,5 @@ describe('MCP Tables Tools', function () { const response = await tool.handler({ teamId: 'team1', databaseId: 'db1' }, { inject }) response.should.equal(errorResponse) }) - - it('refuses ids that would reach another route', async function () { - for (const [teamId, databaseId] of [['team1/../team2', 'db1'], ['team1', '../..'], ['team1', 'db1?x=1']]) { - const response = await tool.handler({ teamId, databaseId }, { 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/teams_spec.js b/test/unit/forge/ee/lib/mcp/tools/teams_spec.js index a1aa6c4b87..9faf11db44 100644 --- a/test/unit/forge/ee/lib/mcp/tools/teams_spec.js +++ b/test/unit/forge/ee/lib/mcp/tools/teams_spec.js @@ -448,14 +448,5 @@ describe('MCP Teams Tools', function () { const response = await tool.handler({ teamId: 'team1', tokenId: 'token1' }, { inject }) response.should.equal(errorResponse) }) - - it('refuses ids that would reach another route', async function () { - for (const [teamId, tokenId] of [['team1/../team2', 'token1'], ['team1', '../..'], ['team1', 'token1?x=1']]) { - const response = await tool.handler({ teamId, tokenId }, { inject }) - response.statusCode.should.equal(400) - response.json().should.have.property('code', 'invalid_request') - } - inject.called.should.be.false() - }) }) }) From 7e67d654096a7d86fee432e74a395342a55451ed Mon Sep 17 00:00:00 2001 From: andypalmi Date: Mon, 12 Oct 2026 16:00:40 +0200 Subject: [PATCH 4/5] Let the team data delete tools rely on the route fixes for 404 messages, in-use git tokens and literal library paths --- forge/ee/lib/mcp/tools/library.js | 11 ++++------- forge/ee/lib/mcp/tools/tables.js | 3 ++- forge/ee/lib/mcp/tools/teams.js | 14 +++----------- test/unit/forge/ee/lib/mcp/tools/library_spec.js | 10 ---------- test/unit/forge/ee/lib/mcp/tools/teams_spec.js | 16 ---------------- 5 files changed, 9 insertions(+), 45 deletions(-) diff --git a/forge/ee/lib/mcp/tools/library.js b/forge/ee/lib/mcp/tools/library.js index 3977aa15d9..e79409be1b 100644 --- a/forge/ee/lib/mcp/tools/library.js +++ b/forge/ee/lib/mcp/tools/library.js @@ -8,10 +8,10 @@ module.exports = [ 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. In a folder path, "_" and "%" match any character(s) rather than themselves, so such a path can delete entries in similarly named folders; list the folder first and confirm the exact entries with the user. + 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, so repeating a call is harmless; the 404 is the same when the shared library is not enabled for the team, the team does not exist, or the caller is not a member of it.`, + Replies { status: "okay", deleteCount } with the number of entries removed. A path that matches nothing returns 404, so repeating a call is harmless. 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: true, openWorldHint: false }, inputSchema: { teamId: teamId.describe('The hashid of the team that owns the shared library'), @@ -20,6 +20,7 @@ module.exports = [ }, 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') } @@ -27,11 +28,7 @@ module.exports = [ if (args.type) { url += `?type=${encodeURIComponent(args.type)}` } - const response = await inject({ method: 'DELETE', url }) - if (response.statusCode === 404) { - return toolError(404, 'not_found', 'Nothing was deleted. No entry or folder matches that path, or the shared library is not enabled for this team, or the team does not exist, or you are not a member of it') - } - return response + return inject({ method: 'DELETE', url }) } } ] diff --git a/forge/ee/lib/mcp/tools/tables.js b/forge/ee/lib/mcp/tools/tables.js index b9d1f63be5..1c2469f1b8 100644 --- a/forge/ee/lib/mcp/tools/tables.js +++ b/forge/ee/lib/mcp/tools/tables.js @@ -265,7 +265,8 @@ module.exports = [ 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.`, + 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, diff --git a/forge/ee/lib/mcp/tools/teams.js b/forge/ee/lib/mcp/tools/teams.js index 4a671550cf..bc0f8b62e4 100644 --- a/forge/ee/lib/mcp/tools/teams.js +++ b/forge/ee/lib/mcp/tools/teams.js @@ -181,7 +181,8 @@ module.exports = [ 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. - CAUTION: pipeline stages that use this token are neither removed nor blocked. They keep their repository settings but lose their token, and every push to or pull from their repository fails from then on, with an unhelpful error, until each stage is pointed at another token. Confirm with the user before calling this, and check which pipelines have git-repository stages with platform_list_pipelines first so you can tell them what will stop working. + 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: { @@ -189,16 +190,7 @@ module.exports = [ tokenId: pathId.describe('The hashid of the git token to delete, as returned by platform_list_team_git_tokens') }, handler: async (args, { inject }) => { - const url = `/api/v1/teams/${args.teamId}/git/tokens` - const response = await inject({ method: 'DELETE', url: `${url}/${args.tokenId}` }) - if (response.statusCode === 404) { - // The route answers 404 for a missing token and for a team without git integration alike - const probe = await inject({ method: 'GET', url }) - if (probe.statusCode === 404) { - return toolError(404, 'not_found', 'Git integration is not enabled for this team, or the team does not exist') - } - return response - } + const response = await inject({ method: 'DELETE', url: `/api/v1/teams/${args.teamId}/git/tokens/${args.tokenId}` }) if (response.statusCode >= 400) { return response } 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 1fa2b38098..ed10b02532 100644 --- a/test/unit/forge/ee/lib/mcp/tools/library_spec.js +++ b/test/unit/forge/ee/lib/mcp/tools/library_spec.js @@ -54,16 +54,6 @@ describe('MCP Library Tools', function () { inject.called.should.be.false() }) - it('explains the route\'s bare 404, which is also what a disabled library or a non-member gets', async function () { - inject.resolves({ statusCode: 404, json: () => ({ code: 'not_found', error: 'Not Found' }) }) - const response = await tool.handler({ teamId: 'team1', path: 'missing' }, { inject }) - response.statusCode.should.equal(404) - const body = response.json() - body.should.have.property('code', 'not_found') - body.error.should.match(/Nothing was deleted/) - body.error.should.match(/shared library is not enabled/) - }) - 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) 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 5775b5d888..0ffa82c77c 100644 --- a/test/unit/forge/ee/lib/mcp/tools/teams_spec.js +++ b/test/unit/forge/ee/lib/mcp/tools/teams_spec.js @@ -407,22 +407,6 @@ describe('MCP Teams Tools', function () { response.json().should.eql({ status: 'okay' }) }) - it('keeps the route 404 when the team has git integration but the token is missing', async function () { - const errorResponse = { statusCode: 404, json: () => ({ code: 'not_found', error: 'Not Found' }) } - inject.onFirstCall().resolves(errorResponse) - inject.onSecondCall().resolves({ statusCode: 200, json: () => ({ tokens: [] }) }) - const response = await tool.handler({ teamId: 'team1', tokenId: 'token1' }, { inject }) - inject.secondCall.args[0].should.eql({ method: 'GET', url: '/api/v1/teams/team1/git/tokens' }) - response.should.equal(errorResponse) - }) - - it('explains a 404 as git integration being unavailable when the token list is also 404', async function () { - inject.callsFake(async () => ({ statusCode: 404, json: () => ({ code: 'not_found', error: 'Not Found' }) })) - const response = await tool.handler({ teamId: 'team1', tokenId: 'token1' }, { inject }) - response.statusCode.should.equal(404) - response.json().error.should.match(/Git integration is not enabled/) - }) - 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) From 1a653a28fb24a823364ee19730e995c1ec15a201 Mon Sep 17 00:00:00 2001 From: andypalmi Date: Mon, 12 Oct 2026 16:31:50 +0200 Subject: [PATCH 5/5] Drop the idempotent hint from the shared library delete tool since a repeated folder delete removes newer entries --- forge/ee/lib/mcp/tools/library.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/forge/ee/lib/mcp/tools/library.js b/forge/ee/lib/mcp/tools/library.js index e79409be1b..a3589f539a 100644 --- a/forge/ee/lib/mcp/tools/library.js +++ b/forge/ee/lib/mcp/tools/library.js @@ -11,8 +11,8 @@ module.exports = [ 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, so repeating a call is harmless. 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: true, openWorldHint: false }, + 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'),