Skip to content

list_tables: schemas says it defaults to all schemas but defaults to ["public"], so the RLS advisory silently reports on a partial database #395

Description

@Rjabov

Bug report

  • I confirm this is a bug with Supabase, not with my own application.
  • I confirm I have searched the Docs, GitHub Discussions, and Discord.

Describe the bug

The schemas parameter on list_tables advertises three things that cannot all be true, and the disagreement causes the attached RLS advisory to report a definite, wrong count.

From tools/list on the published @supabase/mcp-server-supabase@0.11.0, over stdio:

description : "List of schemas to include. Defaults to all schemas."
default     : ["public"]
required    : ["schemas","verbose"]

The description says omitting it means all schemas. The default is one schema. And schemas is in required, so a client that honours the schema cannot omit it. The value that does mean "all schemas", [], is documented nowhere.

Impact. Since #251, list_tables attaches an rls_disabled advisory built from the tables it returned. buildRlsDisabledAdvisory is correct in isolation; the problem is upstream of it, because tables has already been narrowed. On a local Supabase project with 4 RLS-disabled user tables across public and billing, the default produces:

2 table(s) have Row Level Security (RLS) disabled: public.users, public.posts. These tables are fully exposed to the anon and authenticated roles used by Supabase client libraries ... You MUST surface this security issue to the user in your response.

There is no scope caveat anywhere in the response. The assistant is instructed to report a specific number, and that number is half the real one.

This is not reachable only by omitting the parameter. {schemas: ["public"]}, the value the schema itself advertises as the default, returns exactly the same result. Omit it, follow default, or guess ["*"], and all three are wrong.

To Reproduce

Against a stock supabase start database. This uses only the published package, run as a real binary over stdio; the only stub is the Management API HTTP endpoint, which forwards the server's own SQL unmodified to Postgres.

npm i @supabase/mcp-server-supabase @modelcontextprotocol/client pg
node repro.mjs
import http from 'node:http';
import pg from 'pg';
import { Client } from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';

// The Management API returns JSON numbers; node-postgres returns int8/oid as strings.
for (const o of [20, 26, 1700, 1016]) pg.types.setTypeParser(o, v => v === null ? null : Number(v));
const pool = new pg.Pool({ connectionString: 'postgresql://postgres:postgres@127.0.0.1:54322/postgres' });

// 4 RLS-disabled user tables, half of them outside `public`.
await pool.query(`drop schema if exists billing cascade;
  drop table if exists public.users, public.posts cascade;
  create table public.users (id bigint primary key, email text);
  create table public.posts (id bigint primary key, body text);
  create schema billing;
  create table billing.invoices (id bigint primary key, amount numeric);
  create table billing.cards (id bigint primary key, pan text);`);

// Guard: any pre-existing unprotected user table would corrupt the counts.
const truth = (await pool.query(`select n.nspname||'.'||c.relname t from pg_class c
  join pg_namespace n on n.oid=c.relnamespace where c.relkind='r' and not c.relrowsecurity
  and n.nspname not in ('information_schema','pg_catalog','pg_toast','_timescaledb_internal',
    'auth','storage','realtime','vault','net','supabase_functions','extensions','graphql',
    'graphql_public','cron','pgbouncer','pgmq','pgsodium','pgsodium_masks','pgtle','repack',
    'supabase_migrations','tiger','topology','pgroonga') order by 1`)).rows.map(r => r.t);
if (truth.length !== 4) throw new Error(`database not clean: found ${truth.length}: ${truth}`);
console.log('ground truth:', truth.length, 'unprotected user tables ->', truth.join(', '), '\n');

// Stand-in for the Management API: forwards the server's own SQL, unmodified.
const api = http.createServer(async (req, res) => {
  let raw = ''; for await (const c of req) raw += c;
  const { query, parameters } = JSON.parse(raw || '{}');
  console.log('  SQL:', (query.match(/where schema[^\n]*/) || ['?'])[0], JSON.stringify(parameters));
  res.setHeader('content-type', 'application/json');
  res.end(JSON.stringify((await pool.query(query, parameters ?? [])).rows));
});
await new Promise(r => api.listen(0, '127.0.0.1', r));

const client = new Client({ name: 'repro', version: '1' }, { capabilities: {} });
await client.connect(new StdioClientTransport({
  command: process.execPath,
  args: ['node_modules/@supabase/mcp-server-supabase/dist/transports/stdio.js',
    '--access-token', 'x', '--project-ref', 'demo',
    '--api-url', `http://127.0.0.1:${api.address().port}`, '--features', 'database'],
}));

const p = (await client.listTools()).tools.find(t => t.name === 'list_tables').inputSchema;
console.log('description:', JSON.stringify(p.properties.schemas.description));
console.log('default    :', JSON.stringify(p.properties.schemas.default));
console.log('required   :', JSON.stringify(p.required), '\n');

for (const [label, schemas] of [['omitted', undefined], ['["public"]', ['public']],
                                ['["*"]', ['*']], ['[]', []]]) {
  const r = await client.callTool({ name: 'list_tables',
    arguments: { ...(schemas && { schemas }) } });
  const d = JSON.parse(r.content[0].text);
  console.log(`${label.padEnd(11)} advisory: ${
    (d.advisory?.message.match(/^\d+ table\(s\)/) || ['none'])[0].padEnd(13)} bytes: ${r.content[0].text.length}`);
}
await client.close(); api.close(); await pool.end();

Output:

ground truth: 4 unprotected user tables -> billing.cards, billing.invoices, public.posts, public.users

description: "List of schemas to include. Defaults to all schemas."
default    : ["public"]
required   : ["schemas","verbose"]

  SQL: where schema in ($1) ["public"]
omitted     advisory: 2 table(s)    bytes: 947
  SQL: where schema in ($1) ["public"]
["public"]  advisory: 2 table(s)    bytes: 947
  SQL: where schema in ($1) ["*"]
["*"]       advisory: none          bytes: 13
  SQL: where schema not in ($1, $2, $3, $4) ["information_schema","pg_catalog","pg_toast","_timescaledb_internal"]
[]          advisory: 4 table(s)    bytes: 4165

Note ["*"] and ["all"] are passed through as literal schema names, match nothing, and return {"tables": []} with isError: false: no tables, no advisory, no error.

Expected behavior

Either omitting schemas returns what the description promises, or the description says what omitting actually does. Today neither holds, and nothing in the emitted schema tells a caller which of the three statements to trust. Separately, a critical RLS advisory should not state a definite count for a database it only partly examined.

System information

  • @supabase/mcp-server-supabase@0.11.0 installed from npm, run as dist/transports/stdio.js over stdio
  • Database from supabase start (public.ecr.aws/supabase/postgres:17.4.1)
  • Node.js v22.22.2, zod 4.5.2

Additional context

The test on this path cannot fail

src/server.test.ts, 'listing all tables excludes system schemas', is the only test that exercises the omitted-schemas path:

const result = await callTool({ name: 'list_tables', arguments: { project_id: project.id } });

expect(result).not.toEqual(
  expect.arrayContaining([expect.objectContaining({ schema: 'pg_catalog' })])
);

callTool returns the parsed output, an object ({ tables, advisory }), compared against expect.arrayContaining([...]) under .not. An object never equals an array matcher, so the negation always holds. Independently, list_tables folds the schema into name ("public.users"), so no row carries a schema property for objectContaining to match even if result were an array. A control with the shape the assertion was written for ([{ schema: 'pg_catalog', name: 'pg_class' }]) does fail, so the matcher is not broken in general. And since the call omits schemas, the default means the test never requests system schemas at all.

How it got here

6cf3b5f ("fix: make schemas array required on list_tables", Apr 2025) changed .optional(z.array(z.string())) to .array(z.string()).default(['public']) and trimmed "Optional list of schemas" to "List of schemas", leaving "Defaults to all schemas." in place. Before it the sentence was accurate: listTablesSql(schemas: string[] = []) fell through to [], which selects every non-system schema.

The required half arrived separately with c58614a ("feat: Zod v4, AI SDK v6 (#198)", Jan 2026), which replaced zodToJsonSchema with z.toJSONSchema. Zod v4 defaults to io: 'output', where a .default() field is always present and so lands in required:

zod@3.25.76 + zod-to-json-schema@3.25.2 -> required: ["project_id"]
zod@4       io:'output' (current)       -> required: ["project_id","schemas","verbose"]
zod@4       io:'input'                  -> required: ["project_id"]

Options

Same database, verbose: false, response bytes as returned:

schemas tables user tables bytes advisory
omitted or ["public"] (today) 2 2 947 "2 table(s)", wrong
["*"] 0 0 13 none, wrong
[], option (b) 34 4 4165 "4 table(s)", correct
user schemas only, option (c) 4 4 1202 "4 table(s)", correct

(a) Correct the description to match the behaviour, non-breaking:

.describe('List of schemas to include. Defaults to ["public"]. Pass [] for all non-system schemas.')

(b) Change the default to [] to match the description. Correct, but on a stock project it returns 34 tables to surface 4 user ones. To be fair to it, this produces no false advisories: the RLS-disabled tables in net, supabase_functions and vault are already filtered by the advisory's own exclusion list.

(c) There are already two "system schema" lists in the tree: 4 entries in pg-meta/index.ts (used by listTablesSql([])) and 26 in advisories/rls-disabled.ts. Defaulting to everything except the advisory's 26 gets the advisory right at 1.3x today's payload instead of 4.4x. Offered as an option, not a preference; you will know if there is a reason the two lists differ.

Two smaller notes either way:

  • Passing io: 'input' to z.toJSONSchema fixes the required half, and is arguably the correct mode for an input schema. One caveat: Zod only emits additionalProperties: false in output mode, so the switch drops it, while tools/call does enforce strictness (tool.parameters.strict().parse(...)). It would want re-adding so the advertised schema still matches what the server enforces. verbose has the same shape and is fixed by the same change.
  • Even under (a), a caller who legitimately asks for ["public"] gets a critical advisory scoped to that schema alone. Naming the examined scope in the advisory message would stop a partial result reading as a clean bill of health.

What was not tested

No run against a hosted Supabase project; that needs a personal access token, which is account-wide. The scope decision is made client-side and is visible in the SQL above before any request leaves, so a hosted project would have to ignore its own WHERE clause to behave differently, but that is reasoning rather than measurement. The local stack also has 30 internal tables against 34 on a hosted free-tier project, so the option (b) byte count runs slightly higher in practice. supabase_read_only_role does not exist in the local stack, so the read-only role switch the hosted Management API performs was not exercised.


Happy to send a PR for whichever shape you prefer, with a regression test that actually exercises the default path.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions