Bug report
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.
Bug report
Describe the bug
The
schemasparameter onlist_tablesadvertises three things that cannot all be true, and the disagreement causes the attached RLS advisory to report a definite, wrong count.From
tools/liston the published@supabase/mcp-server-supabase@0.11.0, over stdio:The description says omitting it means all schemas. The default is one schema. And
schemasis inrequired, so a client that honours the schema cannot omit it. The value that does mean "all schemas",[], is documented nowhere.Impact. Since #251,
list_tablesattaches anrls_disabledadvisory built from the tables it returned.buildRlsDisabledAdvisoryis correct in isolation; the problem is upstream of it, becausetableshas already been narrowed. On a local Supabase project with 4 RLS-disabled user tables acrosspublicandbilling, the default produces: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, followdefault, or guess["*"], and all three are wrong.To Reproduce
Against a stock
supabase startdatabase. 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.Output:
Note
["*"]and["all"]are passed through as literal schema names, match nothing, and return{"tables": []}withisError: false: no tables, no advisory, no error.Expected behavior
Either omitting
schemasreturns 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, acriticalRLS advisory should not state a definite count for a database it only partly examined.System information
@supabase/mcp-server-supabase@0.11.0installed from npm, run asdist/transports/stdio.jsover stdiosupabase start(public.ecr.aws/supabase/postgres:17.4.1)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-schemaspath:callToolreturns the parsed output, an object ({ tables, advisory }), compared againstexpect.arrayContaining([...])under.not. An object never equals an array matcher, so the negation always holds. Independently,list_tablesfolds the schema intoname("public.users"), so no row carries aschemaproperty forobjectContainingto match even ifresultwere 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 omitsschemas, 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
requiredhalf arrived separately withc58614a("feat: Zod v4, AI SDK v6 (#198)", Jan 2026), which replacedzodToJsonSchemawithz.toJSONSchema. Zod v4 defaults toio: 'output', where a.default()field is always present and so lands inrequired:Options
Same database,
verbose: false, response bytes as returned:schemas["public"](today)["*"][], option (b)(a) Correct the description to match the behaviour, non-breaking:
(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 innet,supabase_functionsandvaultare 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 bylistTablesSql([])) and 26 inadvisories/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:
io: 'input'toz.toJSONSchemafixes therequiredhalf, and is arguably the correct mode for an input schema. One caveat: Zod only emitsadditionalProperties: falseinoutputmode, so the switch drops it, whiletools/calldoes enforce strictness (tool.parameters.strict().parse(...)). It would want re-adding so the advertised schema still matches what the server enforces.verbosehas the same shape and is fixed by the same change.["public"]gets acriticaladvisory 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
WHEREclause 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_roledoes 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.