diff --git a/packages/mcp-server-supabase/src/pg-meta/tables.sql b/packages/mcp-server-supabase/src/pg-meta/tables.sql index d0bb9df3..378ea12c 100644 --- a/packages/mcp-server-supabase/src/pg-meta/tables.sql +++ b/packages/mcp-server-supabase/src/pg-meta/tables.sql @@ -56,24 +56,26 @@ FROM c.conname as constraint_name, nsa.nspname as source_schema, csa.relname as source_table_name, - sa.attname as source_column_name, + array_agg(sa.attname order by cols.ord) as source_columns, nta.nspname as target_table_schema, cta.relname as target_table_name, - ta.attname as target_column_name + array_agg(ta.attname order by cols.ord) as target_columns from pg_constraint c - join ( - pg_attribute sa - join pg_class csa on sa.attrelid = csa.oid - join pg_namespace nsa on csa.relnamespace = nsa.oid - ) on sa.attrelid = c.conrelid and sa.attnum = any (c.conkey) - join ( - pg_attribute ta - join pg_class cta on ta.attrelid = cta.oid - join pg_namespace nta on cta.relnamespace = nta.oid - ) on ta.attrelid = c.confrelid and ta.attnum = any (c.confkey) + join lateral unnest(c.conkey, c.confkey) + with ordinality as cols(conkey, confkey, ord) on true + join pg_class csa on csa.oid = c.conrelid + join pg_namespace nsa on nsa.oid = csa.relnamespace + join pg_attribute sa + on sa.attrelid = c.conrelid and sa.attnum = cols.conkey + join pg_class cta on cta.oid = c.confrelid + join pg_namespace nta on nta.oid = cta.relnamespace + join pg_attribute ta + on ta.attrelid = c.confrelid and ta.attnum = cols.confkey where c.contype = 'f' + group by + c.oid, c.conname, nsa.nspname, csa.relname, nta.nspname, cta.relname ) as relationships on (relationships.source_schema = nc.nspname and relationships.source_table_name = c.relname) or (relationships.target_table_schema = nc.nspname and relationships.target_table_name = c.relname) @@ -95,4 +97,4 @@ group by c.relforcerowsecurity, c.relreplident, nc.nspname, - pk.primary_keys + pk.primary_keys \ No newline at end of file diff --git a/packages/mcp-server-supabase/src/pg-meta/types.ts b/packages/mcp-server-supabase/src/pg-meta/types.ts index 5f6e066d..1e4bb744 100644 --- a/packages/mcp-server-supabase/src/pg-meta/types.ts +++ b/packages/mcp-server-supabase/src/pg-meta/types.ts @@ -12,10 +12,10 @@ export const postgresRelationshipSchema = z.object({ constraint_name: z.string(), source_schema: z.string(), source_table_name: z.string(), - source_column_name: z.string(), + source_columns: z.array(z.string()), target_table_schema: z.string(), target_table_name: z.string(), - target_column_name: z.string(), + target_columns: z.array(z.string()), }); export const postgresColumnSchema = z.object({ diff --git a/packages/mcp-server-supabase/src/server.test.ts b/packages/mcp-server-supabase/src/server.test.ts index d08564de..477189e3 100644 --- a/packages/mcp-server-supabase/src/server.test.ts +++ b/packages/mcp-server-supabase/src/server.test.ts @@ -1108,14 +1108,469 @@ describe('tools', () => { primary_keys: ['id'], foreign_key_constraints: [ expect.objectContaining({ - source: 'public.orders.user_id', - target: 'public.users.id', + source_table: 'public.orders', + source_columns: ['user_id'], + target_table: 'public.users', + target_columns: ['id'], }), ], }) ); }); + test('composite FK is grouped as one constraint with positionally ordered columns', async () => { + const { callTool } = await setup(); + + const org = await createOrganization({ + name: 'My Org', + plan: 'free', + allowed_release_channels: ['ga'], + }); + + const project = await createProject({ + name: 'Project 1', + region: 'us-east-1', + organization_id: org.id, + }); + project.status = 'ACTIVE_HEALTHY'; + + await project.db.exec(` + create table parent ( + y int, + x int, + primary key (y, x) + ); + create table child ( + b int, + a int, + constraint child_parent_fk + foreign key (b, a) references parent (y, x) + ); + `); + + const result = await callTool({ + name: 'list_tables', + arguments: { + project_id: project.id, + schemas: ['public'], + verbose: true, + }, + }); + + const childTable = result.tables.find( + (t: { name: string }) => t.name === 'public.child' + ); + + // exactly one constraint row - not one per column pair + expect(childTable.foreign_key_constraints).toHaveLength(1); + expect(childTable.foreign_key_constraints[0]).toEqual( + expect.objectContaining({ + name: 'child_parent_fk', + source_table: 'public.child', + source_columns: ['b', 'a'], + target_table: 'public.parent', + target_columns: ['y', 'x'], + }) + ); + }); + + test('single-column FK is represented with one-element arrays', async () => { + const { callTool } = await setup(); + + const org = await createOrganization({ + name: 'My Org', + plan: 'free', + allowed_release_channels: ['ga'], + }); + + const project = await createProject({ + name: 'Project 1', + region: 'us-east-1', + organization_id: org.id, + }); + project.status = 'ACTIVE_HEALTHY'; + + await project.db.exec(` + create table parent ( + id int primary key + ); + create table child ( + parent_id int, + constraint child_parent_fk + foreign key (parent_id) references parent (id) + ); + `); + + const result = await callTool({ + name: 'list_tables', + arguments: { + project_id: project.id, + schemas: ['public'], + verbose: true, + }, + }); + + const childTable = result.tables.find( + (t: { name: string }) => t.name === 'public.child' + ); + + expect(childTable.foreign_key_constraints).toHaveLength(1); + expect(childTable.foreign_key_constraints[0]).toEqual( + expect.objectContaining({ + name: 'child_parent_fk', + source_table: 'public.child', + source_columns: ['parent_id'], + target_table: 'public.parent', + target_columns: ['id'], + }) + ); + }); + + test('self-referential composite FK is reported once with correct pairing', async () => { + const { callTool } = await setup(); + + const org = await createOrganization({ + name: 'My Org', + plan: 'free', + allowed_release_channels: ['ga'], + }); + + const project = await createProject({ + name: 'Project 1', + region: 'us-east-1', + organization_id: org.id, + }); + project.status = 'ACTIVE_HEALTHY'; + + await project.db.exec(` + create table node ( + a int, + b int, + parent_a int, + parent_b int, + primary key (a, b), + constraint node_parent_fk + foreign key (parent_a, parent_b) references node (a, b) + ); + `); + + const result = await callTool({ + name: 'list_tables', + arguments: { + project_id: project.id, + schemas: ['public'], + verbose: true, + }, + }); + + const nodeTable = result.tables.find( + (t: { name: string }) => t.name === 'public.node' + ); + + const selfFk = nodeTable.foreign_key_constraints.filter( + (fk: { name: string }) => fk.name === 'node_parent_fk' + ); + expect(selfFk).toHaveLength(1); + expect(selfFk[0]).toEqual( + expect.objectContaining({ + source_table: 'public.node', + source_columns: ['parent_a', 'parent_b'], + target_table: 'public.node', + target_columns: ['a', 'b'], + }) + ); + }); + + test('two independent composite FKs between the same tables stay separate', async () => { + const { callTool } = await setup(); + + const org = await createOrganization({ + name: 'My Org', + plan: 'free', + allowed_release_channels: ['ga'], + }); + + const project = await createProject({ + name: 'Project 1', + region: 'us-east-1', + organization_id: org.id, + }); + project.status = 'ACTIVE_HEALTHY'; + + await project.db.exec(` + create table parent ( + x int, + y int, + primary key (x, y) + ); + create table child ( + a1 int, + a2 int, + b1 int, + b2 int, + constraint child_fk_a + foreign key (a1, a2) references parent (x, y), + constraint child_fk_b + foreign key (b1, b2) references parent (x, y) + ); + `); + + const result = await callTool({ + name: 'list_tables', + arguments: { + project_id: project.id, + schemas: ['public'], + verbose: true, + }, + }); + + const childTable = result.tables.find( + (t: { name: string }) => t.name === 'public.child' + ); + + const fkA = childTable.foreign_key_constraints.find( + (fk: { name: string }) => fk.name === 'child_fk_a' + ); + const fkB = childTable.foreign_key_constraints.find( + (fk: { name: string }) => fk.name === 'child_fk_b' + ); + expect(fkA).toEqual( + expect.objectContaining({ + source_columns: ['a1', 'a2'], + target_columns: ['x', 'y'], + }) + ); + expect(fkB).toEqual( + expect.objectContaining({ + source_columns: ['b1', 'b2'], + target_columns: ['x', 'y'], + }) + ); + }); + + test('three-column composite FK preserves column order', async () => { + const { callTool } = await setup(); + + const org = await createOrganization({ + name: 'My Org', + plan: 'free', + allowed_release_channels: ['ga'], + }); + + const project = await createProject({ + name: 'Project 1', + region: 'us-east-1', + organization_id: org.id, + }); + project.status = 'ACTIVE_HEALTHY'; + + await project.db.exec(` + create table parent ( + p int, + q int, + r int, + primary key (p, q, r) + ); + create table child ( + c int, + b int, + a int, + constraint child_parent_fk + foreign key (c, b, a) references parent (p, q, r) + ); + `); + + const result = await callTool({ + name: 'list_tables', + arguments: { + project_id: project.id, + schemas: ['public'], + verbose: true, + }, + }); + + const childTable = result.tables.find( + (t: { name: string }) => t.name === 'public.child' + ); + + expect(childTable.foreign_key_constraints).toHaveLength(1); + expect(childTable.foreign_key_constraints[0]).toEqual( + expect.objectContaining({ + source_columns: ['c', 'b', 'a'], + target_columns: ['p', 'q', 'r'], + }) + ); + }); + + test('cross-schema composite FK is schema-qualified on both sides', async () => { + const { callTool } = await setup(); + + const org = await createOrganization({ + name: 'My Org', + plan: 'free', + allowed_release_channels: ['ga'], + }); + + const project = await createProject({ + name: 'Project 1', + region: 'us-east-1', + organization_id: org.id, + }); + project.status = 'ACTIVE_HEALTHY'; + + await project.db.exec(` + create schema other; + create table other.parent ( + x int, + y int, + primary key (x, y) + ); + create table child ( + a int, + b int, + constraint child_parent_fk + foreign key (a, b) references other.parent (x, y) + ); + `); + + const result = await callTool({ + name: 'list_tables', + arguments: { + project_id: project.id, + schemas: ['public', 'other'], + verbose: true, + }, + }); + + const childTable = result.tables.find( + (t: { name: string }) => t.name === 'public.child' + ); + + expect(childTable.foreign_key_constraints).toHaveLength(1); + expect(childTable.foreign_key_constraints[0]).toEqual( + expect.objectContaining({ + source_table: 'public.child', + source_columns: ['a', 'b'], + target_table: 'other.parent', + target_columns: ['x', 'y'], + }) + ); + }); + + test('composite FK referencing a non-primary unique constraint is grouped correctly', async () => { + const { callTool } = await setup(); + + const org = await createOrganization({ + name: 'My Org', + plan: 'free', + allowed_release_channels: ['ga'], + }); + + const project = await createProject({ + name: 'Project 1', + region: 'us-east-1', + organization_id: org.id, + }); + project.status = 'ACTIVE_HEALTHY'; + + await project.db.exec(` + create table parent ( + id int primary key, + x int, + y int, + constraint parent_xy_unique unique (x, y) + ); + create table child ( + a int, + b int, + constraint child_parent_fk + foreign key (a, b) references parent (x, y) + ); + `); + + const result = await callTool({ + name: 'list_tables', + arguments: { + project_id: project.id, + schemas: ['public'], + verbose: true, + }, + }); + + const childTable = result.tables.find( + (t: { name: string }) => t.name === 'public.child' + ); + + expect(childTable.foreign_key_constraints).toHaveLength(1); + expect(childTable.foreign_key_constraints[0]).toEqual( + expect.objectContaining({ + name: 'child_parent_fk', + source_columns: ['a', 'b'], + target_columns: ['x', 'y'], + }) + ); + }); + + test('same constraint name on different tables is not merged', async () => { + const { callTool } = await setup(); + + const org = await createOrganization({ + name: 'My Org', + plan: 'free', + allowed_release_channels: ['ga'], + }); + + const project = await createProject({ + name: 'Project 1', + region: 'us-east-1', + organization_id: org.id, + }); + project.status = 'ACTIVE_HEALTHY'; + + await project.db.exec(` + create table parent ( + id int primary key + ); + create table child_a ( + parent_id int, + constraint fk_parent + foreign key (parent_id) references parent (id) + ); + create table child_b ( + parent_id int, + constraint fk_parent + foreign key (parent_id) references parent (id) + ); + `); + + const result = await callTool({ + name: 'list_tables', + arguments: { + project_id: project.id, + schemas: ['public'], + verbose: true, + }, + }); + + const childA = result.tables.find( + (t: { name: string }) => t.name === 'public.child_a' + ); + const childB = result.tables.find( + (t: { name: string }) => t.name === 'public.child_b' + ); + + expect( + childA.foreign_key_constraints.filter( + (fk: { name: string }) => fk.name === 'fk_parent' + ) + ).toHaveLength(1); + expect( + childB.foreign_key_constraints.filter( + (fk: { name: string }) => fk.name === 'fk_parent' + ) + ).toHaveLength(1); + }); + test('list_tables omits advisory when all tables have RLS enabled', async () => { const { callTool } = await setup(); diff --git a/packages/mcp-server-supabase/src/tools/database-operation-tools.ts b/packages/mcp-server-supabase/src/tools/database-operation-tools.ts index 0b9a75be..0d4aaebe 100644 --- a/packages/mcp-server-supabase/src/tools/database-operation-tools.ts +++ b/packages/mcp-server-supabase/src/tools/database-operation-tools.ts @@ -65,8 +65,10 @@ const listTablesOutputSchema = z.object({ .array( z.object({ name: z.string(), - source: z.string(), - target: z.string(), + source_table: z.string(), + source_columns: z.array(z.string()), + target_table: z.string(), + target_columns: z.array(z.string()), }) ) .optional(), @@ -238,14 +240,16 @@ export function getDatabaseTools({ constraint_name, source_schema, source_table_name, - source_column_name, + source_columns, target_table_schema, target_table_name, - target_column_name, + target_columns, }) => ({ name: constraint_name, - source: `${source_schema}.${source_table_name}.${source_column_name}`, - target: `${target_table_schema}.${target_table_name}.${target_column_name}`, + source_table: `${source_schema}.${source_table_name}`, + source_columns, + target_table: `${target_table_schema}.${target_table_name}`, + target_columns, }) );