From 84039fb892106b562bfcca7be2fe91f17148f6ce Mon Sep 17 00:00:00 2001 From: Ankita Date: Wed, 1 Jul 2026 15:16:22 -0400 Subject: [PATCH 1/4] fix(pg-meta): pair composite FK columns positionally to avoid cartesian product The foreign-key subquery in tables.sql joined source and target columns independently (attnum = any(conkey) with attnum = any(confkey)), producing the cartesian product of a composite key's columns. An N-column foreign key yielded N^2 relationship rows, reporting column pairings that do not exist in the schema. This surfaces in list_tables (verbose) foreign_key_constraints. Pair the columns positionally with unnest(conkey, confkey) with ordinality so column i maps only to column i. Adds a regression test. --- .../src/pg-meta/tables.sql | 5 ++-- .../mcp-server-supabase/src/server.test.ts | 23 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/mcp-server-supabase/src/pg-meta/tables.sql b/packages/mcp-server-supabase/src/pg-meta/tables.sql index d0bb9df3..367eaac2 100644 --- a/packages/mcp-server-supabase/src/pg-meta/tables.sql +++ b/packages/mcp-server-supabase/src/pg-meta/tables.sql @@ -62,16 +62,17 @@ FROM ta.attname as target_column_name from pg_constraint c + join lateral unnest(c.conkey, c.confkey) with ordinality as cols(conkey, confkey, ord) on true 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) + ) on sa.attrelid = c.conrelid and sa.attnum = cols.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) + ) on ta.attrelid = c.confrelid and ta.attnum = cols.confkey where c.contype = 'f' ) as relationships diff --git a/packages/mcp-server-supabase/src/server.test.ts b/packages/mcp-server-supabase/src/server.test.ts index d08564de..214e25f5 100644 --- a/packages/mcp-server-supabase/src/server.test.ts +++ b/packages/mcp-server-supabase/src/server.test.ts @@ -3218,6 +3218,29 @@ describe('tools', () => { const parsedContent = JSON.parse(firstContent.text); expect(parsedContent).toBeTypeOf('object'); }); + + test('list_tables verbose returns correct column pairs for composite foreign keys', 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 public.parent (a int not null, b int not null, primary key (a, b)); + create table public.child ( + a int not null, b int not null, + constraint child_parent_fk foreign key (a, b) references public.parent (a, b) + ); + `); + const result = await callTool({ name: 'list_tables', arguments: { project_id: project.id, schemas: ['public'], verbose: true } }); + const child = result.tables.find((t) => t.name === 'public.child'); + const fks = child.foreign_key_constraints ?? []; + const pairs = fks.map((f) => `${f.source}=>${f.target}`).sort(); + expect(pairs).toEqual([ + 'public.child.a=>public.parent.a', + 'public.child.b=>public.parent.b', + ]); + }); + }); describe('feature groups', () => { From d17cdfb7b33bda52d93b844760585b43a8a472d4 Mon Sep 17 00:00:00 2001 From: Ankita Date: Thu, 16 Jul 2026 15:45:26 -0400 Subject: [PATCH 2/4] Group composite FKs into single constraints with positionally ordered column arrays --- .../src/pg-meta/tables.sql | 29 +++---- .../mcp-server-supabase/src/pg-meta/types.ts | 4 +- .../mcp-server-supabase/src/server.test.ts | 84 +++++++++++++------ .../src/tools/database-operation-tools.ts | 16 ++-- 4 files changed, 87 insertions(+), 46 deletions(-) diff --git a/packages/mcp-server-supabase/src/pg-meta/tables.sql b/packages/mcp-server-supabase/src/pg-meta/tables.sql index 367eaac2..378ea12c 100644 --- a/packages/mcp-server-supabase/src/pg-meta/tables.sql +++ b/packages/mcp-server-supabase/src/pg-meta/tables.sql @@ -56,25 +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 lateral unnest(c.conkey, c.confkey) with ordinality as cols(conkey, confkey, ord) on true - 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 = cols.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 = cols.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) @@ -96,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 214e25f5..83b1e85a 100644 --- a/packages/mcp-server-supabase/src/server.test.ts +++ b/packages/mcp-server-supabase/src/server.test.ts @@ -1108,14 +1108,72 @@ 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('list_tables omits advisory when all tables have RLS enabled', async () => { const { callTool } = await setup(); @@ -3219,28 +3277,6 @@ describe('tools', () => { expect(parsedContent).toBeTypeOf('object'); }); - test('list_tables verbose returns correct column pairs for composite foreign keys', 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 public.parent (a int not null, b int not null, primary key (a, b)); - create table public.child ( - a int not null, b int not null, - constraint child_parent_fk foreign key (a, b) references public.parent (a, b) - ); - `); - const result = await callTool({ name: 'list_tables', arguments: { project_id: project.id, schemas: ['public'], verbose: true } }); - const child = result.tables.find((t) => t.name === 'public.child'); - const fks = child.foreign_key_constraints ?? []; - const pairs = fks.map((f) => `${f.source}=>${f.target}`).sort(); - expect(pairs).toEqual([ - 'public.child.a=>public.parent.a', - 'public.child.b=>public.parent.b', - ]); - }); - }); describe('feature groups', () => { 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, }) ); From c9fd142a8149ef2db57da799656a04e41cfbbca7 Mon Sep 17 00:00:00 2001 From: Ankita Date: Fri, 17 Jul 2026 12:33:01 -0400 Subject: [PATCH 3/4] test: add composite FK scenarios (self-referential, cross-schema, non-PK unique ref, multi-FK, 3-column, single-column arrays) --- .../mcp-server-supabase/src/server.test.ts | 397 ++++++++++++++++++ 1 file changed, 397 insertions(+) diff --git a/packages/mcp-server-supabase/src/server.test.ts b/packages/mcp-server-supabase/src/server.test.ts index 83b1e85a..797b9427 100644 --- a/packages/mcp-server-supabase/src/server.test.ts +++ b/packages/mcp-server-supabase/src/server.test.ts @@ -1174,6 +1174,403 @@ describe('tools', () => { ); }); + 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(); From d34abe8beea2be48a40a35a005d2456f5350f71f Mon Sep 17 00:00:00 2001 From: Ankita Date: Fri, 17 Jul 2026 13:36:49 -0400 Subject: [PATCH 4/4] style: apply biome formatting to added FK tests --- packages/mcp-server-supabase/src/server.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/mcp-server-supabase/src/server.test.ts b/packages/mcp-server-supabase/src/server.test.ts index 797b9427..477189e3 100644 --- a/packages/mcp-server-supabase/src/server.test.ts +++ b/packages/mcp-server-supabase/src/server.test.ts @@ -3673,7 +3673,6 @@ describe('tools', () => { const parsedContent = JSON.parse(firstContent.text); expect(parsedContent).toBeTypeOf('object'); }); - }); describe('feature groups', () => {