From 3dfdbec1382e6a7a944164d0e897c8a6236d45f6 Mon Sep 17 00:00:00 2001 From: andypalmi Date: Tue, 28 Jul 2026 11:58:59 +0200 Subject: [PATCH 1/7] feat(mcp): add FlowFuse Tables tools for platform automation Adds platform_list_team_databases, platform_get_team_database, platform_list_database_tables, platform_get_database_table and platform_query_database_table_data tools so the MCP platform automation surface can list databases and tables and read row data for FlowFuse Tables. Database responses have their credentials stripped via a shared helper before being returned. Adds the team:database:list scope to the expert-mcp token's implicit scope list and unit tests covering the new handlers. --- forge/ee/lib/mcp/tools/teams.js | 105 ++++++++++++++ forge/ee/lib/mcp/utils.js | 10 ++ forge/routes/auth/permissions.js | 2 + .../unit/forge/ee/lib/mcp/tools/teams_spec.js | 137 ++++++++++++++++++ 4 files changed, 254 insertions(+) create mode 100644 forge/ee/lib/mcp/utils.js create mode 100644 test/unit/forge/ee/lib/mcp/tools/teams_spec.js diff --git a/forge/ee/lib/mcp/tools/teams.js b/forge/ee/lib/mcp/tools/teams.js index ecc053113b..e68080648f 100644 --- a/forge/ee/lib/mcp/tools/teams.js +++ b/forge/ee/lib/mcp/tools/teams.js @@ -1,5 +1,7 @@ const { z } = require('zod') +const { redactDatabaseCredentials } = require('../utils') + module.exports = [ { name: 'platform_list_teams', @@ -24,5 +26,108 @@ module.exports = [ const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}` }) return response } + }, + { + name: 'platform_list_team_databases', + title: 'List Team Databases', + description: `FlowFuse platform automation tool: + Lists the FlowFuse Tables databases for a team. + FlowFuse Tables is a plan-gated feature; if it is not enabled for the team's plan, the underlying API's error response is returned as-is. + The underlying API response includes a credentials object with connection details, including a password. This tool strips that object before returning results, so no credentials are ever exposed.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + teamId: z.string().describe('The ID or hashid of the team') + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/databases` }) + if (response.statusCode >= 400) { + return response + } + const databases = response.json().map(redactDatabaseCredentials) + return { + statusCode: response.statusCode, + json: () => databases + } + } + }, + { + name: 'platform_get_team_database', + title: 'Get Team Database', + description: `FlowFuse platform automation tool: + Gets a single FlowFuse Tables database for a team. + FlowFuse Tables is a plan-gated feature; if it is not enabled for the team's plan, the underlying API's error response is returned as-is. + The underlying API response includes a credentials object with connection details, including a password. This tool strips that object before returning the result, so no credentials are ever exposed.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + teamId: z.string().describe('The ID or hashid of the team'), + databaseId: z.string().describe('database hashid') + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/databases/${args.databaseId}` }) + if (response.statusCode >= 400) { + return response + } + const database = redactDatabaseCredentials(response.json()) + return { + statusCode: response.statusCode, + json: () => database + } + } + }, + { + name: 'platform_list_database_tables', + title: 'List Database Tables', + description: `FlowFuse platform automation tool: + Lists the tables defined in a FlowFuse Tables database. The full list is returned; this endpoint does not paginate. + FlowFuse Tables is a plan-gated feature; if it is not enabled for the team's plan, the underlying API's error response is returned as-is. + Use platform_get_database_table to get the full schema of a single table, or platform_query_database_table_data to read row data.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + teamId: z.string().describe('The ID or hashid of the team'), + databaseId: z.string().describe('database hashid') + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/databases/${args.databaseId}/tables` }) + return response + } + }, + { + name: 'platform_get_database_table', + title: 'Get Database Table', + description: `FlowFuse platform automation tool: + Gets the schema definition of a single table in a FlowFuse Tables database (column names, types, and constraints). Does not return row data or credentials. + FlowFuse Tables is a plan-gated feature; if it is not enabled for the team's plan, the underlying API's error response is returned as-is. + Use platform_query_database_table_data to read row data instead.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + teamId: z.string().describe('The ID or hashid of the team'), + databaseId: z.string().describe('database hashid'), + tableName: z.string().describe('Name of the database table') + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/databases/${args.databaseId}/tables/${args.tableName}` }) + return response + } + }, + { + name: 'platform_query_database_table_data', + title: 'Query Database Table Data', + description: `FlowFuse platform automation tool: + Reads the row data of a table in a FlowFuse Tables database. There are no column-filter parameters; this returns rows as stored. + At most 10 rows are returned per call (the limit is capped at 10 by the platform). + FlowFuse Tables is a plan-gated feature; if it is not enabled for the team's plan, the underlying API's error response is returned as-is. + Use platform_get_database_table first if you need to know the column names and types.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + teamId: z.string().describe('The ID or hashid of the team'), + databaseId: z.string().describe('database hashid'), + tableName: z.string().describe('Name of the database table'), + limit: z.number().int().min(1).max(10).default(10).describe('Maximum number of rows to return (1-10, default 10)') + }, + handler: async (args, { inject }) => { + const url = `/api/v1/teams/${args.teamId}/databases/${args.databaseId}/tables/${args.tableName}/data${args.limit !== undefined ? `?limit=${encodeURIComponent(args.limit)}` : ''}` + const response = await inject({ method: 'GET', url }) + return response + } } ] diff --git a/forge/ee/lib/mcp/utils.js b/forge/ee/lib/mcp/utils.js new file mode 100644 index 0000000000..8a97fbedfa --- /dev/null +++ b/forge/ee/lib/mcp/utils.js @@ -0,0 +1,10 @@ +// Strips credentials (including the password) before returning results to the caller. +function redactDatabaseCredentials (database) { + if (!database) { + return database + } + const { credentials, ...rest } = database + return rest +} + +module.exports = { redactDatabaseCredentials } diff --git a/forge/routes/auth/permissions.js b/forge/routes/auth/permissions.js index ab22033e51..c1740abaa9 100644 --- a/forge/routes/auth/permissions.js +++ b/forge/routes/auth/permissions.js @@ -76,6 +76,8 @@ const IMPLICIT_TOKEN_SCOPES = { // teams 'user:team:list', // list teams 'team:read', // get team details + // tables + 'team:database:list', // list/get databases, list/get tables, query table data // platform 'stack:list', 'flow-blueprint:list', diff --git a/test/unit/forge/ee/lib/mcp/tools/teams_spec.js b/test/unit/forge/ee/lib/mcp/tools/teams_spec.js new file mode 100644 index 0000000000..df73e73185 --- /dev/null +++ b/test/unit/forge/ee/lib/mcp/tools/teams_spec.js @@ -0,0 +1,137 @@ +const should = require('should') // eslint-disable-line no-unused-vars +const sinon = require('sinon') + +const tools = require('../../../../../../../forge/ee/lib/mcp/tools/teams') + +function getTool (name) { + return tools.find(tool => tool.name === name) +} + +describe('MCP Tables Tools', function () { + let inject + + beforeEach(function () { + inject = sinon.stub() + }) + + describe('platform_list_team_databases', function () { + const tool = getTool('platform_list_team_databases') + + it('calls the databases list endpoint for the team', async function () { + inject.resolves({ statusCode: 200, json: () => [] }) + await tool.handler({ teamId: 'team1' }, { inject }) + inject.calledOnce.should.be.true() + inject.firstCall.args[0].should.eql({ method: 'GET', url: '/api/v1/teams/team1/databases' }) + }) + + it('strips credentials from every returned database', async function () { + inject.resolves({ + statusCode: 200, + json: () => [ + { id: 'db1', name: 'one', credentials: { password: 'secret1' } }, + { id: 'db2', name: 'two', credentials: { password: 'secret2' } } + ] + }) + const response = await tool.handler({ teamId: 'team1' }, { inject }) + response.statusCode.should.equal(200) + const databases = response.json() + databases.should.eql([ + { id: 'db1', name: 'one' }, + { id: 'db2', name: 'two' } + ]) + }) + + it('passes through error responses unmodified, including any credentials', async function () { + const errorResponse = { + statusCode: 404, + json: () => [{ id: 'db1', credentials: { password: 'secret1' } }] + } + inject.resolves(errorResponse) + const response = await tool.handler({ teamId: 'team1' }, { inject }) + response.should.equal(errorResponse) + response.json().should.eql([{ id: 'db1', credentials: { password: 'secret1' } }]) + }) + }) + + describe('platform_get_team_database', function () { + const tool = getTool('platform_get_team_database') + + it('calls the single database endpoint for the team', async function () { + inject.resolves({ statusCode: 200, json: () => ({ id: 'db1' }) }) + await tool.handler({ teamId: 'team1', databaseId: 'db1' }, { inject }) + inject.calledOnce.should.be.true() + inject.firstCall.args[0].should.eql({ method: 'GET', url: '/api/v1/teams/team1/databases/db1' }) + }) + + it('strips credentials from the returned database', async function () { + inject.resolves({ + statusCode: 200, + json: () => ({ id: 'db1', name: 'one', credentials: { password: 'secret1' } }) + }) + const response = await tool.handler({ teamId: 'team1', databaseId: 'db1' }, { inject }) + response.statusCode.should.equal(200) + response.json().should.eql({ id: 'db1', name: 'one' }) + }) + + it('passes through error responses unmodified, including any credentials', async function () { + const errorResponse = { + statusCode: 400, + json: () => ({ id: 'db1', credentials: { password: 'secret1' } }) + } + inject.resolves(errorResponse) + const response = await tool.handler({ teamId: 'team1', databaseId: 'db1' }, { inject }) + response.should.equal(errorResponse) + response.json().should.eql({ id: 'db1', credentials: { password: 'secret1' } }) + }) + }) + + describe('platform_list_database_tables', function () { + const tool = getTool('platform_list_database_tables') + + it('calls the tables list endpoint and returns the response unmodified', async function () { + const injectResponse = { statusCode: 200, json: () => [{ name: 'table1' }] } + inject.resolves(injectResponse) + const response = await tool.handler({ teamId: 'team1', databaseId: 'db1' }, { inject }) + inject.firstCall.args[0].should.eql({ method: 'GET', url: '/api/v1/teams/team1/databases/db1/tables' }) + response.should.equal(injectResponse) + }) + }) + + describe('platform_get_database_table', function () { + const tool = getTool('platform_get_database_table') + + it('calls the table endpoint with the table name and returns the response unmodified', async function () { + const injectResponse = { statusCode: 200, json: () => ({ name: 'table1', columns: [] }) } + inject.resolves(injectResponse) + const response = await tool.handler({ teamId: 'team1', databaseId: 'db1', tableName: 'table1' }, { inject }) + inject.firstCall.args[0].should.eql({ method: 'GET', url: '/api/v1/teams/team1/databases/db1/tables/table1' }) + response.should.equal(injectResponse) + }) + }) + + describe('platform_query_database_table_data', function () { + const tool = getTool('platform_query_database_table_data') + + it('includes the limit query param when limit is provided', async function () { + const injectResponse = { statusCode: 200, json: () => [{ col: 'value' }] } + inject.resolves(injectResponse) + const response = await tool.handler({ teamId: 'team1', databaseId: 'db1', tableName: 'table1', limit: 5 }, { inject }) + inject.firstCall.args[0].should.eql({ + method: 'GET', + url: '/api/v1/teams/team1/databases/db1/tables/table1/data?limit=5' + }) + response.should.equal(injectResponse) + }) + + it('omits the query string when limit is undefined', async function () { + const injectResponse = { statusCode: 200, json: () => [] } + inject.resolves(injectResponse) + const response = await tool.handler({ teamId: 'team1', databaseId: 'db1', tableName: 'table1' }, { inject }) + inject.firstCall.args[0].should.eql({ + method: 'GET', + url: '/api/v1/teams/team1/databases/db1/tables/table1/data' + }) + response.should.equal(injectResponse) + }) + }) +}) From 7b75a0a8913296a0a39f2b0d3e11b8a990f59b54 Mon Sep 17 00:00:00 2001 From: andypalmi Date: Tue, 28 Jul 2026 12:18:31 +0200 Subject: [PATCH 2/7] fix(mcp): require schemaName for table-level FlowFuse Tables tools platform_get_database_table and platform_query_database_table_data now take a required schemaName, matching the schema disambiguation being added to the underlying table routes. The caller gets the schema from platform_list_database_tables, which already returns it per table. --- forge/ee/lib/mcp/tools/teams.js | 12 +++++++--- .../unit/forge/ee/lib/mcp/tools/teams_spec.js | 24 +++++++++++++------ 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/forge/ee/lib/mcp/tools/teams.js b/forge/ee/lib/mcp/tools/teams.js index e68080648f..9567ceb8c6 100644 --- a/forge/ee/lib/mcp/tools/teams.js +++ b/forge/ee/lib/mcp/tools/teams.js @@ -79,6 +79,7 @@ module.exports = [ title: 'List Database Tables', description: `FlowFuse platform automation tool: Lists the tables defined in a FlowFuse Tables database. The full list is returned; this endpoint does not paginate. + Each entry includes the schema it lives in; if the same table name appears under more than one schema, pass that schema to platform_get_database_table or platform_query_database_table_data to pick the right one. FlowFuse Tables is a plan-gated feature; if it is not enabled for the team's plan, the underlying API's error response is returned as-is. Use platform_get_database_table to get the full schema of a single table, or platform_query_database_table_data to read row data.`, annotations: { readOnlyHint: true, destructiveHint: false }, @@ -96,16 +97,19 @@ module.exports = [ title: 'Get Database Table', description: `FlowFuse platform automation tool: Gets the schema definition of a single table in a FlowFuse Tables database (column names, types, and constraints). Does not return row data or credentials. + schemaName is required, since the same table name can exist in more than one schema; get it from platform_list_database_tables. FlowFuse Tables is a plan-gated feature; if it is not enabled for the team's plan, the underlying API's error response is returned as-is. Use platform_query_database_table_data to read row data instead.`, annotations: { readOnlyHint: true, destructiveHint: false }, inputSchema: { teamId: z.string().describe('The ID or hashid of the team'), databaseId: z.string().describe('database hashid'), - tableName: z.string().describe('Name of the database table') + tableName: z.string().describe('Name of the database table'), + schemaName: z.string().describe('Schema the table lives in, as returned by platform_list_database_tables') }, handler: async (args, { inject }) => { - const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/databases/${args.databaseId}/tables/${args.tableName}` }) + const url = `/api/v1/teams/${args.teamId}/databases/${args.databaseId}/tables/${args.tableName}/${encodeURIComponent(args.schemaName)}` + const response = await inject({ method: 'GET', url }) return response } }, @@ -115,6 +119,7 @@ module.exports = [ description: `FlowFuse platform automation tool: Reads the row data of a table in a FlowFuse Tables database. There are no column-filter parameters; this returns rows as stored. At most 10 rows are returned per call (the limit is capped at 10 by the platform). + schemaName is required, since the same table name can exist in more than one schema; get it from platform_list_database_tables. FlowFuse Tables is a plan-gated feature; if it is not enabled for the team's plan, the underlying API's error response is returned as-is. Use platform_get_database_table first if you need to know the column names and types.`, annotations: { readOnlyHint: true, destructiveHint: false }, @@ -122,10 +127,11 @@ module.exports = [ teamId: z.string().describe('The ID or hashid of the team'), databaseId: z.string().describe('database hashid'), tableName: z.string().describe('Name of the database table'), + schemaName: z.string().describe('Schema the table lives in, as returned by platform_list_database_tables'), limit: z.number().int().min(1).max(10).default(10).describe('Maximum number of rows to return (1-10, default 10)') }, handler: async (args, { inject }) => { - const url = `/api/v1/teams/${args.teamId}/databases/${args.databaseId}/tables/${args.tableName}/data${args.limit !== undefined ? `?limit=${encodeURIComponent(args.limit)}` : ''}` + const url = `/api/v1/teams/${args.teamId}/databases/${args.databaseId}/tables/${args.tableName}/data/${encodeURIComponent(args.schemaName)}${args.limit !== undefined ? `?limit=${encodeURIComponent(args.limit)}` : ''}` const response = await inject({ method: 'GET', url }) return response } 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 df73e73185..2a270d9ee4 100644 --- a/test/unit/forge/ee/lib/mcp/tools/teams_spec.js +++ b/test/unit/forge/ee/lib/mcp/tools/teams_spec.js @@ -100,11 +100,11 @@ describe('MCP Tables Tools', function () { describe('platform_get_database_table', function () { const tool = getTool('platform_get_database_table') - it('calls the table endpoint with the table name and returns the response unmodified', async function () { + it('calls the table endpoint with the table name and schema, and returns the response unmodified', async function () { const injectResponse = { statusCode: 200, json: () => ({ name: 'table1', columns: [] }) } inject.resolves(injectResponse) - const response = await tool.handler({ teamId: 'team1', databaseId: 'db1', tableName: 'table1' }, { inject }) - inject.firstCall.args[0].should.eql({ method: 'GET', url: '/api/v1/teams/team1/databases/db1/tables/table1' }) + const response = await tool.handler({ teamId: 'team1', databaseId: 'db1', tableName: 'table1', schemaName: 'public' }, { inject }) + inject.firstCall.args[0].should.eql({ method: 'GET', url: '/api/v1/teams/team1/databases/db1/tables/table1/public' }) response.should.equal(injectResponse) }) }) @@ -115,10 +115,10 @@ describe('MCP Tables Tools', function () { it('includes the limit query param when limit is provided', async function () { const injectResponse = { statusCode: 200, json: () => [{ col: 'value' }] } inject.resolves(injectResponse) - const response = await tool.handler({ teamId: 'team1', databaseId: 'db1', tableName: 'table1', limit: 5 }, { inject }) + const response = await tool.handler({ teamId: 'team1', databaseId: 'db1', tableName: 'table1', schemaName: 'public', limit: 5 }, { inject }) inject.firstCall.args[0].should.eql({ method: 'GET', - url: '/api/v1/teams/team1/databases/db1/tables/table1/data?limit=5' + url: '/api/v1/teams/team1/databases/db1/tables/table1/data/public?limit=5' }) response.should.equal(injectResponse) }) @@ -126,12 +126,22 @@ describe('MCP Tables Tools', function () { it('omits the query string when limit is undefined', async function () { const injectResponse = { statusCode: 200, json: () => [] } inject.resolves(injectResponse) - const response = await tool.handler({ teamId: 'team1', databaseId: 'db1', tableName: 'table1' }, { inject }) + const response = await tool.handler({ teamId: 'team1', databaseId: 'db1', tableName: 'table1', schemaName: 'public' }, { inject }) inject.firstCall.args[0].should.eql({ method: 'GET', - url: '/api/v1/teams/team1/databases/db1/tables/table1/data' + url: '/api/v1/teams/team1/databases/db1/tables/table1/data/public' }) response.should.equal(injectResponse) }) + + it('includes a non-public schema segment', async function () { + const injectResponse = { statusCode: 200, json: () => [{ col: 'value' }] } + inject.resolves(injectResponse) + await tool.handler({ teamId: 'team1', databaseId: 'db1', tableName: 'table1', schemaName: 'custom', limit: 5 }, { inject }) + inject.firstCall.args[0].should.eql({ + method: 'GET', + url: '/api/v1/teams/team1/databases/db1/tables/table1/data/custom?limit=5' + }) + }) }) }) From 9f15b67646cdcedf705caec382dcbb9c0ff9db79 Mon Sep 17 00:00:00 2001 From: andypalmi Date: Tue, 28 Jul 2026 12:50:23 +0200 Subject: [PATCH 3/7] test(mcp): cover platform_list_teams, platform_get_team, and utils.js teams_spec.js only tested the ported Tables tools; the pre-existing list/get team tools and the redactDatabaseCredentials null-guard had no coverage. --- .../unit/forge/ee/lib/mcp/tools/teams_spec.js | 24 +++++++++++++++++++ test/unit/forge/ee/lib/mcp/utils_spec.js | 17 +++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 test/unit/forge/ee/lib/mcp/utils_spec.js 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 2a270d9ee4..d241b303c4 100644 --- a/test/unit/forge/ee/lib/mcp/tools/teams_spec.js +++ b/test/unit/forge/ee/lib/mcp/tools/teams_spec.js @@ -14,6 +14,30 @@ describe('MCP Tables Tools', function () { inject = sinon.stub() }) + describe('platform_list_teams', function () { + const tool = getTool('platform_list_teams') + + it('calls the user teams endpoint and returns the response unmodified', async function () { + const injectResponse = { statusCode: 200, json: () => [{ id: 'team1' }] } + inject.resolves(injectResponse) + const response = await tool.handler({}, { inject }) + inject.firstCall.args[0].should.eql({ method: 'GET', url: '/api/v1/user/teams' }) + response.should.equal(injectResponse) + }) + }) + + describe('platform_get_team', function () { + const tool = getTool('platform_get_team') + + it('calls the team endpoint and returns the response unmodified', async function () { + const injectResponse = { statusCode: 200, json: () => ({ id: 'team1' }) } + inject.resolves(injectResponse) + const response = await tool.handler({ teamId: 'team1' }, { inject }) + inject.firstCall.args[0].should.eql({ method: 'GET', url: '/api/v1/teams/team1' }) + response.should.equal(injectResponse) + }) + }) + describe('platform_list_team_databases', function () { const tool = getTool('platform_list_team_databases') diff --git a/test/unit/forge/ee/lib/mcp/utils_spec.js b/test/unit/forge/ee/lib/mcp/utils_spec.js new file mode 100644 index 0000000000..cdd563bdba --- /dev/null +++ b/test/unit/forge/ee/lib/mcp/utils_spec.js @@ -0,0 +1,17 @@ +const should = require('should') // eslint-disable-line no-unused-vars + +const { redactDatabaseCredentials } = require('../../../../../../forge/ee/lib/mcp/utils') + +describe('MCP utils', function () { + describe('redactDatabaseCredentials', function () { + it('strips the credentials field from a database object', function () { + const result = redactDatabaseCredentials({ id: 'db1', name: 'one', credentials: { password: 'secret' } }) + result.should.eql({ id: 'db1', name: 'one' }) + }) + + it('returns falsy input unchanged', function () { + should(redactDatabaseCredentials(null)).equal(null) + should(redactDatabaseCredentials(undefined)).equal(undefined) + }) + }) +}) From e6ed14ca6dd11fdd7c486cd55e6be1ad8ed6599c Mon Sep 17 00:00:00 2001 From: andypalmi Date: Tue, 28 Jul 2026 13:55:51 +0200 Subject: [PATCH 4/7] feat(mcp): add output schemas to Tables tools, drop credential-redaction wording platform_list_team_databases and platform_get_database_table now wrap their results in an object instead of a bare array, and all five Tables tools declare a zod outputSchema describing their response shape. Also removed the descriptions' explicit mention of stripping credentials, since it doesn't affect how the tool is called. --- forge/comms/platformAutomation.js | 3 +- forge/ee/lib/mcp/toolLoader.js | 3 + forge/ee/lib/mcp/tools/teams.js | 60 ++++++++++++++++--- .../unit/forge/ee/lib/mcp/tools/teams_spec.js | 38 ++++++++---- 4 files changed, 83 insertions(+), 21 deletions(-) diff --git a/forge/comms/platformAutomation.js b/forge/comms/platformAutomation.js index 920e4c42b5..e0968f0912 100644 --- a/forge/comms/platformAutomation.js +++ b/forge/comms/platformAutomation.js @@ -33,11 +33,12 @@ class PlatformAutomationHandler { if (!this._fullToolDefinitions) { const { loadToolDefinitions } = require('../ee/lib/mcp/toolLoader') this._fullToolDefinitions = loadToolDefinitions() - this._wireToolDefinitions = this._fullToolDefinitions.map(({ name, title, description, inputSchema, annotations }) => ({ + this._wireToolDefinitions = this._fullToolDefinitions.map(({ name, title, description, inputSchema, outputSchema, annotations }) => ({ name, title, description, inputSchema: inputSchema && z.toJSONSchema(z.object(inputSchema)), + outputSchema: outputSchema && z.toJSONSchema(z.object(outputSchema)), annotations })) } diff --git a/forge/ee/lib/mcp/toolLoader.js b/forge/ee/lib/mcp/toolLoader.js index 15a41035b1..39822405f2 100644 --- a/forge/ee/lib/mcp/toolLoader.js +++ b/forge/ee/lib/mcp/toolLoader.js @@ -42,6 +42,9 @@ function registerTools (server, toolDefinitions, inject, checkScope, options = { if (tool.inputSchema && Object.keys(tool.inputSchema).length > 0) { config.inputSchema = tool.inputSchema } + if (tool.outputSchema && Object.keys(tool.outputSchema).length > 0) { + config.outputSchema = tool.outputSchema + } server.registerTool(tool.name, config, async (args) => { const scopeError = checkScope(tool) diff --git a/forge/ee/lib/mcp/tools/teams.js b/forge/ee/lib/mcp/tools/teams.js index 9567ceb8c6..43d73cd99f 100644 --- a/forge/ee/lib/mcp/tools/teams.js +++ b/forge/ee/lib/mcp/tools/teams.js @@ -32,12 +32,17 @@ module.exports = [ title: 'List Team Databases', description: `FlowFuse platform automation tool: Lists the FlowFuse Tables databases for a team. - FlowFuse Tables is a plan-gated feature; if it is not enabled for the team's plan, the underlying API's error response is returned as-is. - The underlying API response includes a credentials object with connection details, including a password. This tool strips that object before returning results, so no credentials are ever exposed.`, + FlowFuse Tables is a plan-gated feature; if it is not enabled for the team's plan, the underlying API's error response is returned as-is.`, annotations: { readOnlyHint: true, destructiveHint: false }, inputSchema: { teamId: z.string().describe('The ID or hashid of the team') }, + outputSchema: { + databases: z.array(z.object({ + id: z.string(), + name: z.string() + }).passthrough()) + }, handler: async (args, { inject }) => { const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/databases` }) if (response.statusCode >= 400) { @@ -46,7 +51,7 @@ module.exports = [ const databases = response.json().map(redactDatabaseCredentials) return { statusCode: response.statusCode, - json: () => databases + json: () => ({ databases }) } } }, @@ -55,13 +60,18 @@ module.exports = [ title: 'Get Team Database', description: `FlowFuse platform automation tool: Gets a single FlowFuse Tables database for a team. - FlowFuse Tables is a plan-gated feature; if it is not enabled for the team's plan, the underlying API's error response is returned as-is. - The underlying API response includes a credentials object with connection details, including a password. This tool strips that object before returning the result, so no credentials are ever exposed.`, + FlowFuse Tables is a plan-gated feature; if it is not enabled for the team's plan, the underlying API's error response is returned as-is.`, annotations: { readOnlyHint: true, destructiveHint: false }, inputSchema: { teamId: z.string().describe('The ID or hashid of the team'), databaseId: z.string().describe('database hashid') }, + outputSchema: { + database: z.object({ + id: z.string(), + name: z.string() + }).passthrough() + }, handler: async (args, { inject }) => { const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/databases/${args.databaseId}` }) if (response.statusCode >= 400) { @@ -70,7 +80,7 @@ module.exports = [ const database = redactDatabaseCredentials(response.json()) return { statusCode: response.statusCode, - json: () => database + json: () => ({ database }) } } }, @@ -87,6 +97,14 @@ module.exports = [ teamId: z.string().describe('The ID or hashid of the team'), databaseId: z.string().describe('database hashid') }, + outputSchema: { + count: z.number(), + tables: z.array(z.object({ + name: z.string(), + schema: z.string() + })), + meta: z.record(z.string(), z.any()) + }, handler: async (args, { inject }) => { const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/databases/${args.databaseId}/tables` }) return response @@ -96,7 +114,7 @@ module.exports = [ name: 'platform_get_database_table', title: 'Get Database Table', description: `FlowFuse platform automation tool: - Gets the schema definition of a single table in a FlowFuse Tables database (column names, types, and constraints). Does not return row data or credentials. + Gets the schema definition of a single table in a FlowFuse Tables database (column names, types, and constraints). schemaName is required, since the same table name can exist in more than one schema; get it from platform_list_database_tables. FlowFuse Tables is a plan-gated feature; if it is not enabled for the team's plan, the underlying API's error response is returned as-is. Use platform_query_database_table_data to read row data instead.`, @@ -107,10 +125,31 @@ module.exports = [ tableName: z.string().describe('Name of the database table'), schemaName: z.string().describe('Schema the table lives in, as returned by platform_list_database_tables') }, + outputSchema: { + database: z.string(), + tableName: z.string(), + schemaName: z.string(), + columns: z.array(z.object({ + name: z.string(), + type: z.string() + }).passthrough()) + }, handler: async (args, { inject }) => { const url = `/api/v1/teams/${args.teamId}/databases/${args.databaseId}/tables/${args.tableName}/${encodeURIComponent(args.schemaName)}` const response = await inject({ method: 'GET', url }) - return response + if (response.statusCode >= 400) { + return response + } + const columns = response.json() + return { + statusCode: response.statusCode, + json: () => ({ + database: args.databaseId, + tableName: args.tableName, + schemaName: args.schemaName, + columns + }) + } } }, { @@ -130,6 +169,11 @@ module.exports = [ schemaName: z.string().describe('Schema the table lives in, as returned by platform_list_database_tables'), limit: z.number().int().min(1).max(10).default(10).describe('Maximum number of rows to return (1-10, default 10)') }, + outputSchema: { + count: z.number(), + rows: z.array(z.record(z.string(), z.any())), + meta: z.record(z.string(), z.any()) + }, handler: async (args, { inject }) => { const url = `/api/v1/teams/${args.teamId}/databases/${args.databaseId}/tables/${args.tableName}/data/${encodeURIComponent(args.schemaName)}${args.limit !== undefined ? `?limit=${encodeURIComponent(args.limit)}` : ''}` const response = await inject({ method: 'GET', url }) 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 d241b303c4..c865893b2b 100644 --- a/test/unit/forge/ee/lib/mcp/tools/teams_spec.js +++ b/test/unit/forge/ee/lib/mcp/tools/teams_spec.js @@ -48,7 +48,7 @@ describe('MCP Tables Tools', function () { inject.firstCall.args[0].should.eql({ method: 'GET', url: '/api/v1/teams/team1/databases' }) }) - it('strips credentials from every returned database', async function () { + it('strips credentials from every returned database and wraps them in a databases object', async function () { inject.resolves({ statusCode: 200, json: () => [ @@ -58,11 +58,12 @@ describe('MCP Tables Tools', function () { }) const response = await tool.handler({ teamId: 'team1' }, { inject }) response.statusCode.should.equal(200) - const databases = response.json() - databases.should.eql([ - { id: 'db1', name: 'one' }, - { id: 'db2', name: 'two' } - ]) + response.json().should.eql({ + databases: [ + { id: 'db1', name: 'one' }, + { id: 'db2', name: 'two' } + ] + }) }) it('passes through error responses unmodified, including any credentials', async function () { @@ -87,14 +88,14 @@ describe('MCP Tables Tools', function () { inject.firstCall.args[0].should.eql({ method: 'GET', url: '/api/v1/teams/team1/databases/db1' }) }) - it('strips credentials from the returned database', async function () { + it('strips credentials from the returned database and wraps it in a database object', async function () { inject.resolves({ statusCode: 200, json: () => ({ id: 'db1', name: 'one', credentials: { password: 'secret1' } }) }) const response = await tool.handler({ teamId: 'team1', databaseId: 'db1' }, { inject }) response.statusCode.should.equal(200) - response.json().should.eql({ id: 'db1', name: 'one' }) + response.json().should.eql({ database: { id: 'db1', name: 'one' } }) }) it('passes through error responses unmodified, including any credentials', async function () { @@ -124,12 +125,25 @@ describe('MCP Tables Tools', function () { describe('platform_get_database_table', function () { const tool = getTool('platform_get_database_table') - it('calls the table endpoint with the table name and schema, and returns the response unmodified', async function () { - const injectResponse = { statusCode: 200, json: () => ({ name: 'table1', columns: [] }) } - inject.resolves(injectResponse) + it('calls the table endpoint with the table name and schema, and wraps the columns with the identifying fields', async function () { + const columns = [{ name: 'id', type: 'integer' }] + inject.resolves({ statusCode: 200, json: () => columns }) const response = await tool.handler({ teamId: 'team1', databaseId: 'db1', tableName: 'table1', schemaName: 'public' }, { inject }) inject.firstCall.args[0].should.eql({ method: 'GET', url: '/api/v1/teams/team1/databases/db1/tables/table1/public' }) - response.should.equal(injectResponse) + response.statusCode.should.equal(200) + response.json().should.eql({ + database: 'db1', + tableName: 'table1', + schemaName: 'public', + columns + }) + }) + + it('passes through error responses unmodified', 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: 'table1', schemaName: 'public' }, { inject }) + response.should.equal(errorResponse) }) }) From 4ccfc5a5a689bef261d5a70ffc4a6c30730969e0 Mon Sep 17 00:00:00 2001 From: andypalmi Date: Tue, 28 Jul 2026 14:25:49 +0200 Subject: [PATCH 5/7] refactor(mcp): split Tables tools into their own file with shared schemas Move the 5 FlowFuse Tables tools out of teams.js into tools/tables.js, and extract their repeated input/output field schemas (teamId, databaseId, tableName, schemaName, database shape, count/meta records) into a new tool-schemas/tables.js module, since the same fields are reused across both the input and output schemas. Also drops the plan-gated-feature caveat from tool descriptions and switches remaining .passthrough() calls to the non-deprecated .loose(). --- forge/ee/lib/mcp/tool-schemas/tables.js | 24 +++ forge/ee/lib/mcp/tools/tables.js | 157 +++++++++++++++++ forge/ee/lib/mcp/tools/teams.js | 155 ----------------- .../forge/ee/lib/mcp/tools/tables_spec.js | 161 ++++++++++++++++++ .../unit/forge/ee/lib/mcp/tools/teams_spec.js | 147 +--------------- 5 files changed, 343 insertions(+), 301 deletions(-) create mode 100644 forge/ee/lib/mcp/tool-schemas/tables.js create mode 100644 forge/ee/lib/mcp/tools/tables.js create mode 100644 test/unit/forge/ee/lib/mcp/tools/tables_spec.js diff --git a/forge/ee/lib/mcp/tool-schemas/tables.js b/forge/ee/lib/mcp/tool-schemas/tables.js new file mode 100644 index 0000000000..417c496b89 --- /dev/null +++ b/forge/ee/lib/mcp/tool-schemas/tables.js @@ -0,0 +1,24 @@ +const { z } = require('zod') + +// Shared field schemas reused across the FlowFuse Tables tools, since the same +// database/table identifiers appear in both the input and output schemas. +const teamIdSchema = z.string().describe('The ID or hashid of the team') +const databaseIdSchema = z.string().describe('The hashid of the FlowFuse Tables database') +const tableNameSchema = z.string().describe('Name of the database table') +const schemaNameSchema = z.string().describe('Schema the table lives in, as returned by platform_list_database_tables') +const databaseSchema = z.object({ + id: z.string(), + name: z.string() +}).loose() +const countSchema = z.number() +const recordSchema = z.record(z.string(), z.any()) + +module.exports = { + teamIdSchema, + databaseIdSchema, + tableNameSchema, + schemaNameSchema, + databaseSchema, + countSchema, + recordSchema +} diff --git a/forge/ee/lib/mcp/tools/tables.js b/forge/ee/lib/mcp/tools/tables.js new file mode 100644 index 0000000000..74501e60c1 --- /dev/null +++ b/forge/ee/lib/mcp/tools/tables.js @@ -0,0 +1,157 @@ +const { z } = require('zod') + +const { + teamIdSchema, + databaseIdSchema, + tableNameSchema, + schemaNameSchema, + databaseSchema, + countSchema, + recordSchema +} = require('../tool-schemas/tables') +const { redactDatabaseCredentials } = require('../utils') + +module.exports = [ + { + name: 'platform_list_team_databases', + title: 'List Team Databases', + description: `FlowFuse platform automation tool: + Lists the FlowFuse Tables databases for a team.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + teamId: teamIdSchema + }, + outputSchema: { + databases: z.array(databaseSchema) + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/databases` }) + if (response.statusCode >= 400) { + return response + } + const databases = response.json().map(redactDatabaseCredentials) + return { + statusCode: response.statusCode, + json: () => ({ databases }) + } + } + }, + { + name: 'platform_get_team_database', + title: 'Get Team Database', + description: `FlowFuse platform automation tool: + Gets a single FlowFuse Tables database for a team.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + teamId: teamIdSchema, + databaseId: databaseIdSchema + }, + outputSchema: { + database: databaseSchema + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/databases/${args.databaseId}` }) + if (response.statusCode >= 400) { + return response + } + const database = redactDatabaseCredentials(response.json()) + return { + statusCode: response.statusCode, + json: () => ({ database }) + } + } + }, + { + name: 'platform_list_database_tables', + title: 'List Database Tables', + description: `FlowFuse platform automation tool: + Lists the tables defined in a FlowFuse Tables database. The full list is returned; this endpoint does not paginate. + Each entry includes the schema it lives in; if the same table name appears under more than one schema, pass that schema to platform_get_database_table or platform_query_database_table_data to pick the right one. + Use platform_get_database_table to get the full schema of a single table, or platform_query_database_table_data to read row data.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + teamId: teamIdSchema, + databaseId: databaseIdSchema + }, + outputSchema: { + count: countSchema, + tables: z.array(z.object({ + name: z.string(), + schema: z.string() + })), + meta: recordSchema + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/databases/${args.databaseId}/tables` }) + return response + } + }, + { + name: 'platform_get_database_table', + title: 'Get Database Table', + description: `FlowFuse platform automation tool: + Gets the schema definition of a single table in a FlowFuse Tables database (column names, types, and constraints). + schemaName is required, since the same table name can exist in more than one schema; get it from platform_list_database_tables. + Use platform_query_database_table_data to read row data instead.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + teamId: teamIdSchema, + databaseId: databaseIdSchema, + tableName: tableNameSchema, + schemaName: schemaNameSchema + }, + outputSchema: { + database: databaseIdSchema, + tableName: tableNameSchema, + schemaName: schemaNameSchema, + columns: z.array(z.object({ + name: z.string(), + type: z.string() + }).loose()) + }, + handler: async (args, { inject }) => { + const url = `/api/v1/teams/${args.teamId}/databases/${args.databaseId}/tables/${args.tableName}/${encodeURIComponent(args.schemaName)}` + const response = await inject({ method: 'GET', url }) + if (response.statusCode >= 400) { + return response + } + const columns = response.json() + return { + statusCode: response.statusCode, + json: () => ({ + database: args.databaseId, + tableName: args.tableName, + schemaName: args.schemaName, + columns + }) + } + } + }, + { + name: 'platform_query_database_table_data', + title: 'Query Database Table Data', + description: `FlowFuse platform automation tool: + Reads the row data of a table in a FlowFuse Tables database. There are no column-filter parameters; this returns rows as stored. + At most 10 rows are returned per call (the limit is capped at 10 by the platform). + schemaName is required, since the same table name can exist in more than one schema; get it from platform_list_database_tables. + Use platform_get_database_table first if you need to know the column names and types.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + teamId: teamIdSchema, + databaseId: databaseIdSchema, + tableName: tableNameSchema, + schemaName: schemaNameSchema, + limit: z.number().int().min(1).max(10).default(10).describe('Maximum number of rows to return (1-10, default 10)') + }, + outputSchema: { + count: countSchema, + rows: z.array(recordSchema), + meta: recordSchema + }, + handler: async (args, { inject }) => { + const url = `/api/v1/teams/${args.teamId}/databases/${args.databaseId}/tables/${args.tableName}/data/${encodeURIComponent(args.schemaName)}${args.limit !== undefined ? `?limit=${encodeURIComponent(args.limit)}` : ''}` + const response = await inject({ method: 'GET', url }) + return response + } + } +] diff --git a/forge/ee/lib/mcp/tools/teams.js b/forge/ee/lib/mcp/tools/teams.js index 43d73cd99f..ecc053113b 100644 --- a/forge/ee/lib/mcp/tools/teams.js +++ b/forge/ee/lib/mcp/tools/teams.js @@ -1,7 +1,5 @@ const { z } = require('zod') -const { redactDatabaseCredentials } = require('../utils') - module.exports = [ { name: 'platform_list_teams', @@ -26,158 +24,5 @@ module.exports = [ const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}` }) return response } - }, - { - name: 'platform_list_team_databases', - title: 'List Team Databases', - description: `FlowFuse platform automation tool: - Lists the FlowFuse Tables databases for a team. - FlowFuse Tables is a plan-gated feature; if it is not enabled for the team's plan, the underlying API's error response is returned as-is.`, - annotations: { readOnlyHint: true, destructiveHint: false }, - inputSchema: { - teamId: z.string().describe('The ID or hashid of the team') - }, - outputSchema: { - databases: z.array(z.object({ - id: z.string(), - name: z.string() - }).passthrough()) - }, - handler: async (args, { inject }) => { - const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/databases` }) - if (response.statusCode >= 400) { - return response - } - const databases = response.json().map(redactDatabaseCredentials) - return { - statusCode: response.statusCode, - json: () => ({ databases }) - } - } - }, - { - name: 'platform_get_team_database', - title: 'Get Team Database', - description: `FlowFuse platform automation tool: - Gets a single FlowFuse Tables database for a team. - FlowFuse Tables is a plan-gated feature; if it is not enabled for the team's plan, the underlying API's error response is returned as-is.`, - annotations: { readOnlyHint: true, destructiveHint: false }, - inputSchema: { - teamId: z.string().describe('The ID or hashid of the team'), - databaseId: z.string().describe('database hashid') - }, - outputSchema: { - database: z.object({ - id: z.string(), - name: z.string() - }).passthrough() - }, - handler: async (args, { inject }) => { - const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/databases/${args.databaseId}` }) - if (response.statusCode >= 400) { - return response - } - const database = redactDatabaseCredentials(response.json()) - return { - statusCode: response.statusCode, - json: () => ({ database }) - } - } - }, - { - name: 'platform_list_database_tables', - title: 'List Database Tables', - description: `FlowFuse platform automation tool: - Lists the tables defined in a FlowFuse Tables database. The full list is returned; this endpoint does not paginate. - Each entry includes the schema it lives in; if the same table name appears under more than one schema, pass that schema to platform_get_database_table or platform_query_database_table_data to pick the right one. - FlowFuse Tables is a plan-gated feature; if it is not enabled for the team's plan, the underlying API's error response is returned as-is. - Use platform_get_database_table to get the full schema of a single table, or platform_query_database_table_data to read row data.`, - annotations: { readOnlyHint: true, destructiveHint: false }, - inputSchema: { - teamId: z.string().describe('The ID or hashid of the team'), - databaseId: z.string().describe('database hashid') - }, - outputSchema: { - count: z.number(), - tables: z.array(z.object({ - name: z.string(), - schema: z.string() - })), - meta: z.record(z.string(), z.any()) - }, - handler: async (args, { inject }) => { - const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/databases/${args.databaseId}/tables` }) - return response - } - }, - { - name: 'platform_get_database_table', - title: 'Get Database Table', - description: `FlowFuse platform automation tool: - Gets the schema definition of a single table in a FlowFuse Tables database (column names, types, and constraints). - schemaName is required, since the same table name can exist in more than one schema; get it from platform_list_database_tables. - FlowFuse Tables is a plan-gated feature; if it is not enabled for the team's plan, the underlying API's error response is returned as-is. - Use platform_query_database_table_data to read row data instead.`, - annotations: { readOnlyHint: true, destructiveHint: false }, - inputSchema: { - teamId: z.string().describe('The ID or hashid of the team'), - databaseId: z.string().describe('database hashid'), - tableName: z.string().describe('Name of the database table'), - schemaName: z.string().describe('Schema the table lives in, as returned by platform_list_database_tables') - }, - outputSchema: { - database: z.string(), - tableName: z.string(), - schemaName: z.string(), - columns: z.array(z.object({ - name: z.string(), - type: z.string() - }).passthrough()) - }, - handler: async (args, { inject }) => { - const url = `/api/v1/teams/${args.teamId}/databases/${args.databaseId}/tables/${args.tableName}/${encodeURIComponent(args.schemaName)}` - const response = await inject({ method: 'GET', url }) - if (response.statusCode >= 400) { - return response - } - const columns = response.json() - return { - statusCode: response.statusCode, - json: () => ({ - database: args.databaseId, - tableName: args.tableName, - schemaName: args.schemaName, - columns - }) - } - } - }, - { - name: 'platform_query_database_table_data', - title: 'Query Database Table Data', - description: `FlowFuse platform automation tool: - Reads the row data of a table in a FlowFuse Tables database. There are no column-filter parameters; this returns rows as stored. - At most 10 rows are returned per call (the limit is capped at 10 by the platform). - schemaName is required, since the same table name can exist in more than one schema; get it from platform_list_database_tables. - FlowFuse Tables is a plan-gated feature; if it is not enabled for the team's plan, the underlying API's error response is returned as-is. - Use platform_get_database_table first if you need to know the column names and types.`, - annotations: { readOnlyHint: true, destructiveHint: false }, - inputSchema: { - teamId: z.string().describe('The ID or hashid of the team'), - databaseId: z.string().describe('database hashid'), - tableName: z.string().describe('Name of the database table'), - schemaName: z.string().describe('Schema the table lives in, as returned by platform_list_database_tables'), - limit: z.number().int().min(1).max(10).default(10).describe('Maximum number of rows to return (1-10, default 10)') - }, - outputSchema: { - count: z.number(), - rows: z.array(z.record(z.string(), z.any())), - meta: z.record(z.string(), z.any()) - }, - handler: async (args, { inject }) => { - const url = `/api/v1/teams/${args.teamId}/databases/${args.databaseId}/tables/${args.tableName}/data/${encodeURIComponent(args.schemaName)}${args.limit !== undefined ? `?limit=${encodeURIComponent(args.limit)}` : ''}` - const response = await inject({ method: 'GET', url }) - 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 new file mode 100644 index 0000000000..e458eee761 --- /dev/null +++ b/test/unit/forge/ee/lib/mcp/tools/tables_spec.js @@ -0,0 +1,161 @@ +const should = require('should') // eslint-disable-line no-unused-vars +const sinon = require('sinon') + +const tools = require('../../../../../../../forge/ee/lib/mcp/tools/tables') + +function getTool (name) { + return tools.find(tool => tool.name === name) +} + +describe('MCP Tables Tools', function () { + let inject + + beforeEach(function () { + inject = sinon.stub() + }) + + describe('platform_list_team_databases', function () { + const tool = getTool('platform_list_team_databases') + + it('calls the databases list endpoint for the team', async function () { + inject.resolves({ statusCode: 200, json: () => [] }) + await tool.handler({ teamId: 'team1' }, { inject }) + inject.calledOnce.should.be.true() + inject.firstCall.args[0].should.eql({ method: 'GET', url: '/api/v1/teams/team1/databases' }) + }) + + it('strips credentials from every returned database and wraps them in a databases object', async function () { + inject.resolves({ + statusCode: 200, + json: () => [ + { id: 'db1', name: 'one', credentials: { password: 'secret1' } }, + { id: 'db2', name: 'two', credentials: { password: 'secret2' } } + ] + }) + const response = await tool.handler({ teamId: 'team1' }, { inject }) + response.statusCode.should.equal(200) + response.json().should.eql({ + databases: [ + { id: 'db1', name: 'one' }, + { id: 'db2', name: 'two' } + ] + }) + }) + + it('passes through error responses unmodified, including any credentials', async function () { + const errorResponse = { + statusCode: 404, + json: () => [{ id: 'db1', credentials: { password: 'secret1' } }] + } + inject.resolves(errorResponse) + const response = await tool.handler({ teamId: 'team1' }, { inject }) + response.should.equal(errorResponse) + response.json().should.eql([{ id: 'db1', credentials: { password: 'secret1' } }]) + }) + }) + + describe('platform_get_team_database', function () { + const tool = getTool('platform_get_team_database') + + it('calls the single database endpoint for the team', async function () { + inject.resolves({ statusCode: 200, json: () => ({ id: 'db1' }) }) + await tool.handler({ teamId: 'team1', databaseId: 'db1' }, { inject }) + inject.calledOnce.should.be.true() + inject.firstCall.args[0].should.eql({ method: 'GET', url: '/api/v1/teams/team1/databases/db1' }) + }) + + it('strips credentials from the returned database and wraps it in a database object', async function () { + inject.resolves({ + statusCode: 200, + json: () => ({ id: 'db1', name: 'one', credentials: { password: 'secret1' } }) + }) + const response = await tool.handler({ teamId: 'team1', databaseId: 'db1' }, { inject }) + response.statusCode.should.equal(200) + response.json().should.eql({ database: { id: 'db1', name: 'one' } }) + }) + + it('passes through error responses unmodified, including any credentials', async function () { + const errorResponse = { + statusCode: 400, + json: () => ({ id: 'db1', credentials: { password: 'secret1' } }) + } + inject.resolves(errorResponse) + const response = await tool.handler({ teamId: 'team1', databaseId: 'db1' }, { inject }) + response.should.equal(errorResponse) + response.json().should.eql({ id: 'db1', credentials: { password: 'secret1' } }) + }) + }) + + describe('platform_list_database_tables', function () { + const tool = getTool('platform_list_database_tables') + + it('calls the tables list endpoint and returns the response unmodified', async function () { + const injectResponse = { statusCode: 200, json: () => [{ name: 'table1' }] } + inject.resolves(injectResponse) + const response = await tool.handler({ teamId: 'team1', databaseId: 'db1' }, { inject }) + inject.firstCall.args[0].should.eql({ method: 'GET', url: '/api/v1/teams/team1/databases/db1/tables' }) + response.should.equal(injectResponse) + }) + }) + + describe('platform_get_database_table', function () { + const tool = getTool('platform_get_database_table') + + it('calls the table endpoint with the table name and schema, and wraps the columns with the identifying fields', async function () { + const columns = [{ name: 'id', type: 'integer' }] + inject.resolves({ statusCode: 200, json: () => columns }) + const response = await tool.handler({ teamId: 'team1', databaseId: 'db1', tableName: 'table1', schemaName: 'public' }, { inject }) + inject.firstCall.args[0].should.eql({ method: 'GET', url: '/api/v1/teams/team1/databases/db1/tables/table1/public' }) + response.statusCode.should.equal(200) + response.json().should.eql({ + database: 'db1', + tableName: 'table1', + schemaName: 'public', + columns + }) + }) + + it('passes through error responses unmodified', 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: 'table1', schemaName: 'public' }, { inject }) + response.should.equal(errorResponse) + }) + }) + + describe('platform_query_database_table_data', function () { + const tool = getTool('platform_query_database_table_data') + + it('includes the limit query param when limit is provided', async function () { + const injectResponse = { statusCode: 200, json: () => [{ col: 'value' }] } + inject.resolves(injectResponse) + const response = await tool.handler({ teamId: 'team1', databaseId: 'db1', tableName: 'table1', schemaName: 'public', limit: 5 }, { inject }) + inject.firstCall.args[0].should.eql({ + method: 'GET', + url: '/api/v1/teams/team1/databases/db1/tables/table1/data/public?limit=5' + }) + response.should.equal(injectResponse) + }) + + it('omits the query string when limit is undefined', async function () { + const injectResponse = { statusCode: 200, json: () => [] } + inject.resolves(injectResponse) + const response = await tool.handler({ teamId: 'team1', databaseId: 'db1', tableName: 'table1', schemaName: 'public' }, { inject }) + inject.firstCall.args[0].should.eql({ + method: 'GET', + url: '/api/v1/teams/team1/databases/db1/tables/table1/data/public' + }) + response.should.equal(injectResponse) + }) + + it('includes a non-public schema segment', async function () { + const injectResponse = { statusCode: 200, json: () => [{ col: 'value' }] } + inject.resolves(injectResponse) + await tool.handler({ teamId: 'team1', databaseId: 'db1', tableName: 'table1', schemaName: 'custom', limit: 5 }, { inject }) + inject.firstCall.args[0].should.eql({ + method: 'GET', + url: '/api/v1/teams/team1/databases/db1/tables/table1/data/custom?limit=5' + }) + }) + }) +}) 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 c865893b2b..513a4bf731 100644 --- a/test/unit/forge/ee/lib/mcp/tools/teams_spec.js +++ b/test/unit/forge/ee/lib/mcp/tools/teams_spec.js @@ -7,7 +7,7 @@ function getTool (name) { return tools.find(tool => tool.name === name) } -describe('MCP Tables Tools', function () { +describe('MCP Teams Tools', function () { let inject beforeEach(function () { @@ -37,149 +37,4 @@ describe('MCP Tables Tools', function () { response.should.equal(injectResponse) }) }) - - describe('platform_list_team_databases', function () { - const tool = getTool('platform_list_team_databases') - - it('calls the databases list endpoint for the team', async function () { - inject.resolves({ statusCode: 200, json: () => [] }) - await tool.handler({ teamId: 'team1' }, { inject }) - inject.calledOnce.should.be.true() - inject.firstCall.args[0].should.eql({ method: 'GET', url: '/api/v1/teams/team1/databases' }) - }) - - it('strips credentials from every returned database and wraps them in a databases object', async function () { - inject.resolves({ - statusCode: 200, - json: () => [ - { id: 'db1', name: 'one', credentials: { password: 'secret1' } }, - { id: 'db2', name: 'two', credentials: { password: 'secret2' } } - ] - }) - const response = await tool.handler({ teamId: 'team1' }, { inject }) - response.statusCode.should.equal(200) - response.json().should.eql({ - databases: [ - { id: 'db1', name: 'one' }, - { id: 'db2', name: 'two' } - ] - }) - }) - - it('passes through error responses unmodified, including any credentials', async function () { - const errorResponse = { - statusCode: 404, - json: () => [{ id: 'db1', credentials: { password: 'secret1' } }] - } - inject.resolves(errorResponse) - const response = await tool.handler({ teamId: 'team1' }, { inject }) - response.should.equal(errorResponse) - response.json().should.eql([{ id: 'db1', credentials: { password: 'secret1' } }]) - }) - }) - - describe('platform_get_team_database', function () { - const tool = getTool('platform_get_team_database') - - it('calls the single database endpoint for the team', async function () { - inject.resolves({ statusCode: 200, json: () => ({ id: 'db1' }) }) - await tool.handler({ teamId: 'team1', databaseId: 'db1' }, { inject }) - inject.calledOnce.should.be.true() - inject.firstCall.args[0].should.eql({ method: 'GET', url: '/api/v1/teams/team1/databases/db1' }) - }) - - it('strips credentials from the returned database and wraps it in a database object', async function () { - inject.resolves({ - statusCode: 200, - json: () => ({ id: 'db1', name: 'one', credentials: { password: 'secret1' } }) - }) - const response = await tool.handler({ teamId: 'team1', databaseId: 'db1' }, { inject }) - response.statusCode.should.equal(200) - response.json().should.eql({ database: { id: 'db1', name: 'one' } }) - }) - - it('passes through error responses unmodified, including any credentials', async function () { - const errorResponse = { - statusCode: 400, - json: () => ({ id: 'db1', credentials: { password: 'secret1' } }) - } - inject.resolves(errorResponse) - const response = await tool.handler({ teamId: 'team1', databaseId: 'db1' }, { inject }) - response.should.equal(errorResponse) - response.json().should.eql({ id: 'db1', credentials: { password: 'secret1' } }) - }) - }) - - describe('platform_list_database_tables', function () { - const tool = getTool('platform_list_database_tables') - - it('calls the tables list endpoint and returns the response unmodified', async function () { - const injectResponse = { statusCode: 200, json: () => [{ name: 'table1' }] } - inject.resolves(injectResponse) - const response = await tool.handler({ teamId: 'team1', databaseId: 'db1' }, { inject }) - inject.firstCall.args[0].should.eql({ method: 'GET', url: '/api/v1/teams/team1/databases/db1/tables' }) - response.should.equal(injectResponse) - }) - }) - - describe('platform_get_database_table', function () { - const tool = getTool('platform_get_database_table') - - it('calls the table endpoint with the table name and schema, and wraps the columns with the identifying fields', async function () { - const columns = [{ name: 'id', type: 'integer' }] - inject.resolves({ statusCode: 200, json: () => columns }) - const response = await tool.handler({ teamId: 'team1', databaseId: 'db1', tableName: 'table1', schemaName: 'public' }, { inject }) - inject.firstCall.args[0].should.eql({ method: 'GET', url: '/api/v1/teams/team1/databases/db1/tables/table1/public' }) - response.statusCode.should.equal(200) - response.json().should.eql({ - database: 'db1', - tableName: 'table1', - schemaName: 'public', - columns - }) - }) - - it('passes through error responses unmodified', 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: 'table1', schemaName: 'public' }, { inject }) - response.should.equal(errorResponse) - }) - }) - - describe('platform_query_database_table_data', function () { - const tool = getTool('platform_query_database_table_data') - - it('includes the limit query param when limit is provided', async function () { - const injectResponse = { statusCode: 200, json: () => [{ col: 'value' }] } - inject.resolves(injectResponse) - const response = await tool.handler({ teamId: 'team1', databaseId: 'db1', tableName: 'table1', schemaName: 'public', limit: 5 }, { inject }) - inject.firstCall.args[0].should.eql({ - method: 'GET', - url: '/api/v1/teams/team1/databases/db1/tables/table1/data/public?limit=5' - }) - response.should.equal(injectResponse) - }) - - it('omits the query string when limit is undefined', async function () { - const injectResponse = { statusCode: 200, json: () => [] } - inject.resolves(injectResponse) - const response = await tool.handler({ teamId: 'team1', databaseId: 'db1', tableName: 'table1', schemaName: 'public' }, { inject }) - inject.firstCall.args[0].should.eql({ - method: 'GET', - url: '/api/v1/teams/team1/databases/db1/tables/table1/data/public' - }) - response.should.equal(injectResponse) - }) - - it('includes a non-public schema segment', async function () { - const injectResponse = { statusCode: 200, json: () => [{ col: 'value' }] } - inject.resolves(injectResponse) - await tool.handler({ teamId: 'team1', databaseId: 'db1', tableName: 'table1', schemaName: 'custom', limit: 5 }, { inject }) - inject.firstCall.args[0].should.eql({ - method: 'GET', - url: '/api/v1/teams/team1/databases/db1/tables/table1/data/custom?limit=5' - }) - }) - }) }) From 8341c7b083de5ebb8b804ed2aeba8d34d92c8cac Mon Sep 17 00:00:00 2001 From: andypalmi Date: Tue, 28 Jul 2026 17:47:27 +0200 Subject: [PATCH 6/7] fix(tables): URI-encode tableName in Tables tool URLs schemaName was already encoded in these two handlers; tableName wasn't, even though it's an unconstrained string that can contain characters that break URL path segments. --- forge/ee/lib/mcp/tools/tables.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/forge/ee/lib/mcp/tools/tables.js b/forge/ee/lib/mcp/tools/tables.js index 74501e60c1..653d57eb9e 100644 --- a/forge/ee/lib/mcp/tools/tables.js +++ b/forge/ee/lib/mcp/tools/tables.js @@ -110,7 +110,7 @@ module.exports = [ }).loose()) }, handler: async (args, { inject }) => { - const url = `/api/v1/teams/${args.teamId}/databases/${args.databaseId}/tables/${args.tableName}/${encodeURIComponent(args.schemaName)}` + const url = `/api/v1/teams/${args.teamId}/databases/${args.databaseId}/tables/${encodeURIComponent(args.tableName)}/${encodeURIComponent(args.schemaName)}` const response = await inject({ method: 'GET', url }) if (response.statusCode >= 400) { return response @@ -149,7 +149,7 @@ module.exports = [ meta: recordSchema }, handler: async (args, { inject }) => { - const url = `/api/v1/teams/${args.teamId}/databases/${args.databaseId}/tables/${args.tableName}/data/${encodeURIComponent(args.schemaName)}${args.limit !== undefined ? `?limit=${encodeURIComponent(args.limit)}` : ''}` + const url = `/api/v1/teams/${args.teamId}/databases/${args.databaseId}/tables/${encodeURIComponent(args.tableName)}/data/${encodeURIComponent(args.schemaName)}${args.limit !== undefined ? `?limit=${encodeURIComponent(args.limit)}` : ''}` const response = await inject({ method: 'GET', url }) return response } From e78db22b10b6449e0ec6659236915669d59604eb Mon Sep 17 00:00:00 2001 From: Andrea Palmieri <76187074+andypalmi@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:48:32 +0200 Subject: [PATCH 7/7] Update forge/ee/lib/mcp/tool-schemas/tables.js Co-authored-by: Stephen McLaughlin <44235289+Steve-Mcl@users.noreply.github.com> --- forge/ee/lib/mcp/tool-schemas/tables.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/forge/ee/lib/mcp/tool-schemas/tables.js b/forge/ee/lib/mcp/tool-schemas/tables.js index 417c496b89..179c790273 100644 --- a/forge/ee/lib/mcp/tool-schemas/tables.js +++ b/forge/ee/lib/mcp/tool-schemas/tables.js @@ -2,7 +2,7 @@ const { z } = require('zod') // Shared field schemas reused across the FlowFuse Tables tools, since the same // database/table identifiers appear in both the input and output schemas. -const teamIdSchema = z.string().describe('The ID or hashid of the team') +const teamIdSchema = z.string().describe('The hashid of the team') const databaseIdSchema = z.string().describe('The hashid of the FlowFuse Tables database') const tableNameSchema = z.string().describe('Name of the database table') const schemaNameSchema = z.string().describe('Schema the table lives in, as returned by platform_list_database_tables')