@exposify/supabase 0.1.0

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 ADDED
@@ -0,0 +1,228 @@
1
+ # @exposify/supabase — setup guide
2
+
3
+ Make a Supabase-backed app's database and edge functions usable by AI agents:
4
+ one edge function serves MCP, with tools generated from your database
5
+ (PostgREST + Row Level Security) plus any edge functions you explicitly
6
+ register. The caller's JWT passes straight through to PostgREST — **RLS is
7
+ the authorization layer**. The agent can do exactly what that logged-in user
8
+ can do, nothing more.
9
+
10
+ This guide walks through setup end to end. For the design rationale, see
11
+ `docs/superpowers/specs/2026-08-15-agentify-supabase-design.md` in the repo
12
+ root.
13
+
14
+ ## Prerequisites
15
+
16
+ - A Supabase project (local via the [Supabase CLI](https://supabase.com/docs/guides/cli), or hosted).
17
+ - The Supabase CLI (`supabase` on your `PATH`) for `db push` / `functions deploy`.
18
+ - Node or Yarn to run `npx agentify-supabase` (the CLI is a regular npm
19
+ package; the edge function itself runs on Deno and needs no local install).
20
+
21
+ ## 1. Scaffold
22
+
23
+ From your Supabase project's root (the directory containing `supabase/`):
24
+
25
+ ```bash
26
+ npx agentify-supabase init
27
+ ```
28
+
29
+ This writes two things and never overwrites an existing function file:
30
+
31
+ - `supabase/migrations/<timestamp>_agentify_meta.sql` — a SQL migration that
32
+ adds `agentify_meta()`, a `SECURITY DEFINER` function returning, per table,
33
+ whether RLS is enabled and any `COMMENT ON TABLE`/`COMMENT ON COLUMN` text
34
+ you've written. It only ever returns catalog metadata (table/column names,
35
+ 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
+ - `supabase/functions/mcp/index.ts` — the edge function itself, a few lines
38
+ around `aiReady()`.
39
+ - `supabase/functions/mcp/docs/` — an empty directory for optional bundled
40
+ docs (see "Knowledge base" below).
41
+
42
+ `init` is interactive from there. If the Supabase CLI is on your `PATH` and
43
+ you're running it in a real terminal, it asks:
44
+
45
+ ```
46
+ Apply the migration and deploy the function to whatever Supabase project is
47
+ currently linked, now? [y/N]
48
+ ```
49
+
50
+ Answering **y** runs `supabase db push` then `supabase functions deploy mcp`
51
+ for you, streaming their output. The default is **no** — pressing Enter, the
52
+ Supabase CLI not being installed, or running `init` non-interactively (CI, a
53
+ script, piped input) all skip straight to printing the commands instead:
54
+
55
+ ```
56
+ Run these to finish setup:
57
+ 1. supabase db push
58
+ 2. supabase functions deploy mcp
59
+ 3. Paste the function URL + a user JWT into an MCP client.
60
+ ```
61
+
62
+ That's deliberate: `db push`/`functions deploy` act on whatever project is
63
+ *currently linked*, which may not be a throwaway one, so nothing gets pushed
64
+ without an explicit yes. If you want to edit `writes`/`rpc`/`functions` (next
65
+ section) before the first deploy, just say no here and run the printed
66
+ commands yourself once you're done editing.
67
+
68
+ ## 2. Decide what's exposed
69
+
70
+ **Every RLS-enabled table is automatically readable.** A table only shows up
71
+ at all if `agentify_meta()` reports RLS is turned on for it *and* PostgREST
72
+ actually exposes it (the calling role has grants) — an RLS-less table is
73
+ invisible, full stop, with no override. For each exposed table you get a
74
+ `query_<table>` tool: typed filters (`eq`/`neq`/`gt`/`lt`/`like`), a `select`
75
+ column list, `order`, and a `limit` capped server-side (100 rows by default,
76
+ regardless of what the caller asks for).
77
+
78
+ Writes and Postgres functions are **opt-in**, edited in
79
+ `supabase/functions/mcp/index.ts`:
80
+
81
+ ```ts
82
+ import { aiReady } from "npm:@exposify/supabase";
83
+
84
+ Deno.serve(aiReady({
85
+ // "<table>.insert" | "<table>.update" | "<table>.delete" | "<table>.*"
86
+ writes: ["orders.insert", "orders.update"],
87
+
88
+ // Postgres functions exposed as rpc_<fn> tools
89
+ rpc: ["place_order"],
90
+
91
+ // Sibling edge functions, invoked via fetch with the caller's forwarded JWT
92
+ functions: {
93
+ refund: {
94
+ description: "Refund an order",
95
+ input: {
96
+ type: "object",
97
+ properties: { orderId: { type: "string", description: "Order to refund" } },
98
+ required: ["orderId"],
99
+ },
100
+ },
101
+ },
102
+ }));
103
+ ```
104
+
105
+ Notes:
106
+ - `update_<table>` and `delete_<table>` always require at least one filter —
107
+ there is no way to opt into a table-wide mutation, even where RLS would
108
+ technically allow it.
109
+ - Column/table descriptions shown to the agent come from SQL comments
110
+ (`COMMENT ON TABLE orders IS '...'`, `COMMENT ON COLUMN orders.status IS
111
+ '...'`) — write these once and every generated tool reads them.
112
+ - A registered `functions` entry's `input` is a small JSON Schema subset
113
+ (`object`/`string`/`number`/`integer`/`boolean`/`array`, `enum`,
114
+ `required`) — enough for a flat tool-input shape, not a full JSON Schema
115
+ implementation.
116
+
117
+ Other options you can pass to `aiReady()`:
118
+
119
+ | Option | Default | What it does |
120
+ |---|---|---|
121
+ | `docs` | `["docs/**/*.md"]` | Glob(s), relative to the function directory, bundled into the knowledge base |
122
+ | `docsAuth` | `"public"` | `"required"` hides `search_docs` until the caller sends *any* `Authorization` header (presence-only, not validated) |
123
+ | `maxRows` | `100` | Hard cap applied to every `query_<table>` call |
124
+ | `rateLimit` / `rateWindowMs` | `60` / `60000` | In-memory, per-instance, best-effort rate limit — **not** distributed across edge instances; real limits belong to Supabase's own gateway |
125
+ | `supabaseUrl` / `anonKey` | read from `SUPABASE_URL` / `SUPABASE_ANON_KEY` env vars | Override for local dev/testing; in a deployed function the env vars are automatic |
126
+
127
+ ## 3. Apply the migration and deploy
128
+
129
+ If you didn't let `init` do this for you (or you've since edited `writes`/
130
+ `rpc`/`functions`/`docs` and need to redeploy):
131
+
132
+ ```bash
133
+ supabase db push
134
+ supabase functions deploy mcp
135
+ ```
136
+
137
+ Only `functions deploy` is needed for a config-only change to `index.ts`;
138
+ `db push` is only needed again if the migration itself changed (it hasn't,
139
+ unless you're editing `sql/agentify_meta.sql` directly).
140
+
141
+ (Local dev: `supabase start` then `supabase functions serve mcp` instead of
142
+ `deploy`.)
143
+
144
+ ## 4. Connect an MCP client
145
+
146
+ Point any MCP client at the function's URL with a real user's JWT as the
147
+ bearer token — for example, with the Claude Code CLI:
148
+
149
+ ```bash
150
+ claude mcp add --transport http my-app https://<project-ref>.supabase.co/functions/v1/mcp \
151
+ --header "Authorization: Bearer <user-jwt>"
152
+ ```
153
+
154
+ Ask the agent to query or mutate data. **It should succeed for that user's
155
+ own rows and be refused everyone else's** — that's the definition of done:
156
+ RLS enforcement, not anything in this SDK, is what makes that true.
157
+
158
+ ## Knowledge base
159
+
160
+ Any markdown files matching the `docs` glob (default `docs/**/*.md`,
161
+ relative to `supabase/functions/mcp/`) are bundled with the function,
162
+ chunked, and indexed with BM25 the first time the function handles a
163
+ request in a given instance. There's no cache file and no invalidation
164
+ logic — redeploying the function *is* the invalidation. Agents can search
165
+ them via the `search_docs` tool.
166
+
167
+ ## When PostgREST is unreachable: the fallback ladder
168
+
169
+ Every request tries, in order:
170
+
171
+ 1. **Runtime introspection** — fetch PostgREST's OpenAPI document and call
172
+ `agentify_meta()`. This is the normal path; the result is cached in
173
+ memory for the life of the edge instance, not refetched per call.
174
+ 2. **Bundled manifest** — if step 1 fails, and a `agentify.manifest.json`
175
+ was generated ahead of time (see below), tools are built from that
176
+ instead.
177
+ 3. **Functions + knowledge base only** — if neither works, the function
178
+ still serves any registered `functions` tools and `search_docs`, never
179
+ crashes, and says so.
180
+
181
+ Whichever rung actually served the request is disclosed to the MCP client
182
+ in the `initialize` handshake's `instructions` field, so a degraded server
183
+ never looks silently healthy.
184
+
185
+ To generate the fallback manifest ahead of time, run against a reachable
186
+ project (local `supabase start` or linked):
187
+
188
+ ```bash
189
+ SUPABASE_URL=http://localhost:54321 \
190
+ SUPABASE_SERVICE_ROLE_KEY=<service-role-key> \
191
+ npx agentify-supabase generate
192
+ ```
193
+
194
+ This writes `supabase/functions/mcp/agentify.manifest.json`. Load it in
195
+ `aiReady({ manifest: ... })` — since the function runs on Deno, import it
196
+ with a JSON import:
197
+
198
+ ```ts
199
+ import manifest from "./agentify.manifest.json" with { type: "json" };
200
+
201
+ Deno.serve(aiReady({ manifest, /* ...writes/rpc/functions... */ }));
202
+ ```
203
+
204
+ ## Troubleshooting
205
+
206
+ **"agentify_meta() is not reachable" / "PGRST202" errors in function logs.**
207
+ The migration hasn't been applied to this database. Run `supabase db push`
208
+ (or apply `sql/agentify_meta.sql` from this package directly), then
209
+ redeploy the function — the error message names this exact fix.
210
+
211
+ **A table I expected doesn't show up as a tool.** Check, in order: is RLS
212
+ actually enabled on it (`alter table ... enable row level security;`)? Does
213
+ the calling role (`anon`/`authenticated`) have grants PostgREST can see? If
214
+ both are true and it's still missing, confirm the migration is applied —
215
+ `agentify_meta()` is what reports the RLS flag in the first place.
216
+
217
+ **An `rpc_<fn>` tool takes a single opaque `args` object instead of named
218
+ parameters.** PostgREST's OpenAPI document didn't have a recognizable
219
+ argument schema for that function (older PostgREST, or a function with no
220
+ declared parameters in a shape this SDK's parser recognizes) — pass a JSON
221
+ object of the function's actual parameter names as `args` and it's forwarded
222
+ as-is.
223
+
224
+ **Writes/RPCs I configured aren't callable.** Double-check the `writes`
225
+ entries are `"<table>.insert"` / `"<table>.update"` / `"<table>.delete"` /
226
+ `"<table>.*"` — a malformed entry is dropped (with a console warning), not
227
+ an error, so a typo silently disables just that grant rather than crashing
228
+ the function.
package/dist/cli.js ADDED
@@ -0,0 +1,266 @@
1
+ #!/usr/bin/env node
2
+
3
+ // bin/cli.ts
4
+ import { mkdir, readFile, writeFile, access } from "fs/promises";
5
+ import { dirname, join } from "path";
6
+ import { fileURLToPath } from "url";
7
+ import { execFileSync } from "child_process";
8
+ import { createInterface } from "readline/promises";
9
+
10
+ // src/fetch-openapi.ts
11
+ async function fetchOpenApi(postgrestUrl, headers, fetchImpl) {
12
+ const res = await fetchImpl(postgrestUrl, {
13
+ headers: { ...Object.fromEntries(headers), accept: "application/openapi+json" }
14
+ });
15
+ if (!res.ok) {
16
+ throw new Error(`PostgREST OpenAPI fetch failed: HTTP ${res.status}`);
17
+ }
18
+ return await res.json();
19
+ }
20
+
21
+ // src/meta.ts
22
+ var MissingAgentifyMetaError = class extends Error {
23
+ constructor(cause) {
24
+ super(cause);
25
+ this.name = "MissingAgentifyMetaError";
26
+ }
27
+ };
28
+ async function fetchAgentifyMeta(postgrestUrl, headers, fetchImpl) {
29
+ const res = await fetchImpl(`${postgrestUrl}/rpc/agentify_meta`, {
30
+ method: "POST",
31
+ headers: { ...Object.fromEntries(headers), "content-type": "application/json" },
32
+ body: "{}"
33
+ });
34
+ if (res.status === 404) {
35
+ throw new MissingAgentifyMetaError(
36
+ "agentify_meta() is not reachable at /rpc/agentify_meta (404). Run `npx agentify-supabase init` (or apply sql/agentify_meta.sql directly) and `supabase db push`, then redeploy the function."
37
+ );
38
+ }
39
+ if (!res.ok) {
40
+ const body = await res.text().catch(() => "");
41
+ if (/PGRST202|function .* does not exist/i.test(body)) {
42
+ throw new MissingAgentifyMetaError(
43
+ "agentify_meta() is missing from the database (PostgREST error PGRST202). Apply the migration in sql/agentify_meta.sql via `supabase db push`, then redeploy the function."
44
+ );
45
+ }
46
+ throw new Error(`agentify_meta() call failed: HTTP ${res.status} ${body}`.trim());
47
+ }
48
+ return await res.json();
49
+ }
50
+
51
+ // src/openapi.ts
52
+ var TYPE_MAP = {
53
+ integer: "integer",
54
+ number: "number",
55
+ boolean: "boolean",
56
+ object: "object",
57
+ array: "array",
58
+ string: "string"
59
+ };
60
+ function isPrimaryKey(prop) {
61
+ return /<pk\/>/i.test(prop.description ?? "");
62
+ }
63
+ function columnsFromOpenApi(doc, table) {
64
+ const def = doc.definitions?.[table];
65
+ if (!def?.properties) return [];
66
+ const required = new Set(def.required ?? []);
67
+ return Object.entries(def.properties).map(([name, prop]) => ({
68
+ name,
69
+ type: TYPE_MAP[prop.type ?? "string"] ?? "string",
70
+ format: prop.format,
71
+ required: required.has(name),
72
+ isPrimaryKey: isPrimaryKey(prop),
73
+ // Falls back to the OpenAPI-embedded comment (PostgREST mirrors `COMMENT
74
+ // ON COLUMN` into `description`, then appends its own "Note:\nThis is a
75
+ // Primary/Foreign Key...<pk/|fk/>" boilerplate) -- strip that boilerplate
76
+ // so a column with no real comment ends up `undefined`, not the
77
+ // boilerplate text. agentify_meta()'s copy wins when both exist anyway
78
+ // -- see introspect.ts's merge.
79
+ description: prop.description?.replace(/\n*Note:[\s\S]*$/, "").trim() || void 0
80
+ }));
81
+ }
82
+ function tablesInOpenApi(doc) {
83
+ return new Set(Object.keys(doc.definitions ?? {}));
84
+ }
85
+
86
+ // src/introspect.ts
87
+ function introspectTables(openapi, metaRows) {
88
+ const exposedInOpenApi = tablesInOpenApi(openapi);
89
+ const byTable = groupByTable(metaRows);
90
+ const tables = [];
91
+ for (const [name, rows] of byTable) {
92
+ const rlsEnabled = rows[0].rls_enabled;
93
+ if (!rlsEnabled) continue;
94
+ if (!exposedInOpenApi.has(name)) continue;
95
+ const openApiColumns = new Map(columnsFromOpenApi(openapi, name).map((c) => [c.name, c]));
96
+ const columns = [];
97
+ for (const row of rows) {
98
+ const fromOpenApi = openApiColumns.get(row.column_name);
99
+ if (!fromOpenApi) continue;
100
+ columns.push({
101
+ ...fromOpenApi,
102
+ // agentify_meta()'s comment wins over OpenAPI's mirrored copy (spec:
103
+ // "Column comments → param descriptions" sourced from the migration).
104
+ description: row.column_comment ?? fromOpenApi.description
105
+ });
106
+ }
107
+ if (columns.length === 0) continue;
108
+ tables.push({
109
+ name,
110
+ rlsEnabled: true,
111
+ description: rows[0].table_comment ?? void 0,
112
+ columns
113
+ });
114
+ }
115
+ return tables.sort((a, b) => a.name.localeCompare(b.name));
116
+ }
117
+ function groupByTable(rows) {
118
+ const map = /* @__PURE__ */ new Map();
119
+ for (const row of rows) {
120
+ const list = map.get(row.table_name) ?? [];
121
+ list.push(row);
122
+ map.set(row.table_name, list);
123
+ }
124
+ return map;
125
+ }
126
+
127
+ // src/manifest.ts
128
+ var MANIFEST_VERSION = 1;
129
+ function buildManifest(openapi, metaRows, generatedAt) {
130
+ return { version: MANIFEST_VERSION, generatedAt, tables: introspectTables(openapi, metaRows) };
131
+ }
132
+
133
+ // bin/cli.ts
134
+ var PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
135
+ var FUNCTION_DIR = "supabase/functions/mcp";
136
+ var FUNCTION_TEMPLATE = `import { aiReady } from "npm:@exposify/supabase";
137
+
138
+ Deno.serve(aiReady({
139
+ // writes: ["orders.insert", "orders.update"],
140
+ // rpc: ["place_order"],
141
+ // functions: {
142
+ // refund: { description: "Refund an order", input: { type: "object", properties: {} } },
143
+ // },
144
+ }));
145
+ `;
146
+ async function exists(path) {
147
+ try {
148
+ await access(path);
149
+ return true;
150
+ } catch {
151
+ return false;
152
+ }
153
+ }
154
+ function hasSupabaseCli() {
155
+ try {
156
+ execFileSync("supabase", ["--version"], { stdio: "ignore" });
157
+ return true;
158
+ } catch {
159
+ return false;
160
+ }
161
+ }
162
+ async function promptYesNo(question) {
163
+ if (!process.stdin.isTTY) return false;
164
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
165
+ try {
166
+ const answer = (await rl.question(`${question} [y/N] `)).trim().toLowerCase();
167
+ return answer === "y" || answer === "yes";
168
+ } finally {
169
+ rl.close();
170
+ }
171
+ }
172
+ function printManualCommands(opts = {}) {
173
+ console.log("\nRun these to finish setup:");
174
+ const steps = [];
175
+ if (!opts.alreadyPushed) steps.push("supabase db push");
176
+ if (!opts.alreadyDeployed) steps.push("supabase functions deploy mcp");
177
+ steps.forEach((cmd, i) => console.log(` ${i + 1}. ${cmd}`));
178
+ console.log(` ${steps.length + 1}. Paste the function URL + a user JWT into an MCP client.`);
179
+ }
180
+ async function cmdInit() {
181
+ const migrationSql = await readFile(join(PACKAGE_ROOT, "sql/agentify_meta.sql"), "utf8");
182
+ const migrationsDir = "supabase/migrations";
183
+ await mkdir(migrationsDir, { recursive: true });
184
+ const migrationPath = join(migrationsDir, `${timestamp()}_agentify_meta.sql`);
185
+ await writeFile(migrationPath, migrationSql);
186
+ console.log(`Wrote ${migrationPath}`);
187
+ await mkdir(FUNCTION_DIR, { recursive: true });
188
+ const functionPath = join(FUNCTION_DIR, "index.ts");
189
+ if (await exists(functionPath)) {
190
+ console.log(`Skipped ${functionPath} (already exists)`);
191
+ } else {
192
+ await writeFile(functionPath, FUNCTION_TEMPLATE);
193
+ console.log(`Wrote ${functionPath}`);
194
+ }
195
+ await mkdir(join(FUNCTION_DIR, "docs"), { recursive: true });
196
+ if (!hasSupabaseCli()) {
197
+ console.log("\nSupabase CLI not found on PATH -- install it to run the next steps automatically next time.");
198
+ printManualCommands();
199
+ return;
200
+ }
201
+ const connect = await promptYesNo(
202
+ "\nApply the migration and deploy the function to whatever Supabase project is currently linked, now?"
203
+ );
204
+ if (!connect) {
205
+ printManualCommands();
206
+ return;
207
+ }
208
+ let pushed = false;
209
+ try {
210
+ console.log("\n$ supabase db push");
211
+ execFileSync("supabase", ["db", "push"], { stdio: "inherit" });
212
+ pushed = true;
213
+ console.log("\n$ supabase functions deploy mcp");
214
+ execFileSync("supabase", ["functions", "deploy", "mcp"], { stdio: "inherit" });
215
+ console.log("\nDone. Paste the function URL + a user JWT into an MCP client to try it.");
216
+ } catch (err) {
217
+ console.error(`
218
+ Supabase CLI command failed: ${err instanceof Error ? err.message : err}`);
219
+ printManualCommands({ alreadyPushed: pushed });
220
+ }
221
+ }
222
+ async function cmdGenerate() {
223
+ const supabaseUrl = process.env.SUPABASE_URL ?? argValue("--url");
224
+ const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? argValue("--key");
225
+ if (!supabaseUrl || !serviceKey) {
226
+ console.error(
227
+ "generate needs SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY (env vars, or --url/--key flags) pointing at a running local or linked project (e.g. `supabase start`)."
228
+ );
229
+ process.exitCode = 1;
230
+ return;
231
+ }
232
+ const postgrestUrl = `${supabaseUrl.replace(/\/+$/, "")}/rest/v1`;
233
+ const headers = new Headers({ authorization: `Bearer ${serviceKey}`, apikey: serviceKey });
234
+ const [openapi, metaRows] = await Promise.all([
235
+ fetchOpenApi(postgrestUrl, headers, fetch),
236
+ fetchAgentifyMeta(postgrestUrl, headers, fetch)
237
+ ]);
238
+ const manifest = buildManifest(openapi, metaRows, (/* @__PURE__ */ new Date()).toISOString());
239
+ const outPath = join(FUNCTION_DIR, "agentify.manifest.json");
240
+ await mkdir(dirname(outPath), { recursive: true });
241
+ await writeFile(outPath, JSON.stringify(manifest, null, 2));
242
+ console.log(`Wrote ${outPath} (${manifest.tables.length} table(s))`);
243
+ }
244
+ function argValue(flag) {
245
+ const i = process.argv.indexOf(flag);
246
+ return i >= 0 ? process.argv[i + 1] : void 0;
247
+ }
248
+ function timestamp() {
249
+ const d = /* @__PURE__ */ new Date();
250
+ const pad = (n) => String(n).padStart(2, "0");
251
+ return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
252
+ }
253
+ async function main() {
254
+ const [, , cmd] = process.argv;
255
+ if (cmd === "init") return cmdInit();
256
+ if (cmd === "generate") return cmdGenerate();
257
+ console.log("Usage: agentify-supabase <init|generate>");
258
+ console.log(" init Scaffold the agentify_meta() migration + supabase/functions/mcp/index.ts,");
259
+ console.log(" then interactively offer to run `supabase db push` + `functions deploy`");
260
+ console.log(" generate Build agentify.manifest.json from a running local/linked project");
261
+ process.exitCode = cmd ? 1 : 0;
262
+ }
263
+ main().catch((err) => {
264
+ console.error(err instanceof Error ? err.message : err);
265
+ process.exitCode = 1;
266
+ });