diff --git a/forge/comms/platformAutomation.js b/forge/comms/platformAutomation.js index e25e9dfe91..f23e9edb86 100644 --- a/forge/comms/platformAutomation.js +++ b/forge/comms/platformAutomation.js @@ -6,10 +6,10 @@ const { default: z } = require('zod') /** * Cheap, non-cryptographic fingerprint of the platform tool catalog, over each tool's - * name/title/description/inputSchema/annotations/_meta. Sorted for stability across - * enumeration order, so a caller can detect catalog changes before pulling the full list. + * name/title/description/inputSchema/outputSchema/annotations/_meta. Sorted for stability + * across enumeration order, so a caller can detect catalog changes before pulling the full list. * - * @param {Array<{name:string,title?:string,description?:string,inputSchema?:object,annotations?:object,_meta?:object}>} tools + * @param {Array<{name:string,title?:string,description?:string,inputSchema?:object,outputSchema?:object,annotations?:object,_meta?:object}>} tools * @returns {string} */ function computeCatalogHash (tools) { @@ -17,6 +17,7 @@ function computeCatalogHash (tools) { n: t.name, d: t.description || '', s: t.inputSchema || null, + o: t.outputSchema || null, a: t.annotations || null, m: t._meta || null, t: t.title || null @@ -62,11 +63,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, _meta }) => ({ + this._wireToolDefinitions = this._fullToolDefinitions.map(({ name, title, description, inputSchema, outputSchema, annotations, _meta }) => ({ name, title, description, inputSchema: inputSchema && z.toJSONSchema(z.object(inputSchema)), + outputSchema: outputSchema && z.toJSONSchema(z.object(outputSchema)), annotations, _meta })) 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..179c790273 --- /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 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/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/tables.js b/forge/ee/lib/mcp/tools/tables.js new file mode 100644 index 0000000000..653d57eb9e --- /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/${encodeURIComponent(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/${encodeURIComponent(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/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/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 new file mode 100644 index 0000000000..513a4bf731 --- /dev/null +++ b/test/unit/forge/ee/lib/mcp/tools/teams_spec.js @@ -0,0 +1,40 @@ +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 Teams Tools', function () { + let inject + + beforeEach(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) + }) + }) +}) 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) + }) + }) +})