From a69c81107a1f19612bba6460285eebcd3718f2b5 Mon Sep 17 00:00:00 2001 From: andypalmi Date: Tue, 22 Sep 2026 17:44:49 +0200 Subject: [PATCH 1/4] Add MCP tool to create a database table --- forge/ee/lib/mcp/tools/tables.js | 30 +++++++++++++++++++ .../forge/ee/lib/mcp/tools/tables_spec.js | 26 ++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/forge/ee/lib/mcp/tools/tables.js b/forge/ee/lib/mcp/tools/tables.js index a686c4466f..5dacb66ec2 100644 --- a/forge/ee/lib/mcp/tools/tables.js +++ b/forge/ee/lib/mcp/tools/tables.js @@ -159,5 +159,35 @@ module.exports = [ const response = await inject({ method: 'GET', url }) return response } + }, + { + name: 'platform_create_database_table', + title: 'Create Database Table', + description: `FlowFuse platform automation tool: + Creates a new table in a FlowFuse Tables database. Both name and at least one column are required. + Each column needs a name and a type; the supported types are bigint, bigserial, boolean, date, timestamptz, real, double precision and text. Any other type is rejected. + Fails with 409 if a table of that name already exists in the database.`, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + inputSchema: { + teamId: teamIdSchema, + databaseId: databaseIdSchema, + name: z.string().min(1).describe('Name for the new table'), + columns: z.array(z.object({ + name: z.string().min(1).describe('Column name'), + type: z.string().describe('Column data type; one of bigint, bigserial, boolean, date, timestamptz, real, double precision, text'), + nullable: z.boolean().optional().describe('Whether the column allows NULL. Defaults to NOT NULL when omitted'), + default: z.string().nullable().optional().describe('Default value, or null for none'), + generated: z.boolean().optional().describe('Whether the column value is generated'), + maxLength: z.number().nullable().optional().describe('Maximum length, or null for unbounded') + })).min(1).describe('Column definitions for the new table') + }, + handler: async (args, { inject }) => { + const response = await inject({ + method: 'POST', + url: `/api/v1/teams/${args.teamId}/databases/${args.databaseId}/tables`, + payload: { name: args.name, columns: args.columns } + }) + return response + } } ] diff --git a/test/unit/forge/ee/lib/mcp/tools/tables_spec.js b/test/unit/forge/ee/lib/mcp/tools/tables_spec.js index e458eee761..028136b9af 100644 --- a/test/unit/forge/ee/lib/mcp/tools/tables_spec.js +++ b/test/unit/forge/ee/lib/mcp/tools/tables_spec.js @@ -158,4 +158,30 @@ describe('MCP Tables Tools', function () { }) }) }) + + describe('platform_create_database_table', function () { + const tool = getTool('platform_create_database_table') + + it('posts the name and columns to the tables endpoint and returns the response unmodified', async function () { + const columns = [{ name: 'id', type: 'bigint' }, { name: 'label', type: 'text', nullable: true }] + const injectResponse = { statusCode: 201, json: () => ({}) } + inject.resolves(injectResponse) + const response = await tool.handler({ teamId: 'team1', databaseId: 'db1', name: 'orders', columns }, { inject }) + inject.calledOnce.should.be.true() + inject.firstCall.args[0].should.eql({ + method: 'POST', + url: '/api/v1/teams/team1/databases/db1/tables', + payload: { name: 'orders', columns } + }) + response.should.equal(injectResponse) + }) + + it('passes through error responses unmodified', async function () { + const errorResponse = { statusCode: 409, json: () => ({ code: 'table_exists', error: 'Table already exists' }) } + inject.resolves(errorResponse) + const columns = [{ name: 'id', type: 'bigint' }] + const response = await tool.handler({ teamId: 'team1', databaseId: 'db1', name: 'orders', columns }, { inject }) + response.should.equal(errorResponse) + }) + }) }) From 0a9794e8fb39878125c4703d3ccb500bb9e088d8 Mon Sep 17 00:00:00 2001 From: andypalmi Date: Mon, 5 Oct 2026 16:58:00 +0200 Subject: [PATCH 2/4] Accept a schema in the create database table tool The tool takes an optional schema, defaulting to public, validated with the same rules as the create table API. --- forge/ee/lib/mcp/tools/tables.js | 10 ++++++++-- .../unit/forge/ee/lib/mcp/tools/tables_spec.js | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/forge/ee/lib/mcp/tools/tables.js b/forge/ee/lib/mcp/tools/tables.js index 5dacb66ec2..79232ffa85 100644 --- a/forge/ee/lib/mcp/tools/tables.js +++ b/forge/ee/lib/mcp/tools/tables.js @@ -165,13 +165,15 @@ module.exports = [ title: 'Create Database Table', description: `FlowFuse platform automation tool: Creates a new table in a FlowFuse Tables database. Both name and at least one column are required. + The table is created in the given schema, or in public when none is given. A schema that does not exist yet is created. Each column needs a name and a type; the supported types are bigint, bigserial, boolean, date, timestamptz, real, double precision and text. Any other type is rejected. - Fails with 409 if a table of that name already exists in the database.`, + Fails with 409 if a table of that name already exists in the same schema.`, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, inputSchema: { teamId: teamIdSchema, databaseId: databaseIdSchema, name: z.string().min(1).describe('Name for the new table'), + schema: z.string().regex(/^(?!pg_)(?!information_schema$)[a-zA-Z_][a-zA-Z0-9_]{0,62}$/).optional().describe('Schema to create the table in. Defaults to public, and is created if it does not exist'), columns: z.array(z.object({ name: z.string().min(1).describe('Column name'), type: z.string().describe('Column data type; one of bigint, bigserial, boolean, date, timestamptz, real, double precision, text'), @@ -182,10 +184,14 @@ module.exports = [ })).min(1).describe('Column definitions for the new table') }, handler: async (args, { inject }) => { + const payload = { name: args.name, columns: args.columns } + if (args.schema) { + payload.schema = args.schema + } const response = await inject({ method: 'POST', url: `/api/v1/teams/${args.teamId}/databases/${args.databaseId}/tables`, - payload: { name: args.name, columns: args.columns } + payload }) return response } diff --git a/test/unit/forge/ee/lib/mcp/tools/tables_spec.js b/test/unit/forge/ee/lib/mcp/tools/tables_spec.js index 028136b9af..ebb12c072f 100644 --- a/test/unit/forge/ee/lib/mcp/tools/tables_spec.js +++ b/test/unit/forge/ee/lib/mcp/tools/tables_spec.js @@ -1,5 +1,6 @@ const should = require('should') // eslint-disable-line no-unused-vars const sinon = require('sinon') +const { z } = require('zod') const tools = require('../../../../../../../forge/ee/lib/mcp/tools/tables') @@ -176,6 +177,23 @@ describe('MCP Tables Tools', function () { response.should.equal(injectResponse) }) + it('passes the schema through when one is given', async function () { + const columns = [{ name: 'id', type: 'bigint' }] + inject.resolves({ statusCode: 201, json: () => ({}) }) + await tool.handler({ teamId: 'team1', databaseId: 'db1', name: 'orders', schema: 'reports', columns }, { inject }) + inject.firstCall.args[0].payload.should.eql({ name: 'orders', columns, schema: 'reports' }) + }) + + it('accepts valid schema names and rejects ones Postgres cannot create', function () { + const schema = z.object(tool.inputSchema).shape.schema + for (const valid of ['public', 'Reports', '_staging', 'a'.repeat(63)]) { + schema.safeParse(valid).success.should.be.true(`'${valid}' should be accepted`) + } + for (const invalid of ['', 'a'.repeat(64), '1reports', 'my-schema', 'pg_reports', 'information_schema']) { + schema.safeParse(invalid).success.should.be.false(`'${invalid}' should be rejected`) + } + }) + it('passes through error responses unmodified', async function () { const errorResponse = { statusCode: 409, json: () => ({ code: 'table_exists', error: 'Table already exists' }) } inject.resolves(errorResponse) From 047a8aa072cab69c7e6ba1080ad06168418c9d05 Mon Sep 17 00:00:00 2001 From: andypalmi Date: Mon, 5 Oct 2026 17:01:15 +0200 Subject: [PATCH 3/4] Restrict create database table column types to an enum The input schema now carries the supported types and the schema default, so the tool description no longer repeats them. --- forge/ee/lib/mcp/tools/tables.js | 6 ++---- test/unit/forge/ee/lib/mcp/tools/tables_spec.js | 6 ++++++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/forge/ee/lib/mcp/tools/tables.js b/forge/ee/lib/mcp/tools/tables.js index 79232ffa85..b507611211 100644 --- a/forge/ee/lib/mcp/tools/tables.js +++ b/forge/ee/lib/mcp/tools/tables.js @@ -164,9 +164,7 @@ module.exports = [ name: 'platform_create_database_table', title: 'Create Database Table', description: `FlowFuse platform automation tool: - Creates a new table in a FlowFuse Tables database. Both name and at least one column are required. - The table is created in the given schema, or in public when none is given. A schema that does not exist yet is created. - Each column needs a name and a type; the supported types are bigint, bigserial, boolean, date, timestamptz, real, double precision and text. Any other type is rejected. + Creates a new table in a FlowFuse Tables database. Fails with 409 if a table of that name already exists in the same schema.`, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, inputSchema: { @@ -176,7 +174,7 @@ module.exports = [ schema: z.string().regex(/^(?!pg_)(?!information_schema$)[a-zA-Z_][a-zA-Z0-9_]{0,62}$/).optional().describe('Schema to create the table in. Defaults to public, and is created if it does not exist'), columns: z.array(z.object({ name: z.string().min(1).describe('Column name'), - type: z.string().describe('Column data type; one of bigint, bigserial, boolean, date, timestamptz, real, double precision, text'), + type: z.enum(['bigint', 'bigserial', 'boolean', 'date', 'timestamptz', 'real', 'double precision', 'text']).describe('Column data type'), nullable: z.boolean().optional().describe('Whether the column allows NULL. Defaults to NOT NULL when omitted'), default: z.string().nullable().optional().describe('Default value, or null for none'), generated: z.boolean().optional().describe('Whether the column value is generated'), diff --git a/test/unit/forge/ee/lib/mcp/tools/tables_spec.js b/test/unit/forge/ee/lib/mcp/tools/tables_spec.js index ebb12c072f..2ae3031dd7 100644 --- a/test/unit/forge/ee/lib/mcp/tools/tables_spec.js +++ b/test/unit/forge/ee/lib/mcp/tools/tables_spec.js @@ -194,6 +194,12 @@ describe('MCP Tables Tools', function () { } }) + it('accepts only the column types the database driver supports', function () { + const columns = z.object(tool.inputSchema).shape.columns + columns.safeParse([{ name: 'id', type: 'double precision' }]).success.should.be.true() + columns.safeParse([{ name: 'id', type: 'varchar' }]).success.should.be.false() + }) + it('passes through error responses unmodified', async function () { const errorResponse = { statusCode: 409, json: () => ({ code: 'table_exists', error: 'Table already exists' }) } inject.resolves(errorResponse) From 3a6e940bd0daba9a1907c6f18685dcac818a5126 Mon Sep 17 00:00:00 2001 From: andypalmi Date: Mon, 5 Oct 2026 18:02:25 +0200 Subject: [PATCH 4/4] Return the created table from the create database table tool The create table route replies to a successful create with an empty body, which failed to parse as the tool result. The tool now returns the table name and schema instead. --- forge/ee/lib/mcp/tools/tables.js | 15 ++++++++++++++- test/unit/forge/ee/lib/mcp/tools/tables_spec.js | 15 ++++++++------- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/forge/ee/lib/mcp/tools/tables.js b/forge/ee/lib/mcp/tools/tables.js index b507611211..d4feca75cc 100644 --- a/forge/ee/lib/mcp/tools/tables.js +++ b/forge/ee/lib/mcp/tools/tables.js @@ -181,6 +181,12 @@ module.exports = [ maxLength: z.number().nullable().optional().describe('Maximum length, or null for unbounded') })).min(1).describe('Column definitions for the new table') }, + outputSchema: { + table: z.object({ + name: z.string(), + schema: z.string() + }) + }, handler: async (args, { inject }) => { const payload = { name: args.name, columns: args.columns } if (args.schema) { @@ -191,7 +197,14 @@ module.exports = [ url: `/api/v1/teams/${args.teamId}/databases/${args.databaseId}/tables`, payload }) - return response + if (response.statusCode >= 400) { + return response + } + // The route replies to a successful create with an empty body + return { + statusCode: response.statusCode, + json: () => ({ table: { name: args.name, schema: args.schema || 'public' } }) + } } } ] diff --git a/test/unit/forge/ee/lib/mcp/tools/tables_spec.js b/test/unit/forge/ee/lib/mcp/tools/tables_spec.js index 2ae3031dd7..578829ca35 100644 --- a/test/unit/forge/ee/lib/mcp/tools/tables_spec.js +++ b/test/unit/forge/ee/lib/mcp/tools/tables_spec.js @@ -163,10 +163,9 @@ describe('MCP Tables Tools', function () { describe('platform_create_database_table', function () { const tool = getTool('platform_create_database_table') - it('posts the name and columns to the tables endpoint and returns the response unmodified', async function () { + it('posts the name and columns to the tables endpoint and returns the table in the public schema', async function () { const columns = [{ name: 'id', type: 'bigint' }, { name: 'label', type: 'text', nullable: true }] - const injectResponse = { statusCode: 201, json: () => ({}) } - inject.resolves(injectResponse) + inject.resolves({ statusCode: 201, json: () => JSON.parse('') }) const response = await tool.handler({ teamId: 'team1', databaseId: 'db1', name: 'orders', columns }, { inject }) inject.calledOnce.should.be.true() inject.firstCall.args[0].should.eql({ @@ -174,14 +173,16 @@ describe('MCP Tables Tools', function () { url: '/api/v1/teams/team1/databases/db1/tables', payload: { name: 'orders', columns } }) - response.should.equal(injectResponse) + response.statusCode.should.equal(201) + response.json().should.eql({ table: { name: 'orders', schema: 'public' } }) }) - it('passes the schema through when one is given', async function () { + it('passes the schema through when one is given and returns it', async function () { const columns = [{ name: 'id', type: 'bigint' }] - inject.resolves({ statusCode: 201, json: () => ({}) }) - await tool.handler({ teamId: 'team1', databaseId: 'db1', name: 'orders', schema: 'reports', columns }, { inject }) + inject.resolves({ statusCode: 201, json: () => JSON.parse('') }) + const response = await tool.handler({ teamId: 'team1', databaseId: 'db1', name: 'orders', schema: 'reports', columns }, { inject }) inject.firstCall.args[0].payload.should.eql({ name: 'orders', columns, schema: 'reports' }) + response.json().should.eql({ table: { name: 'orders', schema: 'reports' } }) }) it('accepts valid schema names and rejects ones Postgres cannot create', function () {