@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 +26 -11
- package/dist/cli.js +118 -18
- package/dist/index.cjs +81 -22
- package/dist/index.d.cts +22 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +82 -22
- package/package.json +2 -2
- package/sql/exposify_meta.sql +23 -5
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>
|
|
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
|
|
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*
|
|
208
|
-
|
|
209
|
-
invisible, full stop, with no override.
|
|
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>`
|
|
248
|
-
|
|
249
|
-
|
|
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
|
|
347
|
-
|
|
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
|
|
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}` } :
|
|
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(", ")}` } : {
|
|
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
|
|
745
|
-
fix:
|
|
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 = "
|
|
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
|
|
1155
|
+
const opted = parseWrites(config.writes);
|
|
1053
1156
|
const expected = /* @__PURE__ */ new Set();
|
|
1054
|
-
for (const
|
|
1055
|
-
const
|
|
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 (
|
|
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
|
-
|
|
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
|
|
370
|
-
if (
|
|
371
|
-
throw new Error("
|
|
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 =
|
|
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
|
|
446
|
-
if (
|
|
447
|
-
if (
|
|
448
|
-
if (
|
|
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
|
|
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:
|
|
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(
|
|
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
|
|
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:
|
|
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(
|
|
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.
|
|
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
|
|
336
|
-
if (
|
|
337
|
-
throw new Error("
|
|
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 =
|
|
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
|
|
412
|
-
if (
|
|
413
|
-
if (
|
|
414
|
-
if (
|
|
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
|
|
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:
|
|
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(
|
|
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
|
|
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:
|
|
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(
|
|
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.
|
|
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.
|
|
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.
|
|
30
|
+
"@exposify/core": "0.3.17",
|
|
31
31
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
32
32
|
"zod": "^3.24.0"
|
|
33
33
|
},
|
package/sql/exposify_meta.sql
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|