@exposify/supabase 0.3.10 → 0.3.12
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 +35 -20
- package/dist/cli.js +160 -28
- package/dist/index.cjs +104 -28
- package/dist/index.d.cts +24 -2
- package/dist/index.d.ts +24 -2
- package/dist/index.js +105 -29
- package/package.json +2 -2
- package/sql/exposify_meta.sql +24 -6
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ root.
|
|
|
15
15
|
|
|
16
16
|
- A Supabase project (local via the [Supabase CLI](https://supabase.com/docs/guides/cli), or hosted).
|
|
17
17
|
- The Supabase CLI (`supabase` on your `PATH`) for `db push` / `functions deploy`.
|
|
18
|
-
- Node or Yarn to run `npx exposify
|
|
18
|
+
- Node or Yarn to run `npx @exposify/supabase` (the CLI is a regular npm
|
|
19
19
|
package; the edge function itself runs on Deno and needs no local install).
|
|
20
20
|
|
|
21
21
|
## 1. Scaffold
|
|
@@ -23,17 +23,20 @@ root.
|
|
|
23
23
|
From your Supabase project's root (the directory containing `supabase/`):
|
|
24
24
|
|
|
25
25
|
```bash
|
|
26
|
-
npx exposify
|
|
26
|
+
npx @exposify/supabase init
|
|
27
27
|
```
|
|
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).
|
|
@@ -216,7 +224,7 @@ or edit the managed block in `supabase/functions/mcp/index.ts` by hand,
|
|
|
216
224
|
keeping its format. Anything else goes below the block:
|
|
217
225
|
|
|
218
226
|
```ts
|
|
219
|
-
// exposify:begin -- generated by `npx exposify
|
|
227
|
+
// exposify:begin -- generated by `npx @exposify/supabase init`, ...
|
|
220
228
|
import { aiReady } from "npm:@exposify/supabase";
|
|
221
229
|
|
|
222
230
|
const generated = {
|
|
@@ -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.
|
|
@@ -328,8 +340,8 @@ RLS enforcement, not anything in this SDK, is what makes that true.
|
|
|
328
340
|
## 5. Check it: `doctor`
|
|
329
341
|
|
|
330
342
|
```bash
|
|
331
|
-
npx exposify
|
|
332
|
-
npx exposify
|
|
343
|
+
npx @exposify/supabase doctor # finds the project the same way init does
|
|
344
|
+
npx @exposify/supabase doctor --token <user-jwt | extb_...>
|
|
333
345
|
```
|
|
334
346
|
|
|
335
347
|
A read-only health check of everything `init` sets up. It never writes a file
|
|
@@ -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
|
|
@@ -535,7 +550,7 @@ project (local `supabase start` or linked):
|
|
|
535
550
|
```bash
|
|
536
551
|
SUPABASE_URL=http://localhost:54321 \
|
|
537
552
|
SUPABASE_SERVICE_ROLE_KEY=<service-role-key> \
|
|
538
|
-
npx exposify
|
|
553
|
+
npx @exposify/supabase generate
|
|
539
554
|
```
|
|
540
555
|
|
|
541
556
|
Uses the **service role key**, deliberately the same key runtime
|
|
@@ -565,7 +580,7 @@ the function prints whether MCP is up. It shows in `supabase functions serve`
|
|
|
565
580
|
locally and in the function's logs in production:
|
|
566
581
|
|
|
567
582
|
```
|
|
568
|
-
[exposify
|
|
583
|
+
[@exposify/supabase] MCP ready at https://<ref>.supabase.co/functions/v1/mcp: 9 tools (5 query, 2 write, 1 rpc, search_docs), 3 docs indexed, auth: passthrough (caller's JWT)
|
|
569
584
|
```
|
|
570
585
|
|
|
571
586
|
- **Manifest fallback:** the same line, marked "from the bundled manifest",
|
|
@@ -582,7 +597,7 @@ overrides that.
|
|
|
582
597
|
|
|
583
598
|
## Troubleshooting
|
|
584
599
|
|
|
585
|
-
Start with `npx exposify
|
|
600
|
+
Start with `npx @exposify/supabase doctor`. It checks most of what's below
|
|
586
601
|
and names the fix.
|
|
587
602
|
|
|
588
603
|
**"exposify_meta() is not reachable" / "PGRST202" errors in function logs.**
|
|
@@ -617,7 +632,7 @@ above — pointing at a project where the migration is already applied). On a
|
|
|
617
632
|
brand-new project's first `init`, that's not true yet — say yes to the
|
|
618
633
|
deploy prompt (or run `supabase db push` yourself) and the wizard picks up
|
|
619
634
|
automatically once the migration is live, in the same run if you said yes,
|
|
620
|
-
or on your next
|
|
635
|
+
or on your next `@exposify/supabase init` otherwise.
|
|
621
636
|
|
|
622
637
|
**`PostgREST OpenAPI fetch failed: HTTP 401`, and the error body (if you
|
|
623
638
|
curl it directly) says `"Only the service_role API key can be used for this
|
package/dist/cli.js
CHANGED
|
@@ -40,7 +40,7 @@ async function fetchExposifyMeta(postgrestUrl, headers, fetchImpl) {
|
|
|
40
40
|
});
|
|
41
41
|
if (res.status === 404) {
|
|
42
42
|
throw new MissingExposifyMetaError(
|
|
43
|
-
"exposify_meta() is not reachable at /rpc/exposify_meta (404). Run `npx exposify
|
|
43
|
+
"exposify_meta() is not reachable at /rpc/exposify_meta (404). Run `npx @exposify/supabase init` (or apply sql/exposify_meta.sql directly) and `supabase db push`, then redeploy the function."
|
|
44
44
|
);
|
|
45
45
|
}
|
|
46
46
|
if (!res.ok) {
|
|
@@ -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) {
|
|
@@ -399,6 +411,9 @@ async function discoverConnection(deps) {
|
|
|
399
411
|
return { connection: local ? fromLocal() : null, notes };
|
|
400
412
|
}
|
|
401
413
|
|
|
414
|
+
// bin/doctor.ts
|
|
415
|
+
import { DEFAULT_BROKER_URL, parseExposifyKey, readSupabaseAuth } from "@exposify/core";
|
|
416
|
+
|
|
402
417
|
// bin/function-file.ts
|
|
403
418
|
var KEY_SECRET = "EXPOSIFY_KEY";
|
|
404
419
|
var EMPTY_CONFIG = { writes: [], rpc: [], anonymousSignIn: false, broker: null, oauth: false };
|
|
@@ -422,7 +437,7 @@ var ANONYMOUS_MARKER = "anonymous sign-in enabled";
|
|
|
422
437
|
function buildManagedBlock(config) {
|
|
423
438
|
if (config.broker && config.oauth) throw new Error("the hosted broker and Supabase Auth OAuth can't be combined");
|
|
424
439
|
const lines = [
|
|
425
|
-
`${BEGIN} -- generated by \`npx exposify
|
|
440
|
+
`${BEGIN} -- generated by \`npx @exposify/supabase init\`, which rewrites`,
|
|
426
441
|
`// everything down to exposify:end when you re-run it. Add your own code below.`
|
|
427
442
|
];
|
|
428
443
|
if (config.anonymousSignIn) lines.push(...ANONYMOUS_SIGN_IN_WARNING);
|
|
@@ -525,6 +540,53 @@ function configsEqual(a, b) {
|
|
|
525
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;
|
|
526
541
|
}
|
|
527
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
|
+
|
|
528
590
|
// bin/doctor.ts
|
|
529
591
|
var FUNCTION_NAME = "mcp";
|
|
530
592
|
var FUNCTION_PATH = `supabase/functions/${FUNCTION_NAME}/index.ts`;
|
|
@@ -573,6 +635,17 @@ function invisibleTables(metaRows, exposed) {
|
|
|
573
635
|
}
|
|
574
636
|
return { rlsOff, notGranted };
|
|
575
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
|
+
}
|
|
576
649
|
function parseJsonRpcResponse(contentType, body, id) {
|
|
577
650
|
const candidates = contentType.includes("text/event-stream") ? body.split("\n").filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trim()) : [body];
|
|
578
651
|
for (const raw of candidates) {
|
|
@@ -667,14 +740,20 @@ async function runDoctor(deps) {
|
|
|
667
740
|
);
|
|
668
741
|
sections.push({ title: "Project", checks: project });
|
|
669
742
|
const files = [];
|
|
670
|
-
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");
|
|
671
746
|
files.push(
|
|
672
|
-
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" }
|
|
673
752
|
);
|
|
674
753
|
const functionSource = await deps.readText(FUNCTION_PATH);
|
|
675
754
|
const authMode = functionSource === null ? null : detectAuthMode(functionSource);
|
|
676
755
|
files.push(
|
|
677
|
-
functionSource !== null ? { status: "pass", message: `function ${FUNCTION_PATH} (auth: ${AUTH_LABEL[authMode]})` } : { status: "fail", message: `no ${FUNCTION_PATH}`, fix: "npx exposify
|
|
756
|
+
functionSource !== null ? { status: "pass", message: `function ${FUNCTION_PATH} (auth: ${AUTH_LABEL[authMode]})` } : { status: "fail", message: `no ${FUNCTION_PATH}`, fix: "npx @exposify/supabase init" }
|
|
678
757
|
);
|
|
679
758
|
if (bypassesGatewayJwt2(authMode)) {
|
|
680
759
|
const verifyJwt = toml === null ? null : readFunctionVerifyJwt(toml);
|
|
@@ -686,7 +765,7 @@ async function runDoctor(deps) {
|
|
|
686
765
|
} : {
|
|
687
766
|
status: "fail",
|
|
688
767
|
message: `verify_jwt is ${verifyJwt === null ? "unset (defaults to on)" : "on"} for ${FUNCTION_NAME}; ` + (oauth ? "MCP clients will never get the OAuth login challenge" : "the gateway will reject every extb_... token"),
|
|
689
|
-
fix: "re-run `npx exposify
|
|
768
|
+
fix: "re-run `npx @exposify/supabase init` (it sets it), or add `[functions.mcp] verify_jwt = false` to config.toml and redeploy"
|
|
690
769
|
}
|
|
691
770
|
);
|
|
692
771
|
}
|
|
@@ -715,6 +794,13 @@ async function runDoctor(deps) {
|
|
|
715
794
|
} else {
|
|
716
795
|
const tableCount = new Set(meta.value.map((r) => r.table_name)).size;
|
|
717
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
|
+
}
|
|
718
804
|
}
|
|
719
805
|
if (openapi.status === "rejected") {
|
|
720
806
|
const reason = openapi.reason instanceof Error ? openapi.reason.message : String(openapi.reason);
|
|
@@ -724,10 +810,14 @@ async function runDoctor(deps) {
|
|
|
724
810
|
}
|
|
725
811
|
if (openapi.status === "fulfilled" && meta.status === "fulfilled") {
|
|
726
812
|
const tables = introspectTables(openapi.value, meta.value);
|
|
813
|
+
const { rlsOff, notGranted } = invisibleTables(meta.value, tables);
|
|
727
814
|
db.push(
|
|
728
|
-
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
|
+
}
|
|
729
820
|
);
|
|
730
|
-
const { rlsOff, notGranted } = invisibleTables(meta.value, tables);
|
|
731
821
|
if (rlsOff.length > 0) {
|
|
732
822
|
db.push({
|
|
733
823
|
status: "warn",
|
|
@@ -738,8 +828,23 @@ async function runDoctor(deps) {
|
|
|
738
828
|
if (notGranted.length > 0) {
|
|
739
829
|
db.push({
|
|
740
830
|
status: "warn",
|
|
741
|
-
message: `RLS on but
|
|
742
|
-
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(" ")
|
|
743
848
|
});
|
|
744
849
|
}
|
|
745
850
|
const undocumented = tablesMissingDocs(tables).filter((t) => t.missingTableComment || t.columnsWithoutComments.length > 0);
|
|
@@ -747,7 +852,7 @@ async function runDoctor(deps) {
|
|
|
747
852
|
db.push({
|
|
748
853
|
status: "warn",
|
|
749
854
|
message: `${undocumented.length} table(s) missing comments, so tools have thin descriptions: ${undocumented.map((t) => t.name).join(", ")}`,
|
|
750
|
-
fix: "re-run `npx exposify
|
|
855
|
+
fix: "re-run `npx @exposify/supabase init` and write descriptions, or add COMMENT ON TABLE/COLUMN"
|
|
751
856
|
});
|
|
752
857
|
}
|
|
753
858
|
}
|
|
@@ -763,8 +868,35 @@ async function runDoctor(deps) {
|
|
|
763
868
|
}
|
|
764
869
|
if (authMode === "broker") fn.push(licenseSecretCheck(deps));
|
|
765
870
|
sections.push({ title: "Deployed function", checks: fn });
|
|
871
|
+
if (authMode === "broker" && conn) sections.push({ title: "Connect (agents sign in to your Supabase Auth)", checks: await connectChecks(deps, conn) });
|
|
766
872
|
return sections;
|
|
767
873
|
}
|
|
874
|
+
async function connectChecks(deps, conn) {
|
|
875
|
+
const auth = conn.anonKey ? await readSupabaseAuth(conn.url, conn.anonKey, deps.fetch) : void 0;
|
|
876
|
+
const checks = [];
|
|
877
|
+
if (!auth) {
|
|
878
|
+
checks.push({ status: "warn", message: "couldn't read the project's Auth settings (/auth/v1/settings)", fix: "pass the anon key with --key, or check Auth is enabled" });
|
|
879
|
+
return checks;
|
|
880
|
+
}
|
|
881
|
+
checks.push(
|
|
882
|
+
auth.providers.length ? { status: "pass", message: `social sign-in offered to agents: ${auth.providers.join(", ")}` } : {
|
|
883
|
+
status: "warn",
|
|
884
|
+
message: "no social provider is switched on, so agents can only sign in with an email code",
|
|
885
|
+
fix: "switch one on in Auth \u2192 Sign In / Providers, or turn on email codes in the agemno dashboard"
|
|
886
|
+
}
|
|
887
|
+
);
|
|
888
|
+
if (!auth.signupsDisabled) {
|
|
889
|
+
checks.push({ status: "warn", message: "sign-ups are allowed: a new user who signs in with a social provider through Connect gets an account" });
|
|
890
|
+
}
|
|
891
|
+
const projectId = deps.exposifyKey ? parseExposifyKey(deps.exposifyKey)?.projectId : void 0;
|
|
892
|
+
const callback = projectId ? `${(deps.brokerUrl ?? DEFAULT_BROKER_URL).replace(/\/+$/, "")}/projects/${projectId}/supabase-callback` : null;
|
|
893
|
+
checks.push({
|
|
894
|
+
status: "warn",
|
|
895
|
+
message: callback ? `Redirect URLs must include ${callback} (can't be checked from here)` : "Redirect URLs must include the broker's callback (can't be checked from here)",
|
|
896
|
+
fix: callback ? "add it in Auth \u2192 URL Configuration \u2192 Redirect URLs" : "the agemno dashboard's sign-in page shows it; or run doctor with EXPOSIFY_KEY set to print it"
|
|
897
|
+
});
|
|
898
|
+
return checks;
|
|
899
|
+
}
|
|
768
900
|
function probeChecks(url, outcome, authMode, local) {
|
|
769
901
|
switch (outcome.kind) {
|
|
770
902
|
case "ok": {
|
|
@@ -868,7 +1000,8 @@ var FUNCTION_DIR = "supabase/functions/mcp";
|
|
|
868
1000
|
var FUNCTION_PATH2 = `${FUNCTION_DIR}/index.ts`;
|
|
869
1001
|
var MIGRATIONS_DIR2 = "supabase/migrations";
|
|
870
1002
|
var CONFIG_TOML2 = "supabase/config.toml";
|
|
871
|
-
var META_SUFFIX = "
|
|
1003
|
+
var META_SUFFIX = "_exposify_meta_v2.sql";
|
|
1004
|
+
var LEGACY_META_SUFFIX = "_exposify_meta.sql";
|
|
872
1005
|
var COMMENTS_SUFFIX = "_exposify_comments.sql";
|
|
873
1006
|
function authLabel(config) {
|
|
874
1007
|
return config.broker ? "hosted broker" : config.oauth ? "Supabase Auth OAuth" : "user JWT";
|
|
@@ -903,7 +1036,7 @@ function planInit(input) {
|
|
|
903
1036
|
changes.push({
|
|
904
1037
|
path: `${MIGRATIONS_DIR2}/${name}`,
|
|
905
1038
|
action: "create",
|
|
906
|
-
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",
|
|
907
1040
|
content: input.metaMigrationSql,
|
|
908
1041
|
effect: "push"
|
|
909
1042
|
});
|
|
@@ -1019,14 +1152,10 @@ function staleFunctionReason(probe, config, exposedTables) {
|
|
|
1019
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).";
|
|
1020
1153
|
}
|
|
1021
1154
|
if (probe.kind !== "ok" || !exposedTables) return null;
|
|
1022
|
-
const
|
|
1155
|
+
const opted = parseWrites(config.writes);
|
|
1023
1156
|
const expected = /* @__PURE__ */ new Set();
|
|
1024
|
-
for (const
|
|
1025
|
-
const
|
|
1026
|
-
const table = w.slice(0, dot);
|
|
1027
|
-
if (!exposed.has(table)) continue;
|
|
1028
|
-
const op = w.slice(dot + 1);
|
|
1029
|
-
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)}`);
|
|
1030
1159
|
}
|
|
1031
1160
|
for (const fn of config.rpc) expected.add(`rpc_${sanitizeIdentifier(fn)}`);
|
|
1032
1161
|
const live = new Set(probe.tools.filter((t) => /^(insert|update|delete|rpc)_/.test(t)));
|
|
@@ -1042,7 +1171,7 @@ function liveVerdict(s) {
|
|
|
1042
1171
|
let deploy = false;
|
|
1043
1172
|
if (s.migrationApplied === false) {
|
|
1044
1173
|
push = true;
|
|
1045
|
-
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).");
|
|
1046
1175
|
}
|
|
1047
1176
|
if (s.pendingComments.length > 0) {
|
|
1048
1177
|
push = true;
|
|
@@ -1231,7 +1360,8 @@ async function tryIntrospect(conn) {
|
|
|
1231
1360
|
fetchOpenApi(postgrestUrl, headers, fetchWithTimeout),
|
|
1232
1361
|
fetchExposifyMeta(postgrestUrl, headers, fetchWithTimeout)
|
|
1233
1362
|
]);
|
|
1234
|
-
|
|
1363
|
+
const current = metaRows.length === 0 || grantsOf(metaRows[0]) !== void 0;
|
|
1364
|
+
return { introspection: { tables: introspectTables(openapi, metaRows), openapi }, migrationApplied: current };
|
|
1235
1365
|
} catch (err) {
|
|
1236
1366
|
if (err instanceof MissingExposifyMetaError) return { introspection: null, migrationApplied: false };
|
|
1237
1367
|
console.log(`
|
|
@@ -1470,14 +1600,14 @@ No files were changed. Merge the block above into ${FUNCTION_PATH2}, then re-run
|
|
|
1470
1600
|
}
|
|
1471
1601
|
if (!c) {
|
|
1472
1602
|
console.log(
|
|
1473
|
-
"\nNothing changed locally. Couldn't check the project itself (no --url/--key, env vars, or local/linked project) -- run `npx exposify
|
|
1603
|
+
"\nNothing changed locally. Couldn't check the project itself (no --url/--key, env vars, or local/linked project) -- run `npx @exposify/supabase doctor` once it's reachable."
|
|
1474
1604
|
);
|
|
1475
1605
|
return;
|
|
1476
1606
|
}
|
|
1477
1607
|
console.log("\nNothing changed locally, but the project is behind:");
|
|
1478
1608
|
for (const n of verdict.notes) console.log(` - ${n}`);
|
|
1479
1609
|
if (!verdict.push && !verdict.deploy) {
|
|
1480
|
-
console.log("Run `npx exposify
|
|
1610
|
+
console.log("Run `npx @exposify/supabase doctor` for details.");
|
|
1481
1611
|
return;
|
|
1482
1612
|
}
|
|
1483
1613
|
if (c.source === "local") {
|
|
@@ -1493,7 +1623,7 @@ That's the local stack: run ${todo.join(" and ")}.`);
|
|
|
1493
1623
|
const again = await tryIntrospect(await getConn());
|
|
1494
1624
|
if (!again.introspection) {
|
|
1495
1625
|
console.log(
|
|
1496
|
-
"\nDeployed, but couldn't reach the project to introspect tables (no --url/--key, no SUPABASE_URL/SUPABASE_SERVICE_ROLE_KEY, and no local stack or linked project the Supabase CLI could supply keys for). Re-run `exposify
|
|
1626
|
+
"\nDeployed, but couldn't reach the project to introspect tables (no --url/--key, no SUPABASE_URL/SUPABASE_SERVICE_ROLE_KEY, and no local stack or linked project the Supabase CLI could supply keys for). Re-run `npx @exposify/supabase init` with those set to configure writes, RPCs, and documentation interactively."
|
|
1497
1627
|
);
|
|
1498
1628
|
console.log(`
|
|
1499
1629
|
Done. ${clientStep(effectiveAuth)}`);
|
|
@@ -1567,7 +1697,9 @@ async function cmdDoctor() {
|
|
|
1567
1697
|
connectionNotes: notes,
|
|
1568
1698
|
projectRef,
|
|
1569
1699
|
fetch: fetchWithTimeout,
|
|
1570
|
-
token: argValue("--token")
|
|
1700
|
+
token: argValue("--token"),
|
|
1701
|
+
...process.env.EXPOSIFY_KEY ? { exposifyKey: process.env.EXPOSIFY_KEY } : {},
|
|
1702
|
+
...process.env.EXPOSIFY_BROKER_URL ? { brokerUrl: process.env.EXPOSIFY_BROKER_URL } : {}
|
|
1571
1703
|
});
|
|
1572
1704
|
console.log(formatReport(sections));
|
|
1573
1705
|
if (hasFailures(sections)) process.exitCode = 1;
|
|
@@ -1587,7 +1719,7 @@ async function main() {
|
|
|
1587
1719
|
if (cmd === "generate") return cmdGenerate();
|
|
1588
1720
|
if (cmd === "doctor") return cmdDoctor();
|
|
1589
1721
|
console.log(
|
|
1590
|
-
"Usage: exposify
|
|
1722
|
+
"Usage: @exposify/supabase <init|generate|doctor> [--url <url>] [--key <service-role-key>] [--local|--linked] [--broker [--broker-url <url>] | --oauth] [--token <token>] [--check]"
|
|
1591
1723
|
);
|
|
1592
1724
|
console.log(" init Scaffold the exposify_meta() migration + supabase/functions/mcp/index.ts, then (when the");
|
|
1593
1725
|
console.log(" project is reachable) walk through anonymous sign-in, per-table writes, RPC exposure, and");
|
package/dist/index.cjs
CHANGED
|
@@ -107,7 +107,7 @@ function warnOnce(message) {
|
|
|
107
107
|
if (warned.has(message)) return;
|
|
108
108
|
warned.add(message);
|
|
109
109
|
try {
|
|
110
|
-
console.warn(`[exposify
|
|
110
|
+
console.warn(`[@exposify/supabase] ${message}`);
|
|
111
111
|
} catch {
|
|
112
112
|
}
|
|
113
113
|
}
|
|
@@ -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"];
|
|
@@ -206,7 +222,7 @@ async function fetchExposifyMeta(postgrestUrl, headers, fetchImpl) {
|
|
|
206
222
|
});
|
|
207
223
|
if (res.status === 404) {
|
|
208
224
|
throw new MissingExposifyMetaError(
|
|
209
|
-
"exposify_meta() is not reachable at /rpc/exposify_meta (404). Run `npx exposify
|
|
225
|
+
"exposify_meta() is not reachable at /rpc/exposify_meta (404). Run `npx @exposify/supabase init` (or apply sql/exposify_meta.sql directly) and `supabase db push`, then redeploy the function."
|
|
210
226
|
);
|
|
211
227
|
}
|
|
212
228
|
if (!res.ok) {
|
|
@@ -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
|
}
|
|
@@ -442,10 +477,10 @@ function buildSupabaseMcpServer(deps) {
|
|
|
442
477
|
const taken = /* @__PURE__ */ new Set(["search_docs"]);
|
|
443
478
|
for (const table of tables) {
|
|
444
479
|
registerQueryTool(server, table, client, cfg, taken);
|
|
445
|
-
const
|
|
446
|
-
if (
|
|
447
|
-
if (
|
|
448
|
-
if (
|
|
480
|
+
const { ops } = writeTools(table, cfg);
|
|
481
|
+
if (ops.includes("insert")) registerInsertTool(server, table, client, taken);
|
|
482
|
+
if (ops.includes("update")) registerUpdateTool(server, table, client, taken);
|
|
483
|
+
if (ops.includes("delete")) registerDeleteTool(server, table, client, taken);
|
|
449
484
|
}
|
|
450
485
|
for (const fn of cfg.rpc) {
|
|
451
486
|
registerRpcTool(server, fn, client, deps.openapi, taken);
|
|
@@ -537,23 +572,30 @@ function registerInsertTool(server, table, client, taken) {
|
|
|
537
572
|
async (args) => toResult(() => client.insert(table.name, args.body))
|
|
538
573
|
);
|
|
539
574
|
}
|
|
575
|
+
function keyColumns(table) {
|
|
576
|
+
return table.columns.filter((c) => c.isPrimaryKey).map((c) => c.name);
|
|
577
|
+
}
|
|
578
|
+
function keyFilterShape(table, verb) {
|
|
579
|
+
const keys = keyColumns(table);
|
|
580
|
+
const message = `The filter must include eq on ${keys.join(", ")} -- refusing ${verb === "update" ? "an update" : "a delete"} that could match many rows.`;
|
|
581
|
+
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.`);
|
|
582
|
+
}
|
|
540
583
|
function registerUpdateTool(server, table, client, taken) {
|
|
541
584
|
const name = toolName("update", table.name, taken);
|
|
542
585
|
const setShape = {};
|
|
543
586
|
for (const col of table.columns) setShape[col.name] = columnToZod(col).optional();
|
|
587
|
+
const keys = keyColumns(table);
|
|
544
588
|
server.registerTool(
|
|
545
589
|
name,
|
|
546
590
|
{
|
|
547
|
-
description: `Update
|
|
591
|
+
description: `Update one row in ${table.name}, named by its primary key (${keys.join(", ")}) with eq in the filter.`,
|
|
548
592
|
inputSchema: {
|
|
549
|
-
filter:
|
|
550
|
-
message: "At least one filter is required -- refusing a table-wide update."
|
|
551
|
-
}),
|
|
593
|
+
filter: keyFilterShape(table, "update"),
|
|
552
594
|
set: import_zod2.z.object(setShape)
|
|
553
595
|
},
|
|
554
596
|
annotations: { readOnlyHint: false, destructiveHint: true }
|
|
555
597
|
},
|
|
556
|
-
async (args) => toResult(() => client.update(table.name, filterQueryString(
|
|
598
|
+
async (args) => toResult(() => client.update(table.name, filterQueryString(requireKeyFilter(args.filter, keys)), args.set))
|
|
557
599
|
);
|
|
558
600
|
}
|
|
559
601
|
function registerDeleteTool(server, table, client, taken) {
|
|
@@ -561,15 +603,13 @@ function registerDeleteTool(server, table, client, taken) {
|
|
|
561
603
|
server.registerTool(
|
|
562
604
|
name,
|
|
563
605
|
{
|
|
564
|
-
description: `Delete
|
|
606
|
+
description: `Delete one row from ${table.name}, named by its primary key (${keyColumns(table).join(", ")}) with eq in the filter.`,
|
|
565
607
|
inputSchema: {
|
|
566
|
-
filter:
|
|
567
|
-
message: "At least one filter is required -- refusing a table-wide delete."
|
|
568
|
-
})
|
|
608
|
+
filter: keyFilterShape(table, "delete")
|
|
569
609
|
},
|
|
570
610
|
annotations: { readOnlyHint: false, destructiveHint: true }
|
|
571
611
|
},
|
|
572
|
-
async (args) => toResult(() => client.delete(table.name, filterQueryString(
|
|
612
|
+
async (args) => toResult(() => client.delete(table.name, filterQueryString(requireKeyFilter(args.filter, keyColumns(table)))))
|
|
573
613
|
);
|
|
574
614
|
}
|
|
575
615
|
function registerRpcTool(server, fn, client, openapi, taken) {
|
|
@@ -624,7 +664,7 @@ function registerDiagnosticsResource(server, warnings) {
|
|
|
624
664
|
server.registerResource(
|
|
625
665
|
"exposify-diagnostics",
|
|
626
666
|
"exposify://diagnostics",
|
|
627
|
-
{ title: "exposify
|
|
667
|
+
{ title: "@exposify/supabase diagnostics", mimeType: "application/json" },
|
|
628
668
|
async (uri) => ({ contents: [{ uri: uri.href, text: JSON.stringify(warnings, null, 2) }] })
|
|
629
669
|
);
|
|
630
670
|
}
|
|
@@ -698,12 +738,12 @@ function createSupabaseOAuthGate(oauth, supabaseUrl, fetchImpl) {
|
|
|
698
738
|
|
|
699
739
|
// src/startup.ts
|
|
700
740
|
var import_core4 = require("@exposify/core");
|
|
701
|
-
var LOG_PREFIX = "[exposify
|
|
702
|
-
var INTROSPECTION_FIX = "check that SUPABASE_SERVICE_ROLE_KEY is available to the function and the exposify_meta migration is applied (`supabase db push`); the reason is logged above. To survive this, bundle a manifest with `exposify
|
|
741
|
+
var LOG_PREFIX = "[@exposify/supabase]";
|
|
742
|
+
var INTROSPECTION_FIX = "check that SUPABASE_SERVICE_ROLE_KEY is available to the function and the exposify_meta migration is applied (`supabase db push`); the reason is logged above. To survive this, bundle a manifest with `npx @exposify/supabase generate`";
|
|
703
743
|
function supabaseToolsPhrase(ladder, cfg, hasDocs) {
|
|
704
744
|
const query = ladder.tables.length;
|
|
705
745
|
let write = 0;
|
|
706
|
-
for (const table of ladder.tables) write += cfg.
|
|
746
|
+
for (const table of ladder.tables) write += writeTools(table, cfg).ops.length;
|
|
707
747
|
const rpc = cfg.rpc.length;
|
|
708
748
|
const fn = Object.keys(cfg.functions).length;
|
|
709
749
|
const parts = [`${query} query`];
|
|
@@ -730,6 +770,26 @@ function logColdStart(opts) {
|
|
|
730
770
|
const details = ladder.mode === "manifest" ? ["from the bundled manifest"] : [];
|
|
731
771
|
console.log((0, import_core4.formatReady)(LOG_PREFIX, { endpoint, tools: supabaseToolsPhrase(ladder, cfg, docs > 0), details, docs, auth }));
|
|
732
772
|
}
|
|
773
|
+
const keyless = ladder.tables.flatMap((t) => writeTools(t, cfg).keyless.map((op) => `${t.name}.${op}`));
|
|
774
|
+
if (keyless.length > 0) {
|
|
775
|
+
console.warn(
|
|
776
|
+
(0, import_core4.formatWarning)(
|
|
777
|
+
LOG_PREFIX,
|
|
778
|
+
`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(", ")}`,
|
|
779
|
+
"add a primary key to the table"
|
|
780
|
+
)
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
const ungranted = ladder.tables.flatMap((t) => writeTools(t, cfg).ungranted.map((op) => ({ table: t.name, op })));
|
|
784
|
+
if (ungranted.length > 0) {
|
|
785
|
+
console.warn(
|
|
786
|
+
(0, import_core4.formatWarning)(
|
|
787
|
+
LOG_PREFIX,
|
|
788
|
+
`writes opted in but not granted to the authenticated role, so they aren't tools: ${ungranted.map((w) => `${w.table}.${w.op}`).join(", ")}`,
|
|
789
|
+
ungranted.map((w) => `grant ${w.op} on public.${w.table} to authenticated;`).join(" ")
|
|
790
|
+
)
|
|
791
|
+
);
|
|
792
|
+
}
|
|
733
793
|
if (ladder.mode === "manifest") {
|
|
734
794
|
console.warn(
|
|
735
795
|
(0, import_core4.formatWarning)(LOG_PREFIX, "live schema introspection failed; tools come from the bundled manifest and may be out of date", INTROSPECTION_FIX)
|
|
@@ -797,7 +857,7 @@ function aiReady(options = {}) {
|
|
|
797
857
|
manifest: options.manifest
|
|
798
858
|
}).then((ladder) => {
|
|
799
859
|
for (const w of ladder.warnings) {
|
|
800
|
-
console.error(`[exposify
|
|
860
|
+
console.error(`[@exposify/supabase] degradation ladder: ${w.rung}: ${w.reason}`);
|
|
801
861
|
}
|
|
802
862
|
return ladder;
|
|
803
863
|
});
|
|
@@ -834,7 +894,7 @@ function aiReady(options = {}) {
|
|
|
834
894
|
return { status: res.status, bodyText: await res.text() };
|
|
835
895
|
};
|
|
836
896
|
return buildSupabaseMcpServer({
|
|
837
|
-
appName: "exposify
|
|
897
|
+
appName: "@exposify/supabase",
|
|
838
898
|
appVersion: "0.0.0",
|
|
839
899
|
tables: ladder.tables,
|
|
840
900
|
cfg,
|
|
@@ -861,6 +921,16 @@ function aiReady(options = {}) {
|
|
|
861
921
|
const authRequired = options.authRequired ?? (envAuth !== void 0 || (0, import_core5.isHostedResolver)(auth));
|
|
862
922
|
if (!auth) return core;
|
|
863
923
|
const brokerGate = authRequired ? (0, import_core5.createBrokerGate)(auth) : null;
|
|
924
|
+
const authKey = anonKey || env?.get("SUPABASE_PUBLISHABLE_KEY") || "";
|
|
925
|
+
let supabaseAuth2;
|
|
926
|
+
const supabaseAuthOnce = () => {
|
|
927
|
+
if (!supabaseUrl || !authKey) return Promise.resolve(void 0);
|
|
928
|
+
supabaseAuth2 ??= (0, import_core5.readSupabaseAuth)(supabaseUrl, authKey, fetchImpl).then((found) => {
|
|
929
|
+
if (!found) supabaseAuth2 = void 0;
|
|
930
|
+
return found;
|
|
931
|
+
});
|
|
932
|
+
return supabaseAuth2;
|
|
933
|
+
};
|
|
864
934
|
const functionUrl = supabaseUrl && functionName ? `${supabaseUrl.replace(/\/+$/, "")}/functions/v1/${functionName}` : null;
|
|
865
935
|
return async (req) => {
|
|
866
936
|
let open = false;
|
|
@@ -868,7 +938,13 @@ function aiReady(options = {}) {
|
|
|
868
938
|
const own = (0, import_core5.publicRequestUrl)(req.url, req.headers);
|
|
869
939
|
const isPrm = own.endsWith(PRM_SUFFIX);
|
|
870
940
|
const resource = brokerGate.resourceFor(functionUrl ?? (isPrm ? own.slice(0, -PRM_SUFFIX.length) : own));
|
|
871
|
-
|
|
941
|
+
const supabase = await supabaseAuthOnce();
|
|
942
|
+
open = await (0, import_core5.reportAppLogin)(auth, resource, null, {
|
|
943
|
+
stacks: ["@supabase/supabase-js"],
|
|
944
|
+
why: supabase ? "Your Supabase Auth is turned off for agents in the dashboard." : "Couldn't read your project's Supabase Auth settings (/auth/v1/settings with the anon key), so Connect can't offer it yet.",
|
|
945
|
+
log: logReady,
|
|
946
|
+
...supabase ? { supabase } : {}
|
|
947
|
+
}) === "open";
|
|
872
948
|
if (open) {
|
|
873
949
|
if (isPrm) return new Response("not found", { status: 404 });
|
|
874
950
|
} else {
|
|
@@ -881,7 +957,7 @@ function aiReady(options = {}) {
|
|
|
881
957
|
try {
|
|
882
958
|
resolved = await auth(req.headers, {});
|
|
883
959
|
} catch (err) {
|
|
884
|
-
console.error("exposify
|
|
960
|
+
console.error("@exposify/supabase: auth resolver failed", err);
|
|
885
961
|
return Response.json({ error: "internal MCP server error" }, { status: 503 });
|
|
886
962
|
}
|
|
887
963
|
if (authRequired && !open && !resolved.get("authorization")) {
|
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 {
|
|
@@ -126,7 +143,7 @@ interface AgentifySupabaseOptions {
|
|
|
126
143
|
authRequired?: boolean;
|
|
127
144
|
/** Injectable for tests; defaults to global `fetch`. */
|
|
128
145
|
fetchImpl?: typeof fetch;
|
|
129
|
-
/** Bundled fallback manifest (from
|
|
146
|
+
/** Bundled fallback manifest (from `@exposify/supabase generate`), used when runtime introspection fails. */
|
|
130
147
|
manifest?: unknown;
|
|
131
148
|
rateLimit?: number;
|
|
132
149
|
rateWindowMs?: number;
|
|
@@ -196,7 +213,7 @@ interface ExposifyManifest {
|
|
|
196
213
|
generatedAt: string;
|
|
197
214
|
tables: TableSchema[];
|
|
198
215
|
}
|
|
199
|
-
/** Built by `npx exposify
|
|
216
|
+
/** Built by `npx @exposify/supabase generate` against a local/linked project. */
|
|
200
217
|
declare function buildManifest(openapi: PostgrestOpenApiDoc, metaRows: MetaRow[], generatedAt: string): ExposifyManifest;
|
|
201
218
|
/**
|
|
202
219
|
* Defensive load: a manifest is bundled JSON shipped alongside the function
|
|
@@ -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 {
|
|
@@ -126,7 +143,7 @@ interface AgentifySupabaseOptions {
|
|
|
126
143
|
authRequired?: boolean;
|
|
127
144
|
/** Injectable for tests; defaults to global `fetch`. */
|
|
128
145
|
fetchImpl?: typeof fetch;
|
|
129
|
-
/** Bundled fallback manifest (from
|
|
146
|
+
/** Bundled fallback manifest (from `@exposify/supabase generate`), used when runtime introspection fails. */
|
|
130
147
|
manifest?: unknown;
|
|
131
148
|
rateLimit?: number;
|
|
132
149
|
rateWindowMs?: number;
|
|
@@ -196,7 +213,7 @@ interface ExposifyManifest {
|
|
|
196
213
|
generatedAt: string;
|
|
197
214
|
tables: TableSchema[];
|
|
198
215
|
}
|
|
199
|
-
/** Built by `npx exposify
|
|
216
|
+
/** Built by `npx @exposify/supabase generate` against a local/linked project. */
|
|
200
217
|
declare function buildManifest(openapi: PostgrestOpenApiDoc, metaRows: MetaRow[], generatedAt: string): ExposifyManifest;
|
|
201
218
|
/**
|
|
202
219
|
* Defensive load: a manifest is bundled JSON shipped alongside the function
|
|
@@ -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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import { createBrokerGate, createWebMcpHandler, formatFailed as formatFailed2, publicRequestUrl, formatWarning as formatWarning2, hostedResolverFromEnv, isHostedResolver, readEnv, reportAppLogin, startupLogEnabled } from "@exposify/core";
|
|
2
|
+
import { createBrokerGate, createWebMcpHandler, formatFailed as formatFailed2, publicRequestUrl, formatWarning as formatWarning2, hostedResolverFromEnv, isHostedResolver, readEnv, readSupabaseAuth, reportAppLogin, startupLogEnabled } from "@exposify/core";
|
|
3
3
|
|
|
4
4
|
// src/kb.ts
|
|
5
5
|
import { KnowledgeBase } from "@exposify/core";
|
|
@@ -70,7 +70,7 @@ function warnOnce(message) {
|
|
|
70
70
|
if (warned.has(message)) return;
|
|
71
71
|
warned.add(message);
|
|
72
72
|
try {
|
|
73
|
-
console.warn(`[exposify
|
|
73
|
+
console.warn(`[@exposify/supabase] ${message}`);
|
|
74
74
|
} catch {
|
|
75
75
|
}
|
|
76
76
|
}
|
|
@@ -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"];
|
|
@@ -169,7 +185,7 @@ async function fetchExposifyMeta(postgrestUrl, headers, fetchImpl) {
|
|
|
169
185
|
});
|
|
170
186
|
if (res.status === 404) {
|
|
171
187
|
throw new MissingExposifyMetaError(
|
|
172
|
-
"exposify_meta() is not reachable at /rpc/exposify_meta (404). Run `npx exposify
|
|
188
|
+
"exposify_meta() is not reachable at /rpc/exposify_meta (404). Run `npx @exposify/supabase init` (or apply sql/exposify_meta.sql directly) and `supabase db push`, then redeploy the function."
|
|
173
189
|
);
|
|
174
190
|
}
|
|
175
191
|
if (!res.ok) {
|
|
@@ -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) {
|
|
@@ -332,9 +360,16 @@ function buildQueryString(args, maxRows) {
|
|
|
332
360
|
params.set("limit", String(limit));
|
|
333
361
|
return params.toString();
|
|
334
362
|
}
|
|
335
|
-
function
|
|
336
|
-
if (
|
|
337
|
-
throw new Error("
|
|
363
|
+
function requireKeyFilter(filter, keyColumns2) {
|
|
364
|
+
if (keyColumns2.length === 0) {
|
|
365
|
+
throw new Error("This table has no primary key, so a single row can't be named -- refusing the update/delete.");
|
|
366
|
+
}
|
|
367
|
+
const pinned = new Set((filter ?? []).filter((f) => f.op === "eq").map((f) => f.column));
|
|
368
|
+
const unpinned = keyColumns2.filter((c) => !pinned.has(c));
|
|
369
|
+
if (unpinned.length > 0) {
|
|
370
|
+
throw new Error(
|
|
371
|
+
`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.`
|
|
372
|
+
);
|
|
338
373
|
}
|
|
339
374
|
return filter;
|
|
340
375
|
}
|
|
@@ -408,10 +443,10 @@ function buildSupabaseMcpServer(deps) {
|
|
|
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) {
|
|
@@ -590,7 +630,7 @@ function registerDiagnosticsResource(server, warnings) {
|
|
|
590
630
|
server.registerResource(
|
|
591
631
|
"exposify-diagnostics",
|
|
592
632
|
"exposify://diagnostics",
|
|
593
|
-
{ title: "exposify
|
|
633
|
+
{ title: "@exposify/supabase diagnostics", mimeType: "application/json" },
|
|
594
634
|
async (uri) => ({ contents: [{ uri: uri.href, text: JSON.stringify(warnings, null, 2) }] })
|
|
595
635
|
);
|
|
596
636
|
}
|
|
@@ -664,12 +704,12 @@ function createSupabaseOAuthGate(oauth, supabaseUrl, fetchImpl) {
|
|
|
664
704
|
|
|
665
705
|
// src/startup.ts
|
|
666
706
|
import { formatFailed, formatReady, formatWarning } from "@exposify/core";
|
|
667
|
-
var LOG_PREFIX = "[exposify
|
|
668
|
-
var INTROSPECTION_FIX = "check that SUPABASE_SERVICE_ROLE_KEY is available to the function and the exposify_meta migration is applied (`supabase db push`); the reason is logged above. To survive this, bundle a manifest with `exposify
|
|
707
|
+
var LOG_PREFIX = "[@exposify/supabase]";
|
|
708
|
+
var INTROSPECTION_FIX = "check that SUPABASE_SERVICE_ROLE_KEY is available to the function and the exposify_meta migration is applied (`supabase db push`); the reason is logged above. To survive this, bundle a manifest with `npx @exposify/supabase generate`";
|
|
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)
|
|
@@ -763,7 +823,7 @@ function aiReady(options = {}) {
|
|
|
763
823
|
manifest: options.manifest
|
|
764
824
|
}).then((ladder) => {
|
|
765
825
|
for (const w of ladder.warnings) {
|
|
766
|
-
console.error(`[exposify
|
|
826
|
+
console.error(`[@exposify/supabase] degradation ladder: ${w.rung}: ${w.reason}`);
|
|
767
827
|
}
|
|
768
828
|
return ladder;
|
|
769
829
|
});
|
|
@@ -800,7 +860,7 @@ function aiReady(options = {}) {
|
|
|
800
860
|
return { status: res.status, bodyText: await res.text() };
|
|
801
861
|
};
|
|
802
862
|
return buildSupabaseMcpServer({
|
|
803
|
-
appName: "exposify
|
|
863
|
+
appName: "@exposify/supabase",
|
|
804
864
|
appVersion: "0.0.0",
|
|
805
865
|
tables: ladder.tables,
|
|
806
866
|
cfg,
|
|
@@ -827,6 +887,16 @@ function aiReady(options = {}) {
|
|
|
827
887
|
const authRequired = options.authRequired ?? (envAuth !== void 0 || isHostedResolver(auth));
|
|
828
888
|
if (!auth) return core;
|
|
829
889
|
const brokerGate = authRequired ? createBrokerGate(auth) : null;
|
|
890
|
+
const authKey = anonKey || env?.get("SUPABASE_PUBLISHABLE_KEY") || "";
|
|
891
|
+
let supabaseAuth2;
|
|
892
|
+
const supabaseAuthOnce = () => {
|
|
893
|
+
if (!supabaseUrl || !authKey) return Promise.resolve(void 0);
|
|
894
|
+
supabaseAuth2 ??= readSupabaseAuth(supabaseUrl, authKey, fetchImpl).then((found) => {
|
|
895
|
+
if (!found) supabaseAuth2 = void 0;
|
|
896
|
+
return found;
|
|
897
|
+
});
|
|
898
|
+
return supabaseAuth2;
|
|
899
|
+
};
|
|
830
900
|
const functionUrl = supabaseUrl && functionName ? `${supabaseUrl.replace(/\/+$/, "")}/functions/v1/${functionName}` : null;
|
|
831
901
|
return async (req) => {
|
|
832
902
|
let open = false;
|
|
@@ -834,7 +904,13 @@ function aiReady(options = {}) {
|
|
|
834
904
|
const own = publicRequestUrl(req.url, req.headers);
|
|
835
905
|
const isPrm = own.endsWith(PRM_SUFFIX);
|
|
836
906
|
const resource = brokerGate.resourceFor(functionUrl ?? (isPrm ? own.slice(0, -PRM_SUFFIX.length) : own));
|
|
837
|
-
|
|
907
|
+
const supabase = await supabaseAuthOnce();
|
|
908
|
+
open = await reportAppLogin(auth, resource, null, {
|
|
909
|
+
stacks: ["@supabase/supabase-js"],
|
|
910
|
+
why: supabase ? "Your Supabase Auth is turned off for agents in the dashboard." : "Couldn't read your project's Supabase Auth settings (/auth/v1/settings with the anon key), so Connect can't offer it yet.",
|
|
911
|
+
log: logReady,
|
|
912
|
+
...supabase ? { supabase } : {}
|
|
913
|
+
}) === "open";
|
|
838
914
|
if (open) {
|
|
839
915
|
if (isPrm) return new Response("not found", { status: 404 });
|
|
840
916
|
} else {
|
|
@@ -847,7 +923,7 @@ function aiReady(options = {}) {
|
|
|
847
923
|
try {
|
|
848
924
|
resolved = await auth(req.headers, {});
|
|
849
925
|
} catch (err) {
|
|
850
|
-
console.error("exposify
|
|
926
|
+
console.error("@exposify/supabase: auth resolver failed", err);
|
|
851
927
|
return Response.json({ error: "internal MCP server error" }, { status: 503 });
|
|
852
928
|
}
|
|
853
929
|
if (authRequired && !open && !resolved.get("authorization")) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exposify/supabase",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.12",
|
|
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.16",
|
|
31
31
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
32
32
|
"zod": "^3.24.0"
|
|
33
33
|
},
|
package/sql/exposify_meta.sql
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
-- exposify
|
|
1
|
+
-- @exposify/supabase: schema introspection function.
|
|
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
|