@exposify/supabase 0.3.11 → 0.3.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,12 +28,15 @@ npx @exposify/supabase init
28
28
 
29
29
  This is safe to re-run; see "Re-running `init`" below. It writes:
30
30
 
31
- - `supabase/migrations/<timestamp>_exposify_meta.sql` — a SQL migration that
31
+ - `supabase/migrations/<timestamp>_exposify_meta_v2.sql` — a SQL migration that
32
32
  adds `exposify_meta()`, a `SECURITY DEFINER` function returning, per table,
33
- whether RLS is enabled and any `COMMENT ON TABLE`/`COMMENT ON COLUMN` text
33
+ whether RLS is enabled, what the `authenticated` role is granted on it
34
+ (select/insert/update/delete), and any `COMMENT ON TABLE`/`COMMENT ON COLUMN` text
34
35
  you've written. It only ever returns catalog metadata (table/column names,
35
36
  flags, comments) — never row data — which is what makes it safe to grant
36
- to `anon`/`authenticated` and call with a low-privilege key.
37
+ to `anon`/`authenticated` and call with a low-privilege key. A project with
38
+ the older `<timestamp>_exposify_meta.sql` (no grants) gets the `_v2` one on
39
+ the next `init`, which replaces the function.
37
40
  - `supabase/functions/mcp/index.ts` — the edge function itself. `init` owns
38
41
  the part between `// exposify:begin` and `// exposify:end` (imports and a
39
42
  `generated` options object). Everything after that is yours, starting with
@@ -204,9 +207,14 @@ swaps in on each request, and it stops working when it expires.
204
207
  ## 2. Decide what's exposed
205
208
 
206
209
  **Every RLS-enabled table is automatically readable.** A table only shows up
207
- at all if `exposify_meta()` reports RLS is turned on for it *and* PostgREST
208
- actually exposes it (the calling role has grants) — an RLS-less table is
209
- invisible, full stop, with no override. For each exposed table you get a
210
+ at all if `exposify_meta()` reports RLS is turned on for it *and* the
211
+ `authenticated` role has `SELECT` on it — an RLS-less table is
212
+ invisible, full stop, with no override. RLS decides which rows; the grant
213
+ decides whether the role can touch the table at all, and without it every
214
+ call answers 403. Newer Supabase local images no longer grant `anon` and
215
+ `authenticated` on new tables in `public`, so check with `doctor`, which
216
+ prints the `grant` to run. A write you opt into below only becomes a tool
217
+ when the role is granted that operation too. For each exposed table you get a
210
218
  `query_<table>` tool: typed filters (`eq`/`neq`/`gt`/`lt`/`like`), a `select`
211
219
  column list, `order`, and a `limit` capped server-side (100 rows by default,
212
220
  regardless of what the caller asks for).
@@ -244,9 +252,13 @@ Deno.serve(aiReady({
244
252
  ```
245
253
 
246
254
  Notes:
247
- - `update_<table>` and `delete_<table>` always require at least one filter —
248
- there is no way to opt into a table-wide mutation, even where RLS would
249
- technically allow it.
255
+ - `update_<table>` and `delete_<table>` change one row: the filter must
256
+ include `eq` on every primary-key column (more filters only narrow it).
257
+ A filter that happens to match everything (`id neq -1`, `item like %`) is
258
+ refused, and there is no way to opt into a table-wide mutation, even where
259
+ RLS would technically allow it; an agent that means to change several rows
260
+ makes one call per row. A table without a primary key gets `insert_<table>`
261
+ but no update or delete tools (the cold-start log and `doctor` say so).
250
262
  - Column/table descriptions shown to the agent come from SQL comments
251
263
  (`COMMENT ON TABLE orders IS '...'`, `COMMENT ON COLUMN orders.status IS
252
264
  '...'`) — write these once and every generated tool reads them.
@@ -343,8 +355,11 @@ exits 1 if anything failed, so you can run it in CI.
343
355
  that `verify_jwt = false` for `mcp`.
344
356
  - **Database:** `exposify_meta()` is applied and the PostgREST schema is
345
357
  readable. It lists which tables became tools, and warns about tables that
346
- never will: RLS off, or RLS on but no API grants. It also warns about
347
- tables whose missing comments leave tools with thin descriptions.
358
+ never will: RLS off, or RLS on but no `SELECT` for `authenticated` (with the
359
+ `grant` to run). It warns about writes in the function's config the role
360
+ isn't granted, about the older `exposify_meta()` that doesn't report
361
+ grants, and about tables whose missing comments leave tools with thin
362
+ descriptions.
348
363
  - **Deployed function:** speaks MCP to `<project>/functions/v1/mcp`
349
364
  (`initialize`, then `tools/list`) and reports the tool count. If the
350
365
  function registered `exposify://diagnostics`, which it only does when
package/dist/cli.js CHANGED
@@ -99,6 +99,8 @@ function introspectTables(openapi, metaRows) {
99
99
  const rlsEnabled = rows[0].rls_enabled;
100
100
  if (!rlsEnabled) continue;
101
101
  if (!exposedInOpenApi.has(name)) continue;
102
+ const grants = grantsOf(rows[0]);
103
+ if (grants && !grants.select) continue;
102
104
  const openApiColumns = new Map(columnsFromOpenApi(openapi, name).map((c) => [c.name, c]));
103
105
  const columns = [];
104
106
  for (const row of rows) {
@@ -116,11 +118,21 @@ function introspectTables(openapi, metaRows) {
116
118
  name,
117
119
  rlsEnabled: true,
118
120
  description: rows[0].table_comment ?? void 0,
119
- columns
121
+ columns,
122
+ ...grants ? { grants } : {}
120
123
  });
121
124
  }
122
125
  return tables.sort((a, b) => a.name.localeCompare(b.name));
123
126
  }
127
+ function grantsOf(row) {
128
+ if (typeof row.can_select !== "boolean") return void 0;
129
+ return {
130
+ select: row.can_select,
131
+ insert: row.can_insert === true,
132
+ update: row.can_update === true,
133
+ delete: row.can_delete === true
134
+ };
135
+ }
124
136
  function groupByTable(rows) {
125
137
  const map = /* @__PURE__ */ new Map();
126
138
  for (const row of rows) {
@@ -528,6 +540,53 @@ function configsEqual(a, b) {
528
540
  return sameSet(a.writes, b.writes) && sameSet(a.rpc, b.rpc) && a.anonymousSignIn === b.anonymousSignIn && a.broker === null === (b.broker === null) && a.broker?.brokerUrl === b.broker?.brokerUrl && a.oauth === b.oauth;
529
541
  }
530
542
 
543
+ // src/config.ts
544
+ var WRITE_OPS = ["insert", "update", "delete"];
545
+ function parseWrites(writes = []) {
546
+ const grants = /* @__PURE__ */ new Map();
547
+ for (const entry of writes) {
548
+ const [table, op] = entry.split(".");
549
+ if (!table || !op) {
550
+ warnOnce(`ignoring malformed writes entry "${entry}" (expected "<table>.insert|update|delete|*")`);
551
+ continue;
552
+ }
553
+ const ops = op === "*" ? WRITE_OPS : isWriteOp(op) ? [op] : null;
554
+ if (!ops) {
555
+ warnOnce(`ignoring writes entry "${entry}": "${op}" is not insert, update, delete, or *`);
556
+ continue;
557
+ }
558
+ const set = grants.get(table) ?? /* @__PURE__ */ new Set();
559
+ for (const o of ops) set.add(o);
560
+ grants.set(table, set);
561
+ }
562
+ return grants;
563
+ }
564
+ function isWriteOp(op) {
565
+ return WRITE_OPS.includes(op);
566
+ }
567
+ var warned = /* @__PURE__ */ new Set();
568
+ function warnOnce(message) {
569
+ if (warned.has(message)) return;
570
+ warned.add(message);
571
+ try {
572
+ console.warn(`[@exposify/supabase] ${message}`);
573
+ } catch {
574
+ }
575
+ }
576
+ function writeToolOps(table, opted) {
577
+ const ops = [];
578
+ const ungranted = [];
579
+ const keyless = [];
580
+ const hasKey = table.columns.some((c) => c.isPrimaryKey);
581
+ for (const op of WRITE_OPS) {
582
+ if (!opted?.has(op)) continue;
583
+ if (table.grants && !table.grants[op]) ungranted.push(op);
584
+ else if (op !== "insert" && !hasKey) keyless.push(op);
585
+ else ops.push(op);
586
+ }
587
+ return { ops, ungranted, keyless };
588
+ }
589
+
531
590
  // bin/doctor.ts
532
591
  var FUNCTION_NAME = "mcp";
533
592
  var FUNCTION_PATH = `supabase/functions/${FUNCTION_NAME}/index.ts`;
@@ -576,6 +635,17 @@ function invisibleTables(metaRows, exposed) {
576
635
  }
577
636
  return { rlsOff, notGranted };
578
637
  }
638
+ function unavailableWrites(exposed, writes) {
639
+ const opted = parseWrites(writes);
640
+ const ungranted = [];
641
+ const keyless = [];
642
+ for (const table of exposed) {
643
+ const why = writeToolOps(table, opted.get(table.name));
644
+ for (const op of why.ungranted) ungranted.push({ table: table.name, op });
645
+ for (const op of why.keyless) keyless.push({ table: table.name, op });
646
+ }
647
+ return { ungranted, keyless };
648
+ }
579
649
  function parseJsonRpcResponse(contentType, body, id) {
580
650
  const candidates = contentType.includes("text/event-stream") ? body.split("\n").filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trim()) : [body];
581
651
  for (const raw of candidates) {
@@ -670,9 +740,15 @@ async function runDoctor(deps) {
670
740
  );
671
741
  sections.push({ title: "Project", checks: project });
672
742
  const files = [];
673
- const migration = findExistingMigration(await deps.listDir(MIGRATIONS_DIR), "_exposify_meta.sql");
743
+ const migrations = await deps.listDir(MIGRATIONS_DIR);
744
+ const migration = findExistingMigration(migrations, "_exposify_meta_v2.sql");
745
+ const legacyMigration = findExistingMigration(migrations, "_exposify_meta.sql");
674
746
  files.push(
675
- migration ? { status: "pass", message: `migration ${MIGRATIONS_DIR}/${migration}` } : { status: "fail", message: "no *_exposify_meta.sql migration", fix: "npx @exposify/supabase init" }
747
+ migration ? { status: "pass", message: `migration ${MIGRATIONS_DIR}/${migration}` } : legacyMigration ? {
748
+ status: "warn",
749
+ message: `migration ${MIGRATIONS_DIR}/${legacyMigration} is the older exposify_meta(), which doesn't report grants`,
750
+ fix: "npx @exposify/supabase init (it adds *_exposify_meta_v2.sql), then supabase db push"
751
+ } : { status: "fail", message: "no *_exposify_meta_v2.sql migration", fix: "npx @exposify/supabase init" }
676
752
  );
677
753
  const functionSource = await deps.readText(FUNCTION_PATH);
678
754
  const authMode = functionSource === null ? null : detectAuthMode(functionSource);
@@ -718,6 +794,13 @@ async function runDoctor(deps) {
718
794
  } else {
719
795
  const tableCount = new Set(meta.value.map((r) => r.table_name)).size;
720
796
  db.push({ status: "pass", message: `exposify_meta() applied (${tableCount} table(s) in the catalog)` });
797
+ if (meta.value.length > 0 && grantsOf(meta.value[0]) === void 0) {
798
+ db.push({
799
+ status: "warn",
800
+ message: "exposify_meta() in the database is the older one: it doesn't report grants, so tables agents can't read are still listed as tools",
801
+ fix: "npx @exposify/supabase init, then supabase db push"
802
+ });
803
+ }
721
804
  }
722
805
  if (openapi.status === "rejected") {
723
806
  const reason = openapi.reason instanceof Error ? openapi.reason.message : String(openapi.reason);
@@ -727,10 +810,14 @@ async function runDoctor(deps) {
727
810
  }
728
811
  if (openapi.status === "fulfilled" && meta.status === "fulfilled") {
729
812
  const tables = introspectTables(openapi.value, meta.value);
813
+ const { rlsOff, notGranted } = invisibleTables(meta.value, tables);
730
814
  db.push(
731
- tables.length > 0 ? { status: "pass", message: `${tables.length} table(s) exposed as tools: ${tables.map((t) => t.name).join(", ")}` } : { status: "warn", message: "no tables exposed (none have RLS on and API grants)", fix: "enable RLS on the tables agents should read" }
815
+ tables.length > 0 ? { status: "pass", message: `${tables.length} table(s) exposed as tools: ${tables.map((t) => t.name).join(", ")}` } : {
816
+ status: "warn",
817
+ message: "no tables exposed (none have RLS on and API grants)",
818
+ fix: notGranted.length > 0 ? "grant the authenticated role access (below)" : "enable RLS on the tables agents should read"
819
+ }
732
820
  );
733
- const { rlsOff, notGranted } = invisibleTables(meta.value, tables);
734
821
  if (rlsOff.length > 0) {
735
822
  db.push({
736
823
  status: "warn",
@@ -741,8 +828,23 @@ async function runDoctor(deps) {
741
828
  if (notGranted.length > 0) {
742
829
  db.push({
743
830
  status: "warn",
744
- message: `RLS on but not exposed by PostgREST (no API role grants): ${notGranted.join(", ")}`,
745
- fix: "grant select on <name> to authenticated;"
831
+ message: `RLS on, but the authenticated role can't read them, so they aren't tools: ${notGranted.join(", ")}`,
832
+ fix: notGranted.map((t) => `grant select on public.${t} to authenticated;`).join(" ")
833
+ });
834
+ }
835
+ const { ungranted, keyless } = functionSource === null ? { ungranted: [], keyless: [] } : unavailableWrites(tables, parseFunctionFile(functionSource).config.writes);
836
+ if (keyless.length > 0) {
837
+ db.push({
838
+ status: "warn",
839
+ message: `writes in the function's config on tables without a primary key, so they aren't tools (an update or delete must name its row by key): ${keyless.map((w) => `${w.table}.${w.op}`).join(", ")}`,
840
+ fix: "add a primary key to the table"
841
+ });
842
+ }
843
+ if (ungranted.length > 0) {
844
+ db.push({
845
+ status: "warn",
846
+ message: `writes in the function's config the authenticated role isn't granted, so they aren't tools: ${ungranted.map((w) => `${w.table}.${w.op}`).join(", ")}`,
847
+ fix: ungranted.map((w) => `grant ${w.op} on public.${w.table} to authenticated;`).join(" ")
746
848
  });
747
849
  }
748
850
  const undocumented = tablesMissingDocs(tables).filter((t) => t.missingTableComment || t.columnsWithoutComments.length > 0);
@@ -898,7 +1000,8 @@ var FUNCTION_DIR = "supabase/functions/mcp";
898
1000
  var FUNCTION_PATH2 = `${FUNCTION_DIR}/index.ts`;
899
1001
  var MIGRATIONS_DIR2 = "supabase/migrations";
900
1002
  var CONFIG_TOML2 = "supabase/config.toml";
901
- var META_SUFFIX = "_exposify_meta.sql";
1003
+ var META_SUFFIX = "_exposify_meta_v2.sql";
1004
+ var LEGACY_META_SUFFIX = "_exposify_meta.sql";
902
1005
  var COMMENTS_SUFFIX = "_exposify_comments.sql";
903
1006
  function authLabel(config) {
904
1007
  return config.broker ? "hosted broker" : config.oauth ? "Supabase Auth OAuth" : "user JWT";
@@ -933,7 +1036,7 @@ function planInit(input) {
933
1036
  changes.push({
934
1037
  path: `${MIGRATIONS_DIR2}/${name}`,
935
1038
  action: "create",
936
- summary: "exposify_meta() migration",
1039
+ summary: findExistingMigration(input.migrations, LEGACY_META_SUFFIX) ? "exposify_meta() update: it also reports the authenticated role's grants" : "exposify_meta() migration",
937
1040
  content: input.metaMigrationSql,
938
1041
  effect: "push"
939
1042
  });
@@ -1049,14 +1152,10 @@ function staleFunctionReason(probe, config, exposedTables) {
1049
1152
  return config.oauth ? "the gateway still checks JWTs for the function, so MCP clients never get the login challenge (deploy with --no-verify-jwt)." : "the gateway still checks JWTs for the function, so broker tokens are rejected (deploy with --no-verify-jwt).";
1050
1153
  }
1051
1154
  if (probe.kind !== "ok" || !exposedTables) return null;
1052
- const exposed = new Set(exposedTables.map((t) => t.name));
1155
+ const opted = parseWrites(config.writes);
1053
1156
  const expected = /* @__PURE__ */ new Set();
1054
- for (const w of config.writes) {
1055
- const dot = w.lastIndexOf(".");
1056
- const table = w.slice(0, dot);
1057
- if (!exposed.has(table)) continue;
1058
- const op = w.slice(dot + 1);
1059
- for (const o of op === "*" ? ["insert", "update", "delete"] : [op]) expected.add(`${o}_${sanitizeIdentifier(table)}`);
1157
+ for (const table of exposedTables) {
1158
+ for (const op of writeToolOps(table, opted.get(table.name)).ops) expected.add(`${op}_${sanitizeIdentifier(table.name)}`);
1060
1159
  }
1061
1160
  for (const fn of config.rpc) expected.add(`rpc_${sanitizeIdentifier(fn)}`);
1062
1161
  const live = new Set(probe.tools.filter((t) => /^(insert|update|delete|rpc)_/.test(t)));
@@ -1072,7 +1171,7 @@ function liveVerdict(s) {
1072
1171
  let deploy = false;
1073
1172
  if (s.migrationApplied === false) {
1074
1173
  push = true;
1075
- notes.push("exposify_meta() isn't in the database yet (migration not pushed).");
1174
+ notes.push("the exposify_meta() migration isn't in the database yet (not pushed).");
1076
1175
  }
1077
1176
  if (s.pendingComments.length > 0) {
1078
1177
  push = true;
@@ -1261,7 +1360,8 @@ async function tryIntrospect(conn) {
1261
1360
  fetchOpenApi(postgrestUrl, headers, fetchWithTimeout),
1262
1361
  fetchExposifyMeta(postgrestUrl, headers, fetchWithTimeout)
1263
1362
  ]);
1264
- return { introspection: { tables: introspectTables(openapi, metaRows), openapi }, migrationApplied: true };
1363
+ const current = metaRows.length === 0 || grantsOf(metaRows[0]) !== void 0;
1364
+ return { introspection: { tables: introspectTables(openapi, metaRows), openapi }, migrationApplied: current };
1265
1365
  } catch (err) {
1266
1366
  if (err instanceof MissingExposifyMetaError) return { introspection: null, migrationApplied: false };
1267
1367
  console.log(`
package/dist/index.cjs CHANGED
@@ -131,6 +131,22 @@ function resolveSupabaseConfig(options = {}, hostedBroker2 = false) {
131
131
  rateWindowMs: options.rateWindowMs
132
132
  };
133
133
  }
134
+ function writeToolOps(table, opted) {
135
+ const ops = [];
136
+ const ungranted = [];
137
+ const keyless = [];
138
+ const hasKey = table.columns.some((c) => c.isPrimaryKey);
139
+ for (const op of WRITE_OPS) {
140
+ if (!opted?.has(op)) continue;
141
+ if (table.grants && !table.grants[op]) ungranted.push(op);
142
+ else if (op !== "insert" && !hasKey) keyless.push(op);
143
+ else ops.push(op);
144
+ }
145
+ return { ops, ungranted, keyless };
146
+ }
147
+ function writeTools(table, cfg) {
148
+ return writeToolOps(table, cfg.writes.get(table.name));
149
+ }
134
150
 
135
151
  // src/postgrest-client.ts
136
152
  var FORWARDED_HEADERS = ["authorization", "apikey"];
@@ -275,6 +291,8 @@ function introspectTables(openapi, metaRows) {
275
291
  const rlsEnabled = rows[0].rls_enabled;
276
292
  if (!rlsEnabled) continue;
277
293
  if (!exposedInOpenApi.has(name)) continue;
294
+ const grants = grantsOf(rows[0]);
295
+ if (grants && !grants.select) continue;
278
296
  const openApiColumns = new Map(columnsFromOpenApi(openapi, name).map((c) => [c.name, c]));
279
297
  const columns = [];
280
298
  for (const row of rows) {
@@ -292,11 +310,21 @@ function introspectTables(openapi, metaRows) {
292
310
  name,
293
311
  rlsEnabled: true,
294
312
  description: rows[0].table_comment ?? void 0,
295
- columns
313
+ columns,
314
+ ...grants ? { grants } : {}
296
315
  });
297
316
  }
298
317
  return tables.sort((a, b) => a.name.localeCompare(b.name));
299
318
  }
319
+ function grantsOf(row) {
320
+ if (typeof row.can_select !== "boolean") return void 0;
321
+ return {
322
+ select: row.can_select,
323
+ insert: row.can_insert === true,
324
+ update: row.can_update === true,
325
+ delete: row.can_delete === true
326
+ };
327
+ }
300
328
  function groupByTable(rows) {
301
329
  const map = /* @__PURE__ */ new Map();
302
330
  for (const row of rows) {
@@ -366,9 +394,16 @@ function buildQueryString(args, maxRows) {
366
394
  params.set("limit", String(limit));
367
395
  return params.toString();
368
396
  }
369
- function requireFilter(filter) {
370
- if (!filter || filter.length === 0) {
371
- throw new Error("At least one filter is required -- refusing a table-wide update/delete.");
397
+ function requireKeyFilter(filter, keyColumns2) {
398
+ if (keyColumns2.length === 0) {
399
+ throw new Error("This table has no primary key, so a single row can't be named -- refusing the update/delete.");
400
+ }
401
+ const pinned = new Set((filter ?? []).filter((f) => f.op === "eq").map((f) => f.column));
402
+ const unpinned = keyColumns2.filter((c) => !pinned.has(c));
403
+ if (unpinned.length > 0) {
404
+ throw new Error(
405
+ `The filter must include eq on the primary key (${unpinned.join(", ")}), so it changes one row -- refusing a filter that could match many. To change several rows, make one call per row.`
406
+ );
372
407
  }
373
408
  return filter;
374
409
  }
@@ -435,17 +470,16 @@ function propertyToZod(prop) {
435
470
  var FILTER_OPS = ["eq", "neq", "gt", "lt", "like"];
436
471
  function buildSupabaseMcpServer(deps) {
437
472
  const { cfg, tables, client, kb, incoming } = deps;
438
- const server = new import_mcp.McpServer(
439
- { name: deps.appName, version: deps.appVersion },
440
- { instructions: instructionsFor(deps.warnings) }
473
+ const server = (0, import_core2.advertiseTools)(
474
+ new import_mcp.McpServer({ name: deps.appName, version: deps.appVersion }, { instructions: instructionsFor(deps.warnings) })
441
475
  );
442
476
  const taken = /* @__PURE__ */ new Set(["search_docs"]);
443
477
  for (const table of tables) {
444
478
  registerQueryTool(server, table, client, cfg, taken);
445
- const grants = cfg.writes.get(table.name);
446
- if (grants?.has("insert")) registerInsertTool(server, table, client, taken);
447
- if (grants?.has("update")) registerUpdateTool(server, table, client, taken);
448
- if (grants?.has("delete")) registerDeleteTool(server, table, client, taken);
479
+ const { ops } = writeTools(table, cfg);
480
+ if (ops.includes("insert")) registerInsertTool(server, table, client, taken);
481
+ if (ops.includes("update")) registerUpdateTool(server, table, client, taken);
482
+ if (ops.includes("delete")) registerDeleteTool(server, table, client, taken);
449
483
  }
450
484
  for (const fn of cfg.rpc) {
451
485
  registerRpcTool(server, fn, client, deps.openapi, taken);
@@ -537,23 +571,30 @@ function registerInsertTool(server, table, client, taken) {
537
571
  async (args) => toResult(() => client.insert(table.name, args.body))
538
572
  );
539
573
  }
574
+ function keyColumns(table) {
575
+ return table.columns.filter((c) => c.isPrimaryKey).map((c) => c.name);
576
+ }
577
+ function keyFilterShape(table, verb) {
578
+ const keys = keyColumns(table);
579
+ const message = `The filter must include eq on ${keys.join(", ")} -- refusing ${verb === "update" ? "an update" : "a delete"} that could match many rows.`;
580
+ return filterShape(table.columns).refine((f) => !!f && keys.every((k) => f.some((x) => x.column === k && x.op === "eq")), { message }).describe(`Filters, ANDed together. Must include eq on ${keys.join(", ")} (the primary key), so one row changes.`);
581
+ }
540
582
  function registerUpdateTool(server, table, client, taken) {
541
583
  const name = toolName("update", table.name, taken);
542
584
  const setShape = {};
543
585
  for (const col of table.columns) setShape[col.name] = columnToZod(col).optional();
586
+ const keys = keyColumns(table);
544
587
  server.registerTool(
545
588
  name,
546
589
  {
547
- description: `Update rows in ${table.name}. At least one filter is required.`,
590
+ description: `Update one row in ${table.name}, named by its primary key (${keys.join(", ")}) with eq in the filter.`,
548
591
  inputSchema: {
549
- filter: filterShape(table.columns).refine((f) => !!f && f.length > 0, {
550
- message: "At least one filter is required -- refusing a table-wide update."
551
- }),
592
+ filter: keyFilterShape(table, "update"),
552
593
  set: import_zod2.z.object(setShape)
553
594
  },
554
595
  annotations: { readOnlyHint: false, destructiveHint: true }
555
596
  },
556
- async (args) => toResult(() => client.update(table.name, filterQueryString(requireFilter(args.filter)), args.set))
597
+ async (args) => toResult(() => client.update(table.name, filterQueryString(requireKeyFilter(args.filter, keys)), args.set))
557
598
  );
558
599
  }
559
600
  function registerDeleteTool(server, table, client, taken) {
@@ -561,15 +602,13 @@ function registerDeleteTool(server, table, client, taken) {
561
602
  server.registerTool(
562
603
  name,
563
604
  {
564
- description: `Delete rows from ${table.name}. At least one filter is required.`,
605
+ description: `Delete one row from ${table.name}, named by its primary key (${keyColumns(table).join(", ")}) with eq in the filter.`,
565
606
  inputSchema: {
566
- filter: filterShape(table.columns).refine((f) => !!f && f.length > 0, {
567
- message: "At least one filter is required -- refusing a table-wide delete."
568
- })
607
+ filter: keyFilterShape(table, "delete")
569
608
  },
570
609
  annotations: { readOnlyHint: false, destructiveHint: true }
571
610
  },
572
- async (args) => toResult(() => client.delete(table.name, filterQueryString(requireFilter(args.filter))))
611
+ async (args) => toResult(() => client.delete(table.name, filterQueryString(requireKeyFilter(args.filter, keyColumns(table)))))
573
612
  );
574
613
  }
575
614
  function registerRpcTool(server, fn, client, openapi, taken) {
@@ -703,7 +742,7 @@ var INTROSPECTION_FIX = "check that SUPABASE_SERVICE_ROLE_KEY is available to th
703
742
  function supabaseToolsPhrase(ladder, cfg, hasDocs) {
704
743
  const query = ladder.tables.length;
705
744
  let write = 0;
706
- for (const table of ladder.tables) write += cfg.writes.get(table.name)?.size ?? 0;
745
+ for (const table of ladder.tables) write += writeTools(table, cfg).ops.length;
707
746
  const rpc = cfg.rpc.length;
708
747
  const fn = Object.keys(cfg.functions).length;
709
748
  const parts = [`${query} query`];
@@ -730,6 +769,26 @@ function logColdStart(opts) {
730
769
  const details = ladder.mode === "manifest" ? ["from the bundled manifest"] : [];
731
770
  console.log((0, import_core4.formatReady)(LOG_PREFIX, { endpoint, tools: supabaseToolsPhrase(ladder, cfg, docs > 0), details, docs, auth }));
732
771
  }
772
+ const keyless = ladder.tables.flatMap((t) => writeTools(t, cfg).keyless.map((op) => `${t.name}.${op}`));
773
+ if (keyless.length > 0) {
774
+ console.warn(
775
+ (0, import_core4.formatWarning)(
776
+ LOG_PREFIX,
777
+ `writes opted in on tables without a primary key, so they aren't tools (an update or delete must name its row by key): ${keyless.join(", ")}`,
778
+ "add a primary key to the table"
779
+ )
780
+ );
781
+ }
782
+ const ungranted = ladder.tables.flatMap((t) => writeTools(t, cfg).ungranted.map((op) => ({ table: t.name, op })));
783
+ if (ungranted.length > 0) {
784
+ console.warn(
785
+ (0, import_core4.formatWarning)(
786
+ LOG_PREFIX,
787
+ `writes opted in but not granted to the authenticated role, so they aren't tools: ${ungranted.map((w) => `${w.table}.${w.op}`).join(", ")}`,
788
+ ungranted.map((w) => `grant ${w.op} on public.${w.table} to authenticated;`).join(" ")
789
+ )
790
+ );
791
+ }
733
792
  if (ladder.mode === "manifest") {
734
793
  console.warn(
735
794
  (0, import_core4.formatWarning)(LOG_PREFIX, "live schema introspection failed; tools come from the bundled manifest and may be out of date", INTROSPECTION_FIX)
package/dist/index.d.cts CHANGED
@@ -18,6 +18,18 @@ interface TableSchema {
18
18
  /** From `exposify_meta()`'s `table_comment` (a `COMMENT ON TABLE`). */
19
19
  description?: string;
20
20
  columns: ColumnSchema[];
21
+ /**
22
+ * What the `authenticated` role may do on the table (`exposify_meta()`'s
23
+ * `can_*` columns). Absent when the database still has the older function,
24
+ * which didn't report grants: then nothing is filtered on them.
25
+ */
26
+ grants?: TableGrants;
27
+ }
28
+ interface TableGrants {
29
+ select: boolean;
30
+ insert: boolean;
31
+ update: boolean;
32
+ delete: boolean;
21
33
  }
22
34
  /** One row of `exposify_meta()` -- the migration returns one row per column. */
23
35
  interface MetaRow {
@@ -26,6 +38,11 @@ interface MetaRow {
26
38
  table_comment: string | null;
27
39
  column_name: string;
28
40
  column_comment: string | null;
41
+ /** Absent from the older `exposify_meta()` (before the `_v2` migration). */
42
+ can_select?: boolean;
43
+ can_insert?: boolean;
44
+ can_update?: boolean;
45
+ can_delete?: boolean;
29
46
  }
30
47
  /** Registered sibling edge function exposed as a tool. */
31
48
  interface FunctionToolConfig {
@@ -217,6 +234,11 @@ declare function loadManifest(raw: unknown): ExposifyManifest | null;
217
234
  * appears, silently, exactly like a table PostgREST doesn't know about at
218
235
  * all. There is no override: an RLS-less table is invisible even if the
219
236
  * owner explicitly lists it in `writes`.
237
+ *
238
+ * The OpenAPI document is fetched with the service role key, so it lists
239
+ * tables the `authenticated` role can't touch. A table `exposify_meta()`
240
+ * reports no SELECT grant for is left out too: every call to its query tool
241
+ * would answer 403.
220
242
  */
221
243
  declare function introspectTables(openapi: PostgrestOpenApiDoc, metaRows: MetaRow[]): TableSchema[];
222
244
 
package/dist/index.d.ts CHANGED
@@ -18,6 +18,18 @@ interface TableSchema {
18
18
  /** From `exposify_meta()`'s `table_comment` (a `COMMENT ON TABLE`). */
19
19
  description?: string;
20
20
  columns: ColumnSchema[];
21
+ /**
22
+ * What the `authenticated` role may do on the table (`exposify_meta()`'s
23
+ * `can_*` columns). Absent when the database still has the older function,
24
+ * which didn't report grants: then nothing is filtered on them.
25
+ */
26
+ grants?: TableGrants;
27
+ }
28
+ interface TableGrants {
29
+ select: boolean;
30
+ insert: boolean;
31
+ update: boolean;
32
+ delete: boolean;
21
33
  }
22
34
  /** One row of `exposify_meta()` -- the migration returns one row per column. */
23
35
  interface MetaRow {
@@ -26,6 +38,11 @@ interface MetaRow {
26
38
  table_comment: string | null;
27
39
  column_name: string;
28
40
  column_comment: string | null;
41
+ /** Absent from the older `exposify_meta()` (before the `_v2` migration). */
42
+ can_select?: boolean;
43
+ can_insert?: boolean;
44
+ can_update?: boolean;
45
+ can_delete?: boolean;
29
46
  }
30
47
  /** Registered sibling edge function exposed as a tool. */
31
48
  interface FunctionToolConfig {
@@ -217,6 +234,11 @@ declare function loadManifest(raw: unknown): ExposifyManifest | null;
217
234
  * appears, silently, exactly like a table PostgREST doesn't know about at
218
235
  * all. There is no override: an RLS-less table is invisible even if the
219
236
  * owner explicitly lists it in `writes`.
237
+ *
238
+ * The OpenAPI document is fetched with the service role key, so it lists
239
+ * tables the `authenticated` role can't touch. A table `exposify_meta()`
240
+ * reports no SELECT grant for is left out too: every call to its query tool
241
+ * would answer 403.
220
242
  */
221
243
  declare function introspectTables(openapi: PostgrestOpenApiDoc, metaRows: MetaRow[]): TableSchema[];
222
244
 
package/dist/index.js CHANGED
@@ -94,6 +94,22 @@ function resolveSupabaseConfig(options = {}, hostedBroker2 = false) {
94
94
  rateWindowMs: options.rateWindowMs
95
95
  };
96
96
  }
97
+ function writeToolOps(table, opted) {
98
+ const ops = [];
99
+ const ungranted = [];
100
+ const keyless = [];
101
+ const hasKey = table.columns.some((c) => c.isPrimaryKey);
102
+ for (const op of WRITE_OPS) {
103
+ if (!opted?.has(op)) continue;
104
+ if (table.grants && !table.grants[op]) ungranted.push(op);
105
+ else if (op !== "insert" && !hasKey) keyless.push(op);
106
+ else ops.push(op);
107
+ }
108
+ return { ops, ungranted, keyless };
109
+ }
110
+ function writeTools(table, cfg) {
111
+ return writeToolOps(table, cfg.writes.get(table.name));
112
+ }
97
113
 
98
114
  // src/postgrest-client.ts
99
115
  var FORWARDED_HEADERS = ["authorization", "apikey"];
@@ -238,6 +254,8 @@ function introspectTables(openapi, metaRows) {
238
254
  const rlsEnabled = rows[0].rls_enabled;
239
255
  if (!rlsEnabled) continue;
240
256
  if (!exposedInOpenApi.has(name)) continue;
257
+ const grants = grantsOf(rows[0]);
258
+ if (grants && !grants.select) continue;
241
259
  const openApiColumns = new Map(columnsFromOpenApi(openapi, name).map((c) => [c.name, c]));
242
260
  const columns = [];
243
261
  for (const row of rows) {
@@ -255,11 +273,21 @@ function introspectTables(openapi, metaRows) {
255
273
  name,
256
274
  rlsEnabled: true,
257
275
  description: rows[0].table_comment ?? void 0,
258
- columns
276
+ columns,
277
+ ...grants ? { grants } : {}
259
278
  });
260
279
  }
261
280
  return tables.sort((a, b) => a.name.localeCompare(b.name));
262
281
  }
282
+ function grantsOf(row) {
283
+ if (typeof row.can_select !== "boolean") return void 0;
284
+ return {
285
+ select: row.can_select,
286
+ insert: row.can_insert === true,
287
+ update: row.can_update === true,
288
+ delete: row.can_delete === true
289
+ };
290
+ }
263
291
  function groupByTable(rows) {
264
292
  const map = /* @__PURE__ */ new Map();
265
293
  for (const row of rows) {
@@ -316,6 +344,7 @@ function messageOf(err) {
316
344
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
317
345
  import { z as z2 } from "zod";
318
346
  import {
347
+ advertiseTools,
319
348
  toToolResult,
320
349
  DISPATCH_TIMEOUT_MS
321
350
  } from "@exposify/core";
@@ -332,9 +361,16 @@ function buildQueryString(args, maxRows) {
332
361
  params.set("limit", String(limit));
333
362
  return params.toString();
334
363
  }
335
- function requireFilter(filter) {
336
- if (!filter || filter.length === 0) {
337
- throw new Error("At least one filter is required -- refusing a table-wide update/delete.");
364
+ function requireKeyFilter(filter, keyColumns2) {
365
+ if (keyColumns2.length === 0) {
366
+ throw new Error("This table has no primary key, so a single row can't be named -- refusing the update/delete.");
367
+ }
368
+ const pinned = new Set((filter ?? []).filter((f) => f.op === "eq").map((f) => f.column));
369
+ const unpinned = keyColumns2.filter((c) => !pinned.has(c));
370
+ if (unpinned.length > 0) {
371
+ throw new Error(
372
+ `The filter must include eq on the primary key (${unpinned.join(", ")}), so it changes one row -- refusing a filter that could match many. To change several rows, make one call per row.`
373
+ );
338
374
  }
339
375
  return filter;
340
376
  }
@@ -401,17 +437,16 @@ function propertyToZod(prop) {
401
437
  var FILTER_OPS = ["eq", "neq", "gt", "lt", "like"];
402
438
  function buildSupabaseMcpServer(deps) {
403
439
  const { cfg, tables, client, kb, incoming } = deps;
404
- const server = new McpServer(
405
- { name: deps.appName, version: deps.appVersion },
406
- { instructions: instructionsFor(deps.warnings) }
440
+ const server = advertiseTools(
441
+ new McpServer({ name: deps.appName, version: deps.appVersion }, { instructions: instructionsFor(deps.warnings) })
407
442
  );
408
443
  const taken = /* @__PURE__ */ new Set(["search_docs"]);
409
444
  for (const table of tables) {
410
445
  registerQueryTool(server, table, client, cfg, taken);
411
- const grants = cfg.writes.get(table.name);
412
- if (grants?.has("insert")) registerInsertTool(server, table, client, taken);
413
- if (grants?.has("update")) registerUpdateTool(server, table, client, taken);
414
- if (grants?.has("delete")) registerDeleteTool(server, table, client, taken);
446
+ const { ops } = writeTools(table, cfg);
447
+ if (ops.includes("insert")) registerInsertTool(server, table, client, taken);
448
+ if (ops.includes("update")) registerUpdateTool(server, table, client, taken);
449
+ if (ops.includes("delete")) registerDeleteTool(server, table, client, taken);
415
450
  }
416
451
  for (const fn of cfg.rpc) {
417
452
  registerRpcTool(server, fn, client, deps.openapi, taken);
@@ -503,23 +538,30 @@ function registerInsertTool(server, table, client, taken) {
503
538
  async (args) => toResult(() => client.insert(table.name, args.body))
504
539
  );
505
540
  }
541
+ function keyColumns(table) {
542
+ return table.columns.filter((c) => c.isPrimaryKey).map((c) => c.name);
543
+ }
544
+ function keyFilterShape(table, verb) {
545
+ const keys = keyColumns(table);
546
+ const message = `The filter must include eq on ${keys.join(", ")} -- refusing ${verb === "update" ? "an update" : "a delete"} that could match many rows.`;
547
+ return filterShape(table.columns).refine((f) => !!f && keys.every((k) => f.some((x) => x.column === k && x.op === "eq")), { message }).describe(`Filters, ANDed together. Must include eq on ${keys.join(", ")} (the primary key), so one row changes.`);
548
+ }
506
549
  function registerUpdateTool(server, table, client, taken) {
507
550
  const name = toolName("update", table.name, taken);
508
551
  const setShape = {};
509
552
  for (const col of table.columns) setShape[col.name] = columnToZod(col).optional();
553
+ const keys = keyColumns(table);
510
554
  server.registerTool(
511
555
  name,
512
556
  {
513
- description: `Update rows in ${table.name}. At least one filter is required.`,
557
+ description: `Update one row in ${table.name}, named by its primary key (${keys.join(", ")}) with eq in the filter.`,
514
558
  inputSchema: {
515
- filter: filterShape(table.columns).refine((f) => !!f && f.length > 0, {
516
- message: "At least one filter is required -- refusing a table-wide update."
517
- }),
559
+ filter: keyFilterShape(table, "update"),
518
560
  set: z2.object(setShape)
519
561
  },
520
562
  annotations: { readOnlyHint: false, destructiveHint: true }
521
563
  },
522
- async (args) => toResult(() => client.update(table.name, filterQueryString(requireFilter(args.filter)), args.set))
564
+ async (args) => toResult(() => client.update(table.name, filterQueryString(requireKeyFilter(args.filter, keys)), args.set))
523
565
  );
524
566
  }
525
567
  function registerDeleteTool(server, table, client, taken) {
@@ -527,15 +569,13 @@ function registerDeleteTool(server, table, client, taken) {
527
569
  server.registerTool(
528
570
  name,
529
571
  {
530
- description: `Delete rows from ${table.name}. At least one filter is required.`,
572
+ description: `Delete one row from ${table.name}, named by its primary key (${keyColumns(table).join(", ")}) with eq in the filter.`,
531
573
  inputSchema: {
532
- filter: filterShape(table.columns).refine((f) => !!f && f.length > 0, {
533
- message: "At least one filter is required -- refusing a table-wide delete."
534
- })
574
+ filter: keyFilterShape(table, "delete")
535
575
  },
536
576
  annotations: { readOnlyHint: false, destructiveHint: true }
537
577
  },
538
- async (args) => toResult(() => client.delete(table.name, filterQueryString(requireFilter(args.filter))))
578
+ async (args) => toResult(() => client.delete(table.name, filterQueryString(requireKeyFilter(args.filter, keyColumns(table)))))
539
579
  );
540
580
  }
541
581
  function registerRpcTool(server, fn, client, openapi, taken) {
@@ -669,7 +709,7 @@ var INTROSPECTION_FIX = "check that SUPABASE_SERVICE_ROLE_KEY is available to th
669
709
  function supabaseToolsPhrase(ladder, cfg, hasDocs) {
670
710
  const query = ladder.tables.length;
671
711
  let write = 0;
672
- for (const table of ladder.tables) write += cfg.writes.get(table.name)?.size ?? 0;
712
+ for (const table of ladder.tables) write += writeTools(table, cfg).ops.length;
673
713
  const rpc = cfg.rpc.length;
674
714
  const fn = Object.keys(cfg.functions).length;
675
715
  const parts = [`${query} query`];
@@ -696,6 +736,26 @@ function logColdStart(opts) {
696
736
  const details = ladder.mode === "manifest" ? ["from the bundled manifest"] : [];
697
737
  console.log(formatReady(LOG_PREFIX, { endpoint, tools: supabaseToolsPhrase(ladder, cfg, docs > 0), details, docs, auth }));
698
738
  }
739
+ const keyless = ladder.tables.flatMap((t) => writeTools(t, cfg).keyless.map((op) => `${t.name}.${op}`));
740
+ if (keyless.length > 0) {
741
+ console.warn(
742
+ formatWarning(
743
+ LOG_PREFIX,
744
+ `writes opted in on tables without a primary key, so they aren't tools (an update or delete must name its row by key): ${keyless.join(", ")}`,
745
+ "add a primary key to the table"
746
+ )
747
+ );
748
+ }
749
+ const ungranted = ladder.tables.flatMap((t) => writeTools(t, cfg).ungranted.map((op) => ({ table: t.name, op })));
750
+ if (ungranted.length > 0) {
751
+ console.warn(
752
+ formatWarning(
753
+ LOG_PREFIX,
754
+ `writes opted in but not granted to the authenticated role, so they aren't tools: ${ungranted.map((w) => `${w.table}.${w.op}`).join(", ")}`,
755
+ ungranted.map((w) => `grant ${w.op} on public.${w.table} to authenticated;`).join(" ")
756
+ )
757
+ );
758
+ }
699
759
  if (ladder.mode === "manifest") {
700
760
  console.warn(
701
761
  formatWarning(LOG_PREFIX, "live schema introspection failed; tools come from the bundled manifest and may be out of date", INTROSPECTION_FIX)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exposify/supabase",
3
- "version": "0.3.11",
3
+ "version": "0.3.13",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -27,7 +27,7 @@
27
27
  "prepack": "yarn build"
28
28
  },
29
29
  "dependencies": {
30
- "@exposify/core": "0.3.16",
30
+ "@exposify/core": "0.3.17",
31
31
  "@modelcontextprotocol/sdk": "^1.12.0",
32
32
  "zod": "^3.24.0"
33
33
  },
@@ -2,8 +2,12 @@
2
2
  --
3
3
  -- Exposes, per PostgREST's RPC convention (POST /rpc/exposify_meta), one row
4
4
  -- per column of every ordinary table in the `public` schema: whether Row
5
- -- Level Security is enabled on its table, and the table/column comments an
6
- -- owner wrote via `COMMENT ON TABLE` / `COMMENT ON COLUMN`.
5
+ -- Level Security is enabled on its table, the table/column comments an
6
+ -- owner wrote via `COMMENT ON TABLE` / `COMMENT ON COLUMN`, and what the
7
+ -- `authenticated` role (the one agents' tool calls run as) is granted on the
8
+ -- table. Without a grant every call answers 403, however the RLS policies
9
+ -- read: Supabase's newer local images no longer grant `anon`/`authenticated`
10
+ -- on new public tables, so a table can have RLS on and still be unreachable.
7
11
  --
8
12
  -- This is the runtime source of truth for the DB exposure policy (RLS-gated,
9
13
  -- reads auto) and for tool/param descriptions -- see the design spec's "DB
@@ -20,13 +24,22 @@
20
24
  -- alter function public.agentify_meta() rename to exposify_meta();
21
25
  -- or drop the old one after confirming nothing else calls it:
22
26
  -- drop function public.agentify_meta();
23
- create or replace function public.exposify_meta()
27
+ --
28
+ -- The grant columns were added later, as a new migration
29
+ -- (`<timestamp>_exposify_meta_v2.sql`): adding output columns changes the
30
+ -- function's return type, which `create or replace` can't do, hence the drop.
31
+ drop function if exists public.exposify_meta();
32
+ create function public.exposify_meta()
24
33
  returns table (
25
34
  table_name text,
26
35
  rls_enabled boolean,
27
36
  table_comment text,
28
37
  column_name text,
29
- column_comment text
38
+ column_comment text,
39
+ can_select boolean,
40
+ can_insert boolean,
41
+ can_update boolean,
42
+ can_delete boolean
30
43
  )
31
44
  language sql
32
45
  security definer
@@ -37,7 +50,12 @@ as $$
37
50
  c.relrowsecurity as rls_enabled,
38
51
  obj_description(c.oid, 'pg_class') as table_comment,
39
52
  a.attname as column_name,
40
- col_description(c.oid, a.attnum) as column_comment
53
+ col_description(c.oid, a.attnum) as column_comment,
54
+ -- "any column" so a grant on some columns counts, as PostgREST sees it.
55
+ has_any_column_privilege('authenticated', c.oid, 'SELECT') as can_select,
56
+ has_any_column_privilege('authenticated', c.oid, 'INSERT') as can_insert,
57
+ has_any_column_privilege('authenticated', c.oid, 'UPDATE') as can_update,
58
+ has_table_privilege('authenticated', c.oid, 'DELETE') as can_delete
41
59
  from pg_catalog.pg_class c
42
60
  join pg_catalog.pg_namespace n on n.oid = c.relnamespace
43
61
  join pg_catalog.pg_attribute a on a.attrelid = c.oid