@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 +228 -0
- package/dist/cli.js +266 -0
- package/dist/index.cjs +718 -0
- package/dist/index.d.cts +171 -0
- package/dist/index.d.ts +171 -0
- package/dist/index.js +687 -0
- package/package.json +43 -0
- package/sql/agentify_meta.sql +46 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { createWebMcpHandler } from "@exposify/core";
|
|
3
|
+
|
|
4
|
+
// src/kb.ts
|
|
5
|
+
import { KnowledgeBase } from "@exposify/core";
|
|
6
|
+
async function loadDocsFromDeno(globs, root) {
|
|
7
|
+
const deno = globalThis.Deno;
|
|
8
|
+
if (!deno) throw new Error("loadDocsFromDeno can only run under the Deno runtime.");
|
|
9
|
+
const docs = [];
|
|
10
|
+
for (const pattern of globs) {
|
|
11
|
+
const recursive = pattern.match(/^(.*)\/\*\*\/\*\.md$/);
|
|
12
|
+
if (recursive) {
|
|
13
|
+
await walkMarkdown(deno, root, recursive[1], docs);
|
|
14
|
+
} else {
|
|
15
|
+
try {
|
|
16
|
+
const content = await deno.readTextFile(new URL(pattern, root));
|
|
17
|
+
docs.push({ path: pattern, content });
|
|
18
|
+
} catch {
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return docs.sort((a, b) => a.path.localeCompare(b.path));
|
|
23
|
+
}
|
|
24
|
+
async function walkMarkdown(deno, root, dir, out) {
|
|
25
|
+
let entries;
|
|
26
|
+
try {
|
|
27
|
+
entries = deno.readDir(new URL(`${dir}/`, root));
|
|
28
|
+
} catch {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
for await (const entry of entries) {
|
|
32
|
+
const relPath = `${dir}/${entry.name}`;
|
|
33
|
+
if (entry.isDirectory) {
|
|
34
|
+
await walkMarkdown(deno, root, relPath, out);
|
|
35
|
+
} else if (entry.isFile && relPath.endsWith(".md")) {
|
|
36
|
+
try {
|
|
37
|
+
out.push({ path: relPath, content: await deno.readTextFile(new URL(relPath, root)) });
|
|
38
|
+
} catch {
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// src/config.ts
|
|
45
|
+
var WRITE_OPS = ["insert", "update", "delete"];
|
|
46
|
+
function parseWrites(writes = []) {
|
|
47
|
+
const grants = /* @__PURE__ */ new Map();
|
|
48
|
+
for (const entry of writes) {
|
|
49
|
+
const [table, op] = entry.split(".");
|
|
50
|
+
if (!table || !op) {
|
|
51
|
+
warnOnce(`ignoring malformed writes entry "${entry}" (expected "<table>.insert|update|delete|*")`);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const ops = op === "*" ? WRITE_OPS : isWriteOp(op) ? [op] : null;
|
|
55
|
+
if (!ops) {
|
|
56
|
+
warnOnce(`ignoring writes entry "${entry}": "${op}" is not insert, update, delete, or *`);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const set = grants.get(table) ?? /* @__PURE__ */ new Set();
|
|
60
|
+
for (const o of ops) set.add(o);
|
|
61
|
+
grants.set(table, set);
|
|
62
|
+
}
|
|
63
|
+
return grants;
|
|
64
|
+
}
|
|
65
|
+
function isWriteOp(op) {
|
|
66
|
+
return WRITE_OPS.includes(op);
|
|
67
|
+
}
|
|
68
|
+
var warned = /* @__PURE__ */ new Set();
|
|
69
|
+
function warnOnce(message) {
|
|
70
|
+
if (warned.has(message)) return;
|
|
71
|
+
warned.add(message);
|
|
72
|
+
try {
|
|
73
|
+
console.warn(`[agentify-supabase] ${message}`);
|
|
74
|
+
} catch {
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function resolveSupabaseConfig(options = {}) {
|
|
78
|
+
return {
|
|
79
|
+
writes: parseWrites(options.writes),
|
|
80
|
+
rpc: options.rpc ?? [],
|
|
81
|
+
functions: options.functions ?? {},
|
|
82
|
+
docs: options.docs ?? ["docs/**/*.md"],
|
|
83
|
+
docsAuth: options.docsAuth ?? "public",
|
|
84
|
+
maxRows: options.maxRows ?? 100,
|
|
85
|
+
rateLimit: options.rateLimit,
|
|
86
|
+
rateWindowMs: options.rateWindowMs
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// src/postgrest-client.ts
|
|
91
|
+
var FORWARDED_HEADERS = ["authorization", "apikey"];
|
|
92
|
+
function buildPostgrestHeaders(incoming, anonKey) {
|
|
93
|
+
const headers = new Headers();
|
|
94
|
+
for (const name of FORWARDED_HEADERS) {
|
|
95
|
+
const value = incoming.get(name);
|
|
96
|
+
if (value) headers.set(name, value);
|
|
97
|
+
}
|
|
98
|
+
if (!headers.has("apikey")) headers.set("apikey", anonKey);
|
|
99
|
+
return headers;
|
|
100
|
+
}
|
|
101
|
+
function createPostgrestClient(postgrestUrl, headers, fetchImpl) {
|
|
102
|
+
const base = postgrestUrl.replace(/\/+$/, "");
|
|
103
|
+
const call = async (path, init) => {
|
|
104
|
+
const res = await fetchImpl(`${base}${path}`, init);
|
|
105
|
+
return { status: res.status, bodyText: await res.text() };
|
|
106
|
+
};
|
|
107
|
+
const jsonHeaders = (extra) => {
|
|
108
|
+
const h = new Headers(headers);
|
|
109
|
+
h.set("content-type", "application/json");
|
|
110
|
+
for (const [k, v] of Object.entries(extra ?? {})) h.set(k, v);
|
|
111
|
+
return h;
|
|
112
|
+
};
|
|
113
|
+
return {
|
|
114
|
+
select: (table, qs) => call(`/${table}${qs ? `?${qs}` : ""}`, { method: "GET", headers }),
|
|
115
|
+
insert: (table, body) => call(`/${table}`, {
|
|
116
|
+
method: "POST",
|
|
117
|
+
headers: jsonHeaders({ prefer: "return=representation" }),
|
|
118
|
+
body: JSON.stringify(body)
|
|
119
|
+
}),
|
|
120
|
+
update: (table, qs, body) => call(`/${table}?${qs}`, {
|
|
121
|
+
method: "PATCH",
|
|
122
|
+
headers: jsonHeaders({ prefer: "return=representation" }),
|
|
123
|
+
body: JSON.stringify(body)
|
|
124
|
+
}),
|
|
125
|
+
delete: (table, qs) => call(`/${table}?${qs}`, { method: "DELETE", headers: jsonHeaders({ prefer: "return=representation" }) }),
|
|
126
|
+
rpc: (fn, args) => call(`/rpc/${fn}`, { method: "POST", headers: jsonHeaders(), body: JSON.stringify(args ?? {}) })
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// src/fetch-openapi.ts
|
|
131
|
+
async function fetchOpenApi(postgrestUrl, headers, fetchImpl) {
|
|
132
|
+
const res = await fetchImpl(postgrestUrl, {
|
|
133
|
+
headers: { ...Object.fromEntries(headers), accept: "application/openapi+json" }
|
|
134
|
+
});
|
|
135
|
+
if (!res.ok) {
|
|
136
|
+
throw new Error(`PostgREST OpenAPI fetch failed: HTTP ${res.status}`);
|
|
137
|
+
}
|
|
138
|
+
return await res.json();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// src/meta.ts
|
|
142
|
+
var MissingAgentifyMetaError = class extends Error {
|
|
143
|
+
constructor(cause) {
|
|
144
|
+
super(cause);
|
|
145
|
+
this.name = "MissingAgentifyMetaError";
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
async function fetchAgentifyMeta(postgrestUrl, headers, fetchImpl) {
|
|
149
|
+
const res = await fetchImpl(`${postgrestUrl}/rpc/agentify_meta`, {
|
|
150
|
+
method: "POST",
|
|
151
|
+
headers: { ...Object.fromEntries(headers), "content-type": "application/json" },
|
|
152
|
+
body: "{}"
|
|
153
|
+
});
|
|
154
|
+
if (res.status === 404) {
|
|
155
|
+
throw new MissingAgentifyMetaError(
|
|
156
|
+
"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."
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
if (!res.ok) {
|
|
160
|
+
const body = await res.text().catch(() => "");
|
|
161
|
+
if (/PGRST202|function .* does not exist/i.test(body)) {
|
|
162
|
+
throw new MissingAgentifyMetaError(
|
|
163
|
+
"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."
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
throw new Error(`agentify_meta() call failed: HTTP ${res.status} ${body}`.trim());
|
|
167
|
+
}
|
|
168
|
+
return await res.json();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// src/openapi.ts
|
|
172
|
+
var TYPE_MAP = {
|
|
173
|
+
integer: "integer",
|
|
174
|
+
number: "number",
|
|
175
|
+
boolean: "boolean",
|
|
176
|
+
object: "object",
|
|
177
|
+
array: "array",
|
|
178
|
+
string: "string"
|
|
179
|
+
};
|
|
180
|
+
function isPrimaryKey(prop) {
|
|
181
|
+
return /<pk\/>/i.test(prop.description ?? "");
|
|
182
|
+
}
|
|
183
|
+
function columnsFromOpenApi(doc, table) {
|
|
184
|
+
const def = doc.definitions?.[table];
|
|
185
|
+
if (!def?.properties) return [];
|
|
186
|
+
const required = new Set(def.required ?? []);
|
|
187
|
+
return Object.entries(def.properties).map(([name, prop]) => ({
|
|
188
|
+
name,
|
|
189
|
+
type: TYPE_MAP[prop.type ?? "string"] ?? "string",
|
|
190
|
+
format: prop.format,
|
|
191
|
+
required: required.has(name),
|
|
192
|
+
isPrimaryKey: isPrimaryKey(prop),
|
|
193
|
+
// Falls back to the OpenAPI-embedded comment (PostgREST mirrors `COMMENT
|
|
194
|
+
// ON COLUMN` into `description`, then appends its own "Note:\nThis is a
|
|
195
|
+
// Primary/Foreign Key...<pk/|fk/>" boilerplate) -- strip that boilerplate
|
|
196
|
+
// so a column with no real comment ends up `undefined`, not the
|
|
197
|
+
// boilerplate text. agentify_meta()'s copy wins when both exist anyway
|
|
198
|
+
// -- see introspect.ts's merge.
|
|
199
|
+
description: prop.description?.replace(/\n*Note:[\s\S]*$/, "").trim() || void 0
|
|
200
|
+
}));
|
|
201
|
+
}
|
|
202
|
+
function tablesInOpenApi(doc) {
|
|
203
|
+
return new Set(Object.keys(doc.definitions ?? {}));
|
|
204
|
+
}
|
|
205
|
+
function rpcArgSchema(doc, fn) {
|
|
206
|
+
const params = doc.paths?.[`/rpc/${fn}`]?.post?.parameters;
|
|
207
|
+
const body = params?.find((p) => p.in === "body" && p.schema?.type === "object");
|
|
208
|
+
if (!body?.schema?.properties) return null;
|
|
209
|
+
const properties = {};
|
|
210
|
+
for (const [name, prop] of Object.entries(body.schema.properties)) {
|
|
211
|
+
properties[name] = { type: TYPE_MAP[prop.type ?? "string"] ?? "string", description: prop.description };
|
|
212
|
+
}
|
|
213
|
+
return { type: "object", properties, required: body.schema.required };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// src/introspect.ts
|
|
217
|
+
function introspectTables(openapi, metaRows) {
|
|
218
|
+
const exposedInOpenApi = tablesInOpenApi(openapi);
|
|
219
|
+
const byTable = groupByTable(metaRows);
|
|
220
|
+
const tables = [];
|
|
221
|
+
for (const [name, rows] of byTable) {
|
|
222
|
+
const rlsEnabled = rows[0].rls_enabled;
|
|
223
|
+
if (!rlsEnabled) continue;
|
|
224
|
+
if (!exposedInOpenApi.has(name)) continue;
|
|
225
|
+
const openApiColumns = new Map(columnsFromOpenApi(openapi, name).map((c) => [c.name, c]));
|
|
226
|
+
const columns = [];
|
|
227
|
+
for (const row of rows) {
|
|
228
|
+
const fromOpenApi = openApiColumns.get(row.column_name);
|
|
229
|
+
if (!fromOpenApi) continue;
|
|
230
|
+
columns.push({
|
|
231
|
+
...fromOpenApi,
|
|
232
|
+
// agentify_meta()'s comment wins over OpenAPI's mirrored copy (spec:
|
|
233
|
+
// "Column comments → param descriptions" sourced from the migration).
|
|
234
|
+
description: row.column_comment ?? fromOpenApi.description
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
if (columns.length === 0) continue;
|
|
238
|
+
tables.push({
|
|
239
|
+
name,
|
|
240
|
+
rlsEnabled: true,
|
|
241
|
+
description: rows[0].table_comment ?? void 0,
|
|
242
|
+
columns
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
return tables.sort((a, b) => a.name.localeCompare(b.name));
|
|
246
|
+
}
|
|
247
|
+
function groupByTable(rows) {
|
|
248
|
+
const map = /* @__PURE__ */ new Map();
|
|
249
|
+
for (const row of rows) {
|
|
250
|
+
const list = map.get(row.table_name) ?? [];
|
|
251
|
+
list.push(row);
|
|
252
|
+
map.set(row.table_name, list);
|
|
253
|
+
}
|
|
254
|
+
return map;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// src/manifest.ts
|
|
258
|
+
var MANIFEST_VERSION = 1;
|
|
259
|
+
function buildManifest(openapi, metaRows, generatedAt) {
|
|
260
|
+
return { version: MANIFEST_VERSION, generatedAt, tables: introspectTables(openapi, metaRows) };
|
|
261
|
+
}
|
|
262
|
+
function loadManifest(raw) {
|
|
263
|
+
if (!raw || typeof raw !== "object") return null;
|
|
264
|
+
const m = raw;
|
|
265
|
+
if (m.version !== MANIFEST_VERSION || !Array.isArray(m.tables)) return null;
|
|
266
|
+
return { version: MANIFEST_VERSION, generatedAt: m.generatedAt ?? "unknown", tables: m.tables };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// src/degradation.ts
|
|
270
|
+
async function resolveTableSchemas(deps) {
|
|
271
|
+
const warnings = [];
|
|
272
|
+
try {
|
|
273
|
+
const [openapi, metaRows] = await Promise.all([
|
|
274
|
+
fetchOpenApi(deps.postgrestUrl, deps.headers, deps.fetchImpl),
|
|
275
|
+
fetchAgentifyMeta(deps.postgrestUrl, deps.headers, deps.fetchImpl)
|
|
276
|
+
]);
|
|
277
|
+
return { tables: introspectTables(openapi, metaRows), mode: "runtime", warnings, openapi };
|
|
278
|
+
} catch (err) {
|
|
279
|
+
warnings.push({ rung: "runtime", reason: messageOf(err) });
|
|
280
|
+
}
|
|
281
|
+
const manifest = loadManifest(deps.manifest);
|
|
282
|
+
if (manifest) {
|
|
283
|
+
warnings.push({
|
|
284
|
+
rung: "manifest",
|
|
285
|
+
reason: "runtime introspection failed; serving tools from the bundled agentify.manifest.json instead"
|
|
286
|
+
});
|
|
287
|
+
return { tables: manifest.tables, mode: "manifest", warnings };
|
|
288
|
+
}
|
|
289
|
+
warnings.push({
|
|
290
|
+
rung: "functions-only",
|
|
291
|
+
reason: "runtime introspection failed and no bundled manifest was found; exposing only registered functions and the knowledge base"
|
|
292
|
+
});
|
|
293
|
+
return { tables: [], mode: "functions-only", warnings };
|
|
294
|
+
}
|
|
295
|
+
function messageOf(err) {
|
|
296
|
+
return err instanceof Error ? err.message : String(err);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// src/server.ts
|
|
300
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
301
|
+
import { z as z2 } from "zod";
|
|
302
|
+
import {
|
|
303
|
+
toToolResult,
|
|
304
|
+
DISPATCH_TIMEOUT_MS
|
|
305
|
+
} from "@exposify/core";
|
|
306
|
+
|
|
307
|
+
// src/postgrest-query.ts
|
|
308
|
+
function buildQueryString(args, maxRows) {
|
|
309
|
+
const params = new URLSearchParams();
|
|
310
|
+
for (const f of args.filter ?? []) {
|
|
311
|
+
params.append(f.column, `${f.op}.${f.value}`);
|
|
312
|
+
}
|
|
313
|
+
if (args.select?.length) params.set("select", args.select.join(","));
|
|
314
|
+
if (args.order) params.set("order", args.order);
|
|
315
|
+
const limit = args.limit ? Math.min(args.limit, maxRows) : maxRows;
|
|
316
|
+
params.set("limit", String(limit));
|
|
317
|
+
return params.toString();
|
|
318
|
+
}
|
|
319
|
+
function requireFilter(filter) {
|
|
320
|
+
if (!filter || filter.length === 0) {
|
|
321
|
+
throw new Error("At least one filter is required -- refusing a table-wide update/delete.");
|
|
322
|
+
}
|
|
323
|
+
return filter;
|
|
324
|
+
}
|
|
325
|
+
function filterQueryString(filter) {
|
|
326
|
+
const params = new URLSearchParams();
|
|
327
|
+
for (const f of filter) params.append(f.column, `${f.op}.${f.value}`);
|
|
328
|
+
return params.toString();
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// src/naming.ts
|
|
332
|
+
function sanitizeIdentifier(name) {
|
|
333
|
+
return name.toLowerCase().replace(/[^a-z0-9_]/g, "_").replace(/_+/g, "_").replace(/^_|_$/g, "");
|
|
334
|
+
}
|
|
335
|
+
function toolName(prefix, name, taken) {
|
|
336
|
+
const base = `${prefix}_${sanitizeIdentifier(name)}`;
|
|
337
|
+
let candidate = base;
|
|
338
|
+
let i = 2;
|
|
339
|
+
while (taken.has(candidate)) candidate = `${base}_${i++}`;
|
|
340
|
+
taken.add(candidate);
|
|
341
|
+
return candidate;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// src/jsonschema-to-zod.ts
|
|
345
|
+
import { z } from "zod";
|
|
346
|
+
function jsonSchemaToZodShape(schema) {
|
|
347
|
+
if (!schema?.properties) return {};
|
|
348
|
+
const required = new Set(schema.required ?? []);
|
|
349
|
+
const shape = {};
|
|
350
|
+
for (const [name, prop] of Object.entries(schema.properties)) {
|
|
351
|
+
let field = propertyToZod(prop);
|
|
352
|
+
if (!required.has(name)) field = field.optional();
|
|
353
|
+
shape[name] = field;
|
|
354
|
+
}
|
|
355
|
+
return shape;
|
|
356
|
+
}
|
|
357
|
+
function propertyToZod(prop) {
|
|
358
|
+
let base;
|
|
359
|
+
switch (prop.type) {
|
|
360
|
+
case "string":
|
|
361
|
+
base = prop.enum ? z.enum(prop.enum) : z.string();
|
|
362
|
+
break;
|
|
363
|
+
case "number":
|
|
364
|
+
base = z.number();
|
|
365
|
+
break;
|
|
366
|
+
case "integer":
|
|
367
|
+
base = z.number().int();
|
|
368
|
+
break;
|
|
369
|
+
case "boolean":
|
|
370
|
+
base = z.boolean();
|
|
371
|
+
break;
|
|
372
|
+
case "array":
|
|
373
|
+
base = z.array(prop.items ? propertyToZod(prop.items) : z.unknown());
|
|
374
|
+
break;
|
|
375
|
+
case "object":
|
|
376
|
+
base = prop.properties ? z.object(jsonSchemaToZodShape({ properties: prop.properties, required: prop.required })) : z.record(z.unknown());
|
|
377
|
+
break;
|
|
378
|
+
default:
|
|
379
|
+
base = z.unknown();
|
|
380
|
+
}
|
|
381
|
+
return prop.description ? base.describe(prop.description) : base;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// src/server.ts
|
|
385
|
+
var FILTER_OPS = ["eq", "neq", "gt", "lt", "like"];
|
|
386
|
+
function buildSupabaseMcpServer(deps) {
|
|
387
|
+
const { cfg, tables, client, kb, incoming } = deps;
|
|
388
|
+
const server = new McpServer(
|
|
389
|
+
{ name: deps.appName, version: deps.appVersion },
|
|
390
|
+
{ instructions: instructionsFor(deps.warnings) }
|
|
391
|
+
);
|
|
392
|
+
const taken = /* @__PURE__ */ new Set(["search_docs"]);
|
|
393
|
+
for (const table of tables) {
|
|
394
|
+
registerQueryTool(server, table, client, cfg, taken);
|
|
395
|
+
const grants = cfg.writes.get(table.name);
|
|
396
|
+
if (grants?.has("insert")) registerInsertTool(server, table, client, taken);
|
|
397
|
+
if (grants?.has("update")) registerUpdateTool(server, table, client, taken);
|
|
398
|
+
if (grants?.has("delete")) registerDeleteTool(server, table, client, taken);
|
|
399
|
+
}
|
|
400
|
+
for (const fn of cfg.rpc) {
|
|
401
|
+
registerRpcTool(server, fn, client, deps.openapi, taken);
|
|
402
|
+
}
|
|
403
|
+
for (const [name, config] of Object.entries(cfg.functions)) {
|
|
404
|
+
registerFunctionTool(server, name, config, deps.invokeFunction, taken);
|
|
405
|
+
}
|
|
406
|
+
registerDocsTools(server, kb, cfg, incoming);
|
|
407
|
+
registerDiagnosticsResource(server, deps.warnings);
|
|
408
|
+
return server;
|
|
409
|
+
}
|
|
410
|
+
function instructionsFor(warnings) {
|
|
411
|
+
if (warnings.length === 0) return void 0;
|
|
412
|
+
return warnings.map((w) => `[${w.rung}] ${w.reason}`).join(" ");
|
|
413
|
+
}
|
|
414
|
+
function filterShape(columns) {
|
|
415
|
+
const names = columns.map((c) => c.name);
|
|
416
|
+
if (names.length === 0) return z2.array(z2.never()).optional();
|
|
417
|
+
return z2.array(
|
|
418
|
+
z2.object({
|
|
419
|
+
column: z2.enum(names).describe("Column to filter on"),
|
|
420
|
+
op: z2.enum(FILTER_OPS).describe("eq | neq | gt | lt | like"),
|
|
421
|
+
value: z2.union([z2.string(), z2.number(), z2.boolean()])
|
|
422
|
+
})
|
|
423
|
+
).optional().describe("Filters, ANDed together");
|
|
424
|
+
}
|
|
425
|
+
function registerQueryTool(server, table, client, cfg, taken) {
|
|
426
|
+
const name = toolName("query", table.name, taken);
|
|
427
|
+
const columnNames = table.columns.map((c) => c.name);
|
|
428
|
+
server.registerTool(
|
|
429
|
+
name,
|
|
430
|
+
{
|
|
431
|
+
description: table.description || `Query rows from ${table.name}.`,
|
|
432
|
+
inputSchema: {
|
|
433
|
+
filter: filterShape(table.columns),
|
|
434
|
+
select: columnNames.length > 0 ? z2.array(z2.enum(columnNames)).optional().describe("Columns to return; default all") : z2.array(z2.string()).optional(),
|
|
435
|
+
order: z2.string().optional().describe('e.g. "created_at.desc"'),
|
|
436
|
+
limit: z2.number().int().min(1).max(cfg.maxRows).optional().describe(`Max rows (capped at ${cfg.maxRows})`)
|
|
437
|
+
},
|
|
438
|
+
annotations: { readOnlyHint: true }
|
|
439
|
+
},
|
|
440
|
+
async (args) => {
|
|
441
|
+
const qs = buildQueryString(args, cfg.maxRows);
|
|
442
|
+
return toResult(() => client.select(table.name, qs));
|
|
443
|
+
}
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
function insertBodyShape(columns) {
|
|
447
|
+
const shape = {};
|
|
448
|
+
for (const col of columns) {
|
|
449
|
+
let field = columnToZod(col);
|
|
450
|
+
if (!col.required) field = field.optional();
|
|
451
|
+
shape[col.name] = field;
|
|
452
|
+
}
|
|
453
|
+
return shape;
|
|
454
|
+
}
|
|
455
|
+
function columnToZod(col) {
|
|
456
|
+
let base;
|
|
457
|
+
switch (col.type) {
|
|
458
|
+
case "number":
|
|
459
|
+
base = z2.number();
|
|
460
|
+
break;
|
|
461
|
+
case "integer":
|
|
462
|
+
base = z2.number().int();
|
|
463
|
+
break;
|
|
464
|
+
case "boolean":
|
|
465
|
+
base = z2.boolean();
|
|
466
|
+
break;
|
|
467
|
+
case "object":
|
|
468
|
+
base = z2.record(z2.unknown());
|
|
469
|
+
break;
|
|
470
|
+
case "array":
|
|
471
|
+
base = z2.array(z2.unknown());
|
|
472
|
+
break;
|
|
473
|
+
default:
|
|
474
|
+
base = z2.string();
|
|
475
|
+
}
|
|
476
|
+
return col.description ? base.describe(col.description) : base;
|
|
477
|
+
}
|
|
478
|
+
function registerInsertTool(server, table, client, taken) {
|
|
479
|
+
const name = toolName("insert", table.name, taken);
|
|
480
|
+
server.registerTool(
|
|
481
|
+
name,
|
|
482
|
+
{
|
|
483
|
+
description: `Insert a row into ${table.name}.`,
|
|
484
|
+
inputSchema: { body: z2.object(insertBodyShape(table.columns)) },
|
|
485
|
+
annotations: { readOnlyHint: false, destructiveHint: false }
|
|
486
|
+
},
|
|
487
|
+
async (args) => toResult(() => client.insert(table.name, args.body))
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
function registerUpdateTool(server, table, client, taken) {
|
|
491
|
+
const name = toolName("update", table.name, taken);
|
|
492
|
+
const setShape = {};
|
|
493
|
+
for (const col of table.columns) setShape[col.name] = columnToZod(col).optional();
|
|
494
|
+
server.registerTool(
|
|
495
|
+
name,
|
|
496
|
+
{
|
|
497
|
+
description: `Update rows in ${table.name}. At least one filter is required.`,
|
|
498
|
+
inputSchema: {
|
|
499
|
+
filter: filterShape(table.columns).refine((f) => !!f && f.length > 0, {
|
|
500
|
+
message: "At least one filter is required -- refusing a table-wide update."
|
|
501
|
+
}),
|
|
502
|
+
set: z2.object(setShape)
|
|
503
|
+
},
|
|
504
|
+
annotations: { readOnlyHint: false, destructiveHint: true }
|
|
505
|
+
},
|
|
506
|
+
async (args) => toResult(() => client.update(table.name, filterQueryString(requireFilter(args.filter)), args.set))
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
function registerDeleteTool(server, table, client, taken) {
|
|
510
|
+
const name = toolName("delete", table.name, taken);
|
|
511
|
+
server.registerTool(
|
|
512
|
+
name,
|
|
513
|
+
{
|
|
514
|
+
description: `Delete rows from ${table.name}. At least one filter is required.`,
|
|
515
|
+
inputSchema: {
|
|
516
|
+
filter: filterShape(table.columns).refine((f) => !!f && f.length > 0, {
|
|
517
|
+
message: "At least one filter is required -- refusing a table-wide delete."
|
|
518
|
+
})
|
|
519
|
+
},
|
|
520
|
+
annotations: { readOnlyHint: false, destructiveHint: true }
|
|
521
|
+
},
|
|
522
|
+
async (args) => toResult(() => client.delete(table.name, filterQueryString(requireFilter(args.filter))))
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
function registerRpcTool(server, fn, client, openapi, taken) {
|
|
526
|
+
const name = toolName("rpc", fn, taken);
|
|
527
|
+
const schema = openapi ? rpcArgSchema(openapi, fn) : null;
|
|
528
|
+
if (schema) {
|
|
529
|
+
const shape = jsonSchemaToZodShape(schema);
|
|
530
|
+
server.registerTool(
|
|
531
|
+
name,
|
|
532
|
+
{ description: `Call the ${fn} Postgres function.`, inputSchema: shape },
|
|
533
|
+
async (args) => toResult(() => client.rpc(fn, args))
|
|
534
|
+
);
|
|
535
|
+
} else {
|
|
536
|
+
server.registerTool(
|
|
537
|
+
name,
|
|
538
|
+
{
|
|
539
|
+
description: `Call the ${fn} Postgres function. Argument names were not available from PostgREST's OpenAPI document -- pass them as a JSON object.`,
|
|
540
|
+
inputSchema: { args: z2.record(z2.unknown()).optional().describe("RPC arguments as a JSON object") }
|
|
541
|
+
},
|
|
542
|
+
async (args) => toResult(() => client.rpc(fn, args.args ?? {}))
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
function registerFunctionTool(server, name, config, invokeFunction, taken) {
|
|
547
|
+
const toolNameStr = toolName("fn", name, taken);
|
|
548
|
+
server.registerTool(
|
|
549
|
+
toolNameStr,
|
|
550
|
+
{ description: config.description, inputSchema: jsonSchemaToZodShape(config.input) },
|
|
551
|
+
async (args) => toResult(() => invokeFunction(name, args))
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
function registerDocsTools(server, kb, cfg, incoming) {
|
|
555
|
+
const docsAllowed = cfg.docsAuth === "public" || incoming.has("authorization");
|
|
556
|
+
if (!docsAllowed || kb.isEmpty) return;
|
|
557
|
+
server.registerTool(
|
|
558
|
+
"search_docs",
|
|
559
|
+
{
|
|
560
|
+
description: "Search this app's documentation and product knowledge.",
|
|
561
|
+
inputSchema: { query: z2.string().describe("Search query"), limit: z2.number().int().min(1).max(20).optional() },
|
|
562
|
+
annotations: { readOnlyHint: true }
|
|
563
|
+
},
|
|
564
|
+
async ({ query, limit }) => {
|
|
565
|
+
const hits = kb.search(query, limit ?? 5);
|
|
566
|
+
const text = hits.length ? hits.map((h) => `[${h.chunk.source} \u2014 ${h.chunk.heading}]
|
|
567
|
+
${h.chunk.text}`).join("\n\n---\n\n") : "No matching documentation found.";
|
|
568
|
+
return { content: [{ type: "text", text }] };
|
|
569
|
+
}
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
function registerDiagnosticsResource(server, warnings) {
|
|
573
|
+
if (warnings.length === 0) return;
|
|
574
|
+
server.registerResource(
|
|
575
|
+
"agentify-diagnostics",
|
|
576
|
+
"agentify://diagnostics",
|
|
577
|
+
{ title: "agentify-supabase diagnostics", mimeType: "application/json" },
|
|
578
|
+
async (uri) => ({ contents: [{ uri: uri.href, text: JSON.stringify(warnings, null, 2) }] })
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
async function toResult(run) {
|
|
582
|
+
let timer;
|
|
583
|
+
try {
|
|
584
|
+
const outcome = await Promise.race([
|
|
585
|
+
run(),
|
|
586
|
+
new Promise((_, reject) => {
|
|
587
|
+
timer = setTimeout(() => reject(new Error("timeout")), DISPATCH_TIMEOUT_MS);
|
|
588
|
+
})
|
|
589
|
+
]);
|
|
590
|
+
const result = toToolResult(outcome);
|
|
591
|
+
return { content: [{ type: "text", text: result.text }], isError: result.isError };
|
|
592
|
+
} catch (err) {
|
|
593
|
+
const message = err.message === "timeout" ? `Tool call timed out after ${DISPATCH_TIMEOUT_MS / 1e3}s` : "Tool call failed reaching Supabase.";
|
|
594
|
+
return { content: [{ type: "text", text: message }], isError: true };
|
|
595
|
+
} finally {
|
|
596
|
+
clearTimeout(timer);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// src/index.ts
|
|
601
|
+
function denoEnv() {
|
|
602
|
+
return globalThis.Deno?.env;
|
|
603
|
+
}
|
|
604
|
+
function aiReady(options = {}) {
|
|
605
|
+
const cfg = resolveSupabaseConfig(options);
|
|
606
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
607
|
+
const env = denoEnv();
|
|
608
|
+
const supabaseUrl = options.supabaseUrl ?? env?.get("SUPABASE_URL");
|
|
609
|
+
const anonKey = options.anonKey ?? env?.get("SUPABASE_ANON_KEY") ?? "";
|
|
610
|
+
let kbPromise = null;
|
|
611
|
+
const getKb = () => {
|
|
612
|
+
kbPromise ??= (async () => {
|
|
613
|
+
const mainModule = globalThis.Deno?.mainModule;
|
|
614
|
+
if (!mainModule) return KnowledgeBase.build([]);
|
|
615
|
+
try {
|
|
616
|
+
return KnowledgeBase.build(await loadDocsFromDeno(cfg.docs, new URL(mainModule)));
|
|
617
|
+
} catch {
|
|
618
|
+
return KnowledgeBase.build([]);
|
|
619
|
+
}
|
|
620
|
+
})();
|
|
621
|
+
return kbPromise;
|
|
622
|
+
};
|
|
623
|
+
let ladderPromise = null;
|
|
624
|
+
const getLadder = (postgrestUrl) => {
|
|
625
|
+
ladderPromise ??= resolveTableSchemas({
|
|
626
|
+
postgrestUrl,
|
|
627
|
+
headers: buildPostgrestHeaders(new Headers(), anonKey),
|
|
628
|
+
fetchImpl,
|
|
629
|
+
manifest: options.manifest
|
|
630
|
+
}).then((ladder) => {
|
|
631
|
+
for (const w of ladder.warnings) {
|
|
632
|
+
console.error(`[agentify-supabase] degradation ladder: ${w.rung}: ${w.reason}`);
|
|
633
|
+
}
|
|
634
|
+
return ladder;
|
|
635
|
+
});
|
|
636
|
+
return ladderPromise;
|
|
637
|
+
};
|
|
638
|
+
return createWebMcpHandler(
|
|
639
|
+
{
|
|
640
|
+
getServer: async (req) => {
|
|
641
|
+
if (!supabaseUrl) {
|
|
642
|
+
throw new Error(
|
|
643
|
+
"SUPABASE_URL is not set. In a deployed function this is automatic; for local dev pass { supabaseUrl } explicitly."
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
const postgrestUrl = `${supabaseUrl.replace(/\/+$/, "")}/rest/v1`;
|
|
647
|
+
const incoming = req.headers;
|
|
648
|
+
const headers = buildPostgrestHeaders(incoming, anonKey);
|
|
649
|
+
const ladder = await getLadder(postgrestUrl);
|
|
650
|
+
const client = createPostgrestClient(postgrestUrl, headers, fetchImpl);
|
|
651
|
+
const invokeFunction = async (name, body) => {
|
|
652
|
+
const res = await fetchImpl(`${supabaseUrl.replace(/\/+$/, "")}/functions/v1/${name}`, {
|
|
653
|
+
method: "POST",
|
|
654
|
+
headers: { ...Object.fromEntries(headers), "content-type": "application/json" },
|
|
655
|
+
body: JSON.stringify(body ?? {})
|
|
656
|
+
});
|
|
657
|
+
return { status: res.status, bodyText: await res.text() };
|
|
658
|
+
};
|
|
659
|
+
return buildSupabaseMcpServer({
|
|
660
|
+
appName: "agentify-supabase",
|
|
661
|
+
appVersion: "0.0.0",
|
|
662
|
+
tables: ladder.tables,
|
|
663
|
+
cfg,
|
|
664
|
+
client,
|
|
665
|
+
openapi: ladder.openapi,
|
|
666
|
+
kb: await getKb(),
|
|
667
|
+
incoming,
|
|
668
|
+
invokeFunction,
|
|
669
|
+
warnings: ladder.warnings
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
},
|
|
673
|
+
{ rateLimit: cfg.rateLimit, rateWindowMs: cfg.rateWindowMs }
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
export {
|
|
677
|
+
MANIFEST_VERSION,
|
|
678
|
+
MissingAgentifyMetaError,
|
|
679
|
+
aiReady,
|
|
680
|
+
buildManifest,
|
|
681
|
+
columnsFromOpenApi,
|
|
682
|
+
fetchAgentifyMeta,
|
|
683
|
+
fetchOpenApi,
|
|
684
|
+
introspectTables,
|
|
685
|
+
loadManifest,
|
|
686
|
+
rpcArgSchema
|
|
687
|
+
};
|