Skip to content
10 changes: 6 additions & 4 deletions forge/comms/platformAutomation.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,18 @@ 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) {
const items = (tools || []).map(t => JSON.stringify({
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
Expand Down Expand Up @@ -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
}))
Expand Down
24 changes: 24 additions & 0 deletions forge/ee/lib/mcp/tool-schemas/tables.js
Original file line number Diff line number Diff line change
@@ -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
}
3 changes: 3 additions & 0 deletions forge/ee/lib/mcp/toolLoader.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
157 changes: 157 additions & 0 deletions forge/ee/lib/mcp/tools/tables.js
Original file line number Diff line number Diff line change
@@ -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
}
}
]
10 changes: 10 additions & 0 deletions forge/ee/lib/mcp/utils.js
Original file line number Diff line number Diff line change
@@ -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 }
2 changes: 2 additions & 0 deletions forge/routes/auth/permissions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading