Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions forge/ee/lib/mcp/tools/tables.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,5 +159,52 @@ 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.
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.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'),
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) {
payload.schema = args.schema
}
const response = await inject({
method: 'POST',
url: `/api/v1/teams/${args.teamId}/databases/${args.databaseId}/tables`,
payload
})
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' } })
}
}
}
]
51 changes: 51 additions & 0 deletions test/unit/forge/ee/lib/mcp/tools/tables_spec.js
Original file line number Diff line number Diff line change
@@ -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')

Expand Down Expand Up @@ -158,4 +159,54 @@ 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 table in the public schema', async function () {
const columns = [{ name: 'id', type: 'bigint' }, { name: 'label', type: 'text', nullable: true }]
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({
method: 'POST',
url: '/api/v1/teams/team1/databases/db1/tables',
payload: { name: 'orders', columns }
})
response.statusCode.should.equal(201)
response.json().should.eql({ table: { name: 'orders', schema: 'public' } })
})

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: () => 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 () {
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('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)
const columns = [{ name: 'id', type: 'bigint' }]
const response = await tool.handler({ teamId: 'team1', databaseId: 'db1', name: 'orders', columns }, { inject })
response.should.equal(errorResponse)
})
})
})
Loading