@exposify/supabase 0.3.7 → 0.3.8
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 +240 -23
- package/dist/cli.js +1146 -264
- package/dist/index.cjs +148 -8
- package/dist/index.d.cts +51 -2
- package/dist/index.d.ts +51 -2
- package/dist/index.js +145 -6
- package/package.json +3 -2
package/dist/index.cjs
CHANGED
|
@@ -29,10 +29,11 @@ __export(index_exports, {
|
|
|
29
29
|
fetchOpenApi: () => fetchOpenApi,
|
|
30
30
|
introspectTables: () => introspectTables,
|
|
31
31
|
loadManifest: () => loadManifest,
|
|
32
|
-
rpcArgSchema: () => rpcArgSchema
|
|
32
|
+
rpcArgSchema: () => rpcArgSchema,
|
|
33
|
+
supabaseAuth: () => supabaseAuth
|
|
33
34
|
});
|
|
34
35
|
module.exports = __toCommonJS(index_exports);
|
|
35
|
-
var
|
|
36
|
+
var import_core5 = require("@exposify/core");
|
|
36
37
|
|
|
37
38
|
// src/kb.ts
|
|
38
39
|
var import_core = require("@exposify/core");
|
|
@@ -108,12 +109,20 @@ function warnOnce(message) {
|
|
|
108
109
|
}
|
|
109
110
|
}
|
|
110
111
|
function resolveSupabaseConfig(options = {}) {
|
|
112
|
+
if (options.oauth) {
|
|
113
|
+
if (options.docsAuth === "public") {
|
|
114
|
+
throw new Error('docsAuth: "public" cannot be combined with oauth (the whole MCP endpoint requires a token)');
|
|
115
|
+
}
|
|
116
|
+
if (options.auth || options.authRequired) {
|
|
117
|
+
throw new Error("oauth cannot be combined with auth/authRequired (oauth already requires a valid token on every request)");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
111
120
|
return {
|
|
112
121
|
writes: parseWrites(options.writes),
|
|
113
122
|
rpc: options.rpc ?? [],
|
|
114
123
|
functions: options.functions ?? {},
|
|
115
124
|
docs: options.docs ?? ["docs/**/*.md"],
|
|
116
|
-
docsAuth: options.docsAuth ?? "public",
|
|
125
|
+
docsAuth: options.docsAuth ?? (options.oauth ? "required" : "public"),
|
|
117
126
|
maxRows: options.maxRows ?? 100,
|
|
118
127
|
rateLimit: options.rateLimit,
|
|
119
128
|
rateWindowMs: options.rateWindowMs
|
|
@@ -635,6 +644,96 @@ async function toResult(run) {
|
|
|
635
644
|
}
|
|
636
645
|
}
|
|
637
646
|
|
|
647
|
+
// src/oauth.ts
|
|
648
|
+
var import_core3 = require("@exposify/core");
|
|
649
|
+
var PRM_SUFFIX = "/.well-known/oauth-protected-resource";
|
|
650
|
+
function supabaseAuth(overrides = {}) {
|
|
651
|
+
return (supabaseUrl) => {
|
|
652
|
+
const base = supabaseUrl.replace(/\/+$/, "");
|
|
653
|
+
return {
|
|
654
|
+
issuer: `${base}/auth/v1`,
|
|
655
|
+
jwksUri: `${base}/auth/v1/.well-known/jwks.json`,
|
|
656
|
+
audience: "authenticated",
|
|
657
|
+
...overrides
|
|
658
|
+
};
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
function functionNameFromMainModule() {
|
|
662
|
+
const mainModule = globalThis.Deno?.mainModule;
|
|
663
|
+
if (!mainModule) return void 0;
|
|
664
|
+
try {
|
|
665
|
+
const parts = new URL(mainModule).pathname.split("/").filter(Boolean);
|
|
666
|
+
return parts.length >= 2 ? parts[parts.length - 2] : void 0;
|
|
667
|
+
} catch {
|
|
668
|
+
return void 0;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
function createSupabaseOAuthGate(oauth, supabaseUrl, fetchImpl) {
|
|
672
|
+
if (typeof oauth === "function" && !supabaseUrl) {
|
|
673
|
+
throw new Error("oauth: SUPABASE_URL is not set. In a deployed function this is automatic; for local dev pass { supabaseUrl }.");
|
|
674
|
+
}
|
|
675
|
+
const opts = typeof oauth === "function" ? oauth(supabaseUrl) : oauth;
|
|
676
|
+
let resource = opts.resource;
|
|
677
|
+
if (!resource) {
|
|
678
|
+
const name = functionNameFromMainModule();
|
|
679
|
+
if (!supabaseUrl || !name) {
|
|
680
|
+
throw new Error("oauth.resource could not be derived (needs SUPABASE_URL and Deno.mainModule); pass oauth.resource explicitly.");
|
|
681
|
+
}
|
|
682
|
+
resource = `${supabaseUrl.replace(/\/+$/, "")}/functions/v1/${name}`;
|
|
683
|
+
}
|
|
684
|
+
const { metadataUrl, ...oauthOptions } = opts;
|
|
685
|
+
const cfg = (0, import_core3.resolveOAuthOptions)({ ...oauthOptions, resource });
|
|
686
|
+
if (metadataUrl !== void 0) {
|
|
687
|
+
try {
|
|
688
|
+
new URL(metadataUrl);
|
|
689
|
+
} catch {
|
|
690
|
+
throw new Error(`oauth.metadataUrl must be an absolute URL, got ${JSON.stringify(metadataUrl)}`);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
return (0, import_core3.createOAuthGate)(cfg, { metadataUrl: metadataUrl ?? `${cfg.resource}${PRM_SUFFIX}`, fetchImpl });
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// src/startup.ts
|
|
697
|
+
var import_core4 = require("@exposify/core");
|
|
698
|
+
var LOG_PREFIX = "[exposify-supabase]";
|
|
699
|
+
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-supabase generate`";
|
|
700
|
+
function supabaseToolsPhrase(ladder, cfg, hasDocs) {
|
|
701
|
+
const query = ladder.tables.length;
|
|
702
|
+
let write = 0;
|
|
703
|
+
for (const table of ladder.tables) write += cfg.writes.get(table.name)?.size ?? 0;
|
|
704
|
+
const rpc = cfg.rpc.length;
|
|
705
|
+
const fn = Object.keys(cfg.functions).length;
|
|
706
|
+
const parts = [`${query} query`];
|
|
707
|
+
if (write) parts.push(`${write} write`);
|
|
708
|
+
if (rpc) parts.push(`${rpc} rpc`);
|
|
709
|
+
if (fn) parts.push(`${fn} function${fn === 1 ? "" : "s"}`);
|
|
710
|
+
if (hasDocs) parts.push("search_docs");
|
|
711
|
+
const total = query + write + rpc + fn + (hasDocs ? 1 : 0);
|
|
712
|
+
return `${total} tool${total === 1 ? "" : "s"} (${parts.join(", ")})`;
|
|
713
|
+
}
|
|
714
|
+
function logColdStart(opts) {
|
|
715
|
+
const { ladder, cfg, docs, endpoint, auth, logReady } = opts;
|
|
716
|
+
if (ladder.mode === "functions-only") {
|
|
717
|
+
console.error(
|
|
718
|
+
(0, import_core4.formatFailed)(
|
|
719
|
+
LOG_PREFIX,
|
|
720
|
+
"couldn't read the database schema and no bundled manifest exists, so only registered functions and docs are exposed",
|
|
721
|
+
INTROSPECTION_FIX
|
|
722
|
+
)
|
|
723
|
+
);
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
if (logReady) {
|
|
727
|
+
const details = ladder.mode === "manifest" ? ["from the bundled manifest"] : [];
|
|
728
|
+
console.log((0, import_core4.formatReady)(LOG_PREFIX, { endpoint, tools: supabaseToolsPhrase(ladder, cfg, docs > 0), details, docs, auth }));
|
|
729
|
+
}
|
|
730
|
+
if (ladder.mode === "manifest") {
|
|
731
|
+
console.warn(
|
|
732
|
+
(0, import_core4.formatWarning)(LOG_PREFIX, "live schema introspection failed; tools come from the bundled manifest and may be out of date", INTROSPECTION_FIX)
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
638
737
|
// src/index.ts
|
|
639
738
|
function denoEnv() {
|
|
640
739
|
return globalThis.Deno?.env;
|
|
@@ -646,13 +745,38 @@ function aiReady(options = {}) {
|
|
|
646
745
|
const supabaseUrl = options.supabaseUrl ?? env?.get("SUPABASE_URL");
|
|
647
746
|
const anonKey = options.anonKey ?? env?.get("SUPABASE_ANON_KEY") ?? "";
|
|
648
747
|
const serviceRoleKey = options.serviceRoleKey ?? env?.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
|
|
748
|
+
const gate = options.oauth ? createSupabaseOAuthGate(options.oauth, supabaseUrl, fetchImpl) : null;
|
|
749
|
+
const logReady = (0, import_core5.startupLogEnabled)(options.startupLog);
|
|
750
|
+
const functionName = functionNameFromMainModule();
|
|
751
|
+
const endpoint = gate?.metadata.resource ?? (supabaseUrl && functionName ? `${supabaseUrl.replace(/\/+$/, "")}/functions/v1/${functionName}` : "this function");
|
|
752
|
+
const authLabel = gate ? `oauth (${String(gate.metadata.authorization_servers[0])})` : options.auth ? "custom resolver" : "passthrough (caller's JWT)";
|
|
753
|
+
if (!supabaseUrl) {
|
|
754
|
+
console.error(
|
|
755
|
+
(0, import_core5.formatFailed)(LOG_PREFIX, "SUPABASE_URL is not set", "deployed functions get it automatically; for local dev pass { supabaseUrl }")
|
|
756
|
+
);
|
|
757
|
+
} else if (gate && logReady) {
|
|
758
|
+
void gate.probe().then((problem) => {
|
|
759
|
+
if (!problem) return;
|
|
760
|
+
console.warn(
|
|
761
|
+
(0, import_core5.formatWarning)(
|
|
762
|
+
LOG_PREFIX,
|
|
763
|
+
`can't verify access tokens from oauth.issuer ${String(gate.metadata.authorization_servers[0])}: ${problem}`,
|
|
764
|
+
"check the issuer (supabaseAuth() needs the project's OAuth server enabled and asymmetric JWT signing keys); until then MCP clients get 503"
|
|
765
|
+
)
|
|
766
|
+
);
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
let coldStartLogged = false;
|
|
649
770
|
let kbPromise = null;
|
|
771
|
+
let docCount = 0;
|
|
650
772
|
const getKb = () => {
|
|
651
773
|
kbPromise ??= (async () => {
|
|
652
774
|
const mainModule = globalThis.Deno?.mainModule;
|
|
653
775
|
if (!mainModule) return import_core.KnowledgeBase.build([]);
|
|
654
776
|
try {
|
|
655
|
-
|
|
777
|
+
const docs = await loadDocsFromDeno(cfg.docs, new URL(mainModule));
|
|
778
|
+
docCount = docs.length;
|
|
779
|
+
return import_core.KnowledgeBase.build(docs);
|
|
656
780
|
} catch {
|
|
657
781
|
return import_core.KnowledgeBase.build([]);
|
|
658
782
|
}
|
|
@@ -675,7 +799,7 @@ function aiReady(options = {}) {
|
|
|
675
799
|
return ladderPromise;
|
|
676
800
|
};
|
|
677
801
|
const resolvedAuth = /* @__PURE__ */ new WeakMap();
|
|
678
|
-
const core = (0,
|
|
802
|
+
const core = (0, import_core5.createWebMcpHandler)(
|
|
679
803
|
{
|
|
680
804
|
getServer: async (req) => {
|
|
681
805
|
if (!supabaseUrl) {
|
|
@@ -687,6 +811,14 @@ function aiReady(options = {}) {
|
|
|
687
811
|
const incoming = req.headers;
|
|
688
812
|
const headers = buildPostgrestHeaders(resolvedAuth.get(req) ?? incoming, anonKey);
|
|
689
813
|
const ladder = await getLadder(postgrestUrl);
|
|
814
|
+
const kb = await getKb();
|
|
815
|
+
if (!coldStartLogged) {
|
|
816
|
+
coldStartLogged = true;
|
|
817
|
+
try {
|
|
818
|
+
logColdStart({ ladder, cfg, docs: docCount, endpoint, auth: authLabel, logReady });
|
|
819
|
+
} catch {
|
|
820
|
+
}
|
|
821
|
+
}
|
|
690
822
|
const client = createPostgrestClient(postgrestUrl, headers, fetchImpl);
|
|
691
823
|
const invokeFunction = async (name, body) => {
|
|
692
824
|
const res = await fetchImpl(`${supabaseUrl.replace(/\/+$/, "")}/functions/v1/${name}`, {
|
|
@@ -703,7 +835,7 @@ function aiReady(options = {}) {
|
|
|
703
835
|
cfg,
|
|
704
836
|
client,
|
|
705
837
|
openapi: ladder.openapi,
|
|
706
|
-
kb
|
|
838
|
+
kb,
|
|
707
839
|
incoming,
|
|
708
840
|
invokeFunction,
|
|
709
841
|
warnings: ladder.warnings
|
|
@@ -712,12 +844,19 @@ function aiReady(options = {}) {
|
|
|
712
844
|
},
|
|
713
845
|
{ rateLimit: cfg.rateLimit, rateWindowMs: cfg.rateWindowMs }
|
|
714
846
|
);
|
|
847
|
+
if (gate) {
|
|
848
|
+
return async (req) => {
|
|
849
|
+
if (new URL(req.url).pathname.endsWith(PRM_SUFFIX)) return gate.metadataResponse(req.method);
|
|
850
|
+
const result = await gate.check(req.headers);
|
|
851
|
+
return result.ok ? core(req) : result.response;
|
|
852
|
+
};
|
|
853
|
+
}
|
|
715
854
|
const auth = options.auth;
|
|
716
855
|
if (!auth) return core;
|
|
717
856
|
return async (req) => {
|
|
718
857
|
let resolved;
|
|
719
858
|
try {
|
|
720
|
-
resolved = await auth(req.headers);
|
|
859
|
+
resolved = await auth(req.headers, {});
|
|
721
860
|
} catch (err) {
|
|
722
861
|
console.error("exposify-supabase: auth resolver failed", err);
|
|
723
862
|
return Response.json({ error: "internal MCP server error" }, { status: 503 });
|
|
@@ -740,5 +879,6 @@ function aiReady(options = {}) {
|
|
|
740
879
|
fetchOpenApi,
|
|
741
880
|
introspectTables,
|
|
742
881
|
loadManifest,
|
|
743
|
-
rpcArgSchema
|
|
882
|
+
rpcArgSchema,
|
|
883
|
+
supabaseAuth
|
|
744
884
|
});
|
package/dist/index.d.cts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AuthResolver, WebMcpHandler } from '@exposify/core';
|
|
1
|
+
import { OAuthOptions, AuthResolver, WebMcpHandler } from '@exposify/core';
|
|
2
2
|
|
|
3
3
|
type ColumnType = "string" | "number" | "integer" | "boolean" | "object" | "array";
|
|
4
4
|
interface ColumnSchema {
|
|
@@ -45,6 +45,23 @@ interface JsonSchemaObject {
|
|
|
45
45
|
properties?: Record<string, JsonSchemaProperty>;
|
|
46
46
|
required?: string[];
|
|
47
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* `OAuthOptions` with `resource` optional: it defaults to this function's own
|
|
50
|
+
* URL, `${SUPABASE_URL}/functions/v1/<function name>`.
|
|
51
|
+
*/
|
|
52
|
+
type SupabaseOAuthOptions = Omit<OAuthOptions, "resource"> & {
|
|
53
|
+
resource?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Where the `401` challenge says the Protected Resource Metadata lives.
|
|
56
|
+
* Default: `${resource}/.well-known/oauth-protected-resource`, under the
|
|
57
|
+
* function's own URL. Set it when a proxy you control serves the function
|
|
58
|
+
* on your own domain and forwards the RFC 9728 root-level well-known URI
|
|
59
|
+
* (e.g. `https://mcp.acme.com/.well-known/oauth-protected-resource/mcp`)
|
|
60
|
+
* to the function; the function answers any path ending in
|
|
61
|
+
* `/.well-known/oauth-protected-resource`.
|
|
62
|
+
*/
|
|
63
|
+
metadataUrl?: string;
|
|
64
|
+
};
|
|
48
65
|
interface AgentifySupabaseOptions {
|
|
49
66
|
/** `"<table>.insert"` / `"<table>.update"` / `"<table>.delete"` / `"<table>.*"`. */
|
|
50
67
|
writes?: string[];
|
|
@@ -54,7 +71,25 @@ interface AgentifySupabaseOptions {
|
|
|
54
71
|
functions?: Record<string, FunctionToolConfig>;
|
|
55
72
|
/** Doc globs bundled in the function directory. Default: `["docs/**\/*.md"]`. */
|
|
56
73
|
docs?: string[];
|
|
74
|
+
/** Default `"public"`, or `"required"` when `oauth` is set (which can't be combined with `"public"`). */
|
|
57
75
|
docsAuth?: "public" | "required";
|
|
76
|
+
/**
|
|
77
|
+
* Require an OAuth access token on the whole endpoint, so MCP clients can
|
|
78
|
+
* "click Connect and log in": a tokenless request gets a `401` pointing at
|
|
79
|
+
* Protected Resource Metadata served under this function's own path. Use
|
|
80
|
+
* `supabaseAuth()` for Supabase Auth's OAuth 2.1 server, or pass any
|
|
81
|
+
* spec-compliant AS (a function form receives the resolved `SUPABASE_URL`).
|
|
82
|
+
* The validated token is forwarded to PostgREST as-is, so RLS runs as the
|
|
83
|
+
* signed-in user. Can't be combined with `auth`/`authRequired`; deploy with
|
|
84
|
+
* `verify_jwt = false` so the gateway lets the first tokenless request
|
|
85
|
+
* through to answer it.
|
|
86
|
+
*/
|
|
87
|
+
oauth?: SupabaseOAuthOptions | ((supabaseUrl: string) => SupabaseOAuthOptions);
|
|
88
|
+
/**
|
|
89
|
+
* The cold-start "MCP ready" line and the oauth issuer check. Default: on,
|
|
90
|
+
* except under `NODE_ENV=test`. Failures are logged regardless.
|
|
91
|
+
*/
|
|
92
|
+
startupLog?: boolean;
|
|
58
93
|
/** Row cap applied to every `query_<table>` call regardless of caller-supplied `limit`. Default 100. */
|
|
59
94
|
maxRows?: number;
|
|
60
95
|
/** Overrides for local/CI use -- production reads these from the Deno env automatically. */
|
|
@@ -96,6 +131,20 @@ interface AgentifySupabaseOptions {
|
|
|
96
131
|
rateWindowMs?: number;
|
|
97
132
|
}
|
|
98
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Supabase Auth's OAuth 2.1 server as the authorization server. Per
|
|
136
|
+
* Supabase's docs its access tokens are ordinary project JWTs: `iss` is
|
|
137
|
+
* `${SUPABASE_URL}/auth/v1`, keys are at `/auth/v1/.well-known/jwks.json`,
|
|
138
|
+
* and `aud` is always `"authenticated"` -- it can't bind tokens to this
|
|
139
|
+
* function's URL, hence the `audience` override (any of the project's user
|
|
140
|
+
* JWTs is accepted, which is exactly what RLS already trusts). Needs, in the
|
|
141
|
+
* project: the OAuth 2.1 server enabled (beta as of 2026-10) with dynamic
|
|
142
|
+
* client registration on, an authorization path pointing at a consent page
|
|
143
|
+
* the app hosts, and asymmetric JWT signing keys (legacy HS256 projects have
|
|
144
|
+
* no JWKS to verify against).
|
|
145
|
+
*/
|
|
146
|
+
declare function supabaseAuth(overrides?: Partial<SupabaseOAuthOptions>): (supabaseUrl: string) => SupabaseOAuthOptions;
|
|
147
|
+
|
|
99
148
|
/** Subset of PostgREST's generated OpenAPI (Swagger 2.0) document that we read. */
|
|
100
149
|
interface PostgrestOpenApiDoc {
|
|
101
150
|
definitions?: Record<string, PostgrestTableDefinition>;
|
|
@@ -205,4 +254,4 @@ declare function fetchOpenApi(postgrestUrl: string, headers: Headers, fetchImpl:
|
|
|
205
254
|
*/
|
|
206
255
|
declare function aiReady(options?: AgentifySupabaseOptions): WebMcpHandler;
|
|
207
256
|
|
|
208
|
-
export { type AgentifySupabaseOptions, type ExposifyManifest, MANIFEST_VERSION, MissingExposifyMetaError, type PostgrestOpenApiDoc, aiReady, buildManifest, columnsFromOpenApi, fetchExposifyMeta, fetchOpenApi, introspectTables, loadManifest, rpcArgSchema };
|
|
257
|
+
export { type AgentifySupabaseOptions, type ExposifyManifest, MANIFEST_VERSION, MissingExposifyMetaError, type PostgrestOpenApiDoc, type SupabaseOAuthOptions, aiReady, buildManifest, columnsFromOpenApi, fetchExposifyMeta, fetchOpenApi, introspectTables, loadManifest, rpcArgSchema, supabaseAuth };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AuthResolver, WebMcpHandler } from '@exposify/core';
|
|
1
|
+
import { OAuthOptions, AuthResolver, WebMcpHandler } from '@exposify/core';
|
|
2
2
|
|
|
3
3
|
type ColumnType = "string" | "number" | "integer" | "boolean" | "object" | "array";
|
|
4
4
|
interface ColumnSchema {
|
|
@@ -45,6 +45,23 @@ interface JsonSchemaObject {
|
|
|
45
45
|
properties?: Record<string, JsonSchemaProperty>;
|
|
46
46
|
required?: string[];
|
|
47
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* `OAuthOptions` with `resource` optional: it defaults to this function's own
|
|
50
|
+
* URL, `${SUPABASE_URL}/functions/v1/<function name>`.
|
|
51
|
+
*/
|
|
52
|
+
type SupabaseOAuthOptions = Omit<OAuthOptions, "resource"> & {
|
|
53
|
+
resource?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Where the `401` challenge says the Protected Resource Metadata lives.
|
|
56
|
+
* Default: `${resource}/.well-known/oauth-protected-resource`, under the
|
|
57
|
+
* function's own URL. Set it when a proxy you control serves the function
|
|
58
|
+
* on your own domain and forwards the RFC 9728 root-level well-known URI
|
|
59
|
+
* (e.g. `https://mcp.acme.com/.well-known/oauth-protected-resource/mcp`)
|
|
60
|
+
* to the function; the function answers any path ending in
|
|
61
|
+
* `/.well-known/oauth-protected-resource`.
|
|
62
|
+
*/
|
|
63
|
+
metadataUrl?: string;
|
|
64
|
+
};
|
|
48
65
|
interface AgentifySupabaseOptions {
|
|
49
66
|
/** `"<table>.insert"` / `"<table>.update"` / `"<table>.delete"` / `"<table>.*"`. */
|
|
50
67
|
writes?: string[];
|
|
@@ -54,7 +71,25 @@ interface AgentifySupabaseOptions {
|
|
|
54
71
|
functions?: Record<string, FunctionToolConfig>;
|
|
55
72
|
/** Doc globs bundled in the function directory. Default: `["docs/**\/*.md"]`. */
|
|
56
73
|
docs?: string[];
|
|
74
|
+
/** Default `"public"`, or `"required"` when `oauth` is set (which can't be combined with `"public"`). */
|
|
57
75
|
docsAuth?: "public" | "required";
|
|
76
|
+
/**
|
|
77
|
+
* Require an OAuth access token on the whole endpoint, so MCP clients can
|
|
78
|
+
* "click Connect and log in": a tokenless request gets a `401` pointing at
|
|
79
|
+
* Protected Resource Metadata served under this function's own path. Use
|
|
80
|
+
* `supabaseAuth()` for Supabase Auth's OAuth 2.1 server, or pass any
|
|
81
|
+
* spec-compliant AS (a function form receives the resolved `SUPABASE_URL`).
|
|
82
|
+
* The validated token is forwarded to PostgREST as-is, so RLS runs as the
|
|
83
|
+
* signed-in user. Can't be combined with `auth`/`authRequired`; deploy with
|
|
84
|
+
* `verify_jwt = false` so the gateway lets the first tokenless request
|
|
85
|
+
* through to answer it.
|
|
86
|
+
*/
|
|
87
|
+
oauth?: SupabaseOAuthOptions | ((supabaseUrl: string) => SupabaseOAuthOptions);
|
|
88
|
+
/**
|
|
89
|
+
* The cold-start "MCP ready" line and the oauth issuer check. Default: on,
|
|
90
|
+
* except under `NODE_ENV=test`. Failures are logged regardless.
|
|
91
|
+
*/
|
|
92
|
+
startupLog?: boolean;
|
|
58
93
|
/** Row cap applied to every `query_<table>` call regardless of caller-supplied `limit`. Default 100. */
|
|
59
94
|
maxRows?: number;
|
|
60
95
|
/** Overrides for local/CI use -- production reads these from the Deno env automatically. */
|
|
@@ -96,6 +131,20 @@ interface AgentifySupabaseOptions {
|
|
|
96
131
|
rateWindowMs?: number;
|
|
97
132
|
}
|
|
98
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Supabase Auth's OAuth 2.1 server as the authorization server. Per
|
|
136
|
+
* Supabase's docs its access tokens are ordinary project JWTs: `iss` is
|
|
137
|
+
* `${SUPABASE_URL}/auth/v1`, keys are at `/auth/v1/.well-known/jwks.json`,
|
|
138
|
+
* and `aud` is always `"authenticated"` -- it can't bind tokens to this
|
|
139
|
+
* function's URL, hence the `audience` override (any of the project's user
|
|
140
|
+
* JWTs is accepted, which is exactly what RLS already trusts). Needs, in the
|
|
141
|
+
* project: the OAuth 2.1 server enabled (beta as of 2026-10) with dynamic
|
|
142
|
+
* client registration on, an authorization path pointing at a consent page
|
|
143
|
+
* the app hosts, and asymmetric JWT signing keys (legacy HS256 projects have
|
|
144
|
+
* no JWKS to verify against).
|
|
145
|
+
*/
|
|
146
|
+
declare function supabaseAuth(overrides?: Partial<SupabaseOAuthOptions>): (supabaseUrl: string) => SupabaseOAuthOptions;
|
|
147
|
+
|
|
99
148
|
/** Subset of PostgREST's generated OpenAPI (Swagger 2.0) document that we read. */
|
|
100
149
|
interface PostgrestOpenApiDoc {
|
|
101
150
|
definitions?: Record<string, PostgrestTableDefinition>;
|
|
@@ -205,4 +254,4 @@ declare function fetchOpenApi(postgrestUrl: string, headers: Headers, fetchImpl:
|
|
|
205
254
|
*/
|
|
206
255
|
declare function aiReady(options?: AgentifySupabaseOptions): WebMcpHandler;
|
|
207
256
|
|
|
208
|
-
export { type AgentifySupabaseOptions, type ExposifyManifest, MANIFEST_VERSION, MissingExposifyMetaError, type PostgrestOpenApiDoc, aiReady, buildManifest, columnsFromOpenApi, fetchExposifyMeta, fetchOpenApi, introspectTables, loadManifest, rpcArgSchema };
|
|
257
|
+
export { type AgentifySupabaseOptions, type ExposifyManifest, MANIFEST_VERSION, MissingExposifyMetaError, type PostgrestOpenApiDoc, type SupabaseOAuthOptions, aiReady, buildManifest, columnsFromOpenApi, fetchExposifyMeta, fetchOpenApi, introspectTables, loadManifest, rpcArgSchema, supabaseAuth };
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import { createWebMcpHandler } from "@exposify/core";
|
|
2
|
+
import { createWebMcpHandler, formatFailed as formatFailed2, formatWarning as formatWarning2, startupLogEnabled } from "@exposify/core";
|
|
3
3
|
|
|
4
4
|
// src/kb.ts
|
|
5
5
|
import { KnowledgeBase } from "@exposify/core";
|
|
@@ -75,12 +75,20 @@ function warnOnce(message) {
|
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
77
|
function resolveSupabaseConfig(options = {}) {
|
|
78
|
+
if (options.oauth) {
|
|
79
|
+
if (options.docsAuth === "public") {
|
|
80
|
+
throw new Error('docsAuth: "public" cannot be combined with oauth (the whole MCP endpoint requires a token)');
|
|
81
|
+
}
|
|
82
|
+
if (options.auth || options.authRequired) {
|
|
83
|
+
throw new Error("oauth cannot be combined with auth/authRequired (oauth already requires a valid token on every request)");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
78
86
|
return {
|
|
79
87
|
writes: parseWrites(options.writes),
|
|
80
88
|
rpc: options.rpc ?? [],
|
|
81
89
|
functions: options.functions ?? {},
|
|
82
90
|
docs: options.docs ?? ["docs/**/*.md"],
|
|
83
|
-
docsAuth: options.docsAuth ?? "public",
|
|
91
|
+
docsAuth: options.docsAuth ?? (options.oauth ? "required" : "public"),
|
|
84
92
|
maxRows: options.maxRows ?? 100,
|
|
85
93
|
rateLimit: options.rateLimit,
|
|
86
94
|
rateWindowMs: options.rateWindowMs
|
|
@@ -605,6 +613,96 @@ async function toResult(run) {
|
|
|
605
613
|
}
|
|
606
614
|
}
|
|
607
615
|
|
|
616
|
+
// src/oauth.ts
|
|
617
|
+
import { createOAuthGate, resolveOAuthOptions } from "@exposify/core";
|
|
618
|
+
var PRM_SUFFIX = "/.well-known/oauth-protected-resource";
|
|
619
|
+
function supabaseAuth(overrides = {}) {
|
|
620
|
+
return (supabaseUrl) => {
|
|
621
|
+
const base = supabaseUrl.replace(/\/+$/, "");
|
|
622
|
+
return {
|
|
623
|
+
issuer: `${base}/auth/v1`,
|
|
624
|
+
jwksUri: `${base}/auth/v1/.well-known/jwks.json`,
|
|
625
|
+
audience: "authenticated",
|
|
626
|
+
...overrides
|
|
627
|
+
};
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
function functionNameFromMainModule() {
|
|
631
|
+
const mainModule = globalThis.Deno?.mainModule;
|
|
632
|
+
if (!mainModule) return void 0;
|
|
633
|
+
try {
|
|
634
|
+
const parts = new URL(mainModule).pathname.split("/").filter(Boolean);
|
|
635
|
+
return parts.length >= 2 ? parts[parts.length - 2] : void 0;
|
|
636
|
+
} catch {
|
|
637
|
+
return void 0;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
function createSupabaseOAuthGate(oauth, supabaseUrl, fetchImpl) {
|
|
641
|
+
if (typeof oauth === "function" && !supabaseUrl) {
|
|
642
|
+
throw new Error("oauth: SUPABASE_URL is not set. In a deployed function this is automatic; for local dev pass { supabaseUrl }.");
|
|
643
|
+
}
|
|
644
|
+
const opts = typeof oauth === "function" ? oauth(supabaseUrl) : oauth;
|
|
645
|
+
let resource = opts.resource;
|
|
646
|
+
if (!resource) {
|
|
647
|
+
const name = functionNameFromMainModule();
|
|
648
|
+
if (!supabaseUrl || !name) {
|
|
649
|
+
throw new Error("oauth.resource could not be derived (needs SUPABASE_URL and Deno.mainModule); pass oauth.resource explicitly.");
|
|
650
|
+
}
|
|
651
|
+
resource = `${supabaseUrl.replace(/\/+$/, "")}/functions/v1/${name}`;
|
|
652
|
+
}
|
|
653
|
+
const { metadataUrl, ...oauthOptions } = opts;
|
|
654
|
+
const cfg = resolveOAuthOptions({ ...oauthOptions, resource });
|
|
655
|
+
if (metadataUrl !== void 0) {
|
|
656
|
+
try {
|
|
657
|
+
new URL(metadataUrl);
|
|
658
|
+
} catch {
|
|
659
|
+
throw new Error(`oauth.metadataUrl must be an absolute URL, got ${JSON.stringify(metadataUrl)}`);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
return createOAuthGate(cfg, { metadataUrl: metadataUrl ?? `${cfg.resource}${PRM_SUFFIX}`, fetchImpl });
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// src/startup.ts
|
|
666
|
+
import { formatFailed, formatReady, formatWarning } from "@exposify/core";
|
|
667
|
+
var LOG_PREFIX = "[exposify-supabase]";
|
|
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-supabase generate`";
|
|
669
|
+
function supabaseToolsPhrase(ladder, cfg, hasDocs) {
|
|
670
|
+
const query = ladder.tables.length;
|
|
671
|
+
let write = 0;
|
|
672
|
+
for (const table of ladder.tables) write += cfg.writes.get(table.name)?.size ?? 0;
|
|
673
|
+
const rpc = cfg.rpc.length;
|
|
674
|
+
const fn = Object.keys(cfg.functions).length;
|
|
675
|
+
const parts = [`${query} query`];
|
|
676
|
+
if (write) parts.push(`${write} write`);
|
|
677
|
+
if (rpc) parts.push(`${rpc} rpc`);
|
|
678
|
+
if (fn) parts.push(`${fn} function${fn === 1 ? "" : "s"}`);
|
|
679
|
+
if (hasDocs) parts.push("search_docs");
|
|
680
|
+
const total = query + write + rpc + fn + (hasDocs ? 1 : 0);
|
|
681
|
+
return `${total} tool${total === 1 ? "" : "s"} (${parts.join(", ")})`;
|
|
682
|
+
}
|
|
683
|
+
function logColdStart(opts) {
|
|
684
|
+
const { ladder, cfg, docs, endpoint, auth, logReady } = opts;
|
|
685
|
+
if (ladder.mode === "functions-only") {
|
|
686
|
+
console.error(
|
|
687
|
+
formatFailed(
|
|
688
|
+
LOG_PREFIX,
|
|
689
|
+
"couldn't read the database schema and no bundled manifest exists, so only registered functions and docs are exposed",
|
|
690
|
+
INTROSPECTION_FIX
|
|
691
|
+
)
|
|
692
|
+
);
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
if (logReady) {
|
|
696
|
+
const details = ladder.mode === "manifest" ? ["from the bundled manifest"] : [];
|
|
697
|
+
console.log(formatReady(LOG_PREFIX, { endpoint, tools: supabaseToolsPhrase(ladder, cfg, docs > 0), details, docs, auth }));
|
|
698
|
+
}
|
|
699
|
+
if (ladder.mode === "manifest") {
|
|
700
|
+
console.warn(
|
|
701
|
+
formatWarning(LOG_PREFIX, "live schema introspection failed; tools come from the bundled manifest and may be out of date", INTROSPECTION_FIX)
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
608
706
|
// src/index.ts
|
|
609
707
|
function denoEnv() {
|
|
610
708
|
return globalThis.Deno?.env;
|
|
@@ -616,13 +714,38 @@ function aiReady(options = {}) {
|
|
|
616
714
|
const supabaseUrl = options.supabaseUrl ?? env?.get("SUPABASE_URL");
|
|
617
715
|
const anonKey = options.anonKey ?? env?.get("SUPABASE_ANON_KEY") ?? "";
|
|
618
716
|
const serviceRoleKey = options.serviceRoleKey ?? env?.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
|
|
717
|
+
const gate = options.oauth ? createSupabaseOAuthGate(options.oauth, supabaseUrl, fetchImpl) : null;
|
|
718
|
+
const logReady = startupLogEnabled(options.startupLog);
|
|
719
|
+
const functionName = functionNameFromMainModule();
|
|
720
|
+
const endpoint = gate?.metadata.resource ?? (supabaseUrl && functionName ? `${supabaseUrl.replace(/\/+$/, "")}/functions/v1/${functionName}` : "this function");
|
|
721
|
+
const authLabel = gate ? `oauth (${String(gate.metadata.authorization_servers[0])})` : options.auth ? "custom resolver" : "passthrough (caller's JWT)";
|
|
722
|
+
if (!supabaseUrl) {
|
|
723
|
+
console.error(
|
|
724
|
+
formatFailed2(LOG_PREFIX, "SUPABASE_URL is not set", "deployed functions get it automatically; for local dev pass { supabaseUrl }")
|
|
725
|
+
);
|
|
726
|
+
} else if (gate && logReady) {
|
|
727
|
+
void gate.probe().then((problem) => {
|
|
728
|
+
if (!problem) return;
|
|
729
|
+
console.warn(
|
|
730
|
+
formatWarning2(
|
|
731
|
+
LOG_PREFIX,
|
|
732
|
+
`can't verify access tokens from oauth.issuer ${String(gate.metadata.authorization_servers[0])}: ${problem}`,
|
|
733
|
+
"check the issuer (supabaseAuth() needs the project's OAuth server enabled and asymmetric JWT signing keys); until then MCP clients get 503"
|
|
734
|
+
)
|
|
735
|
+
);
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
let coldStartLogged = false;
|
|
619
739
|
let kbPromise = null;
|
|
740
|
+
let docCount = 0;
|
|
620
741
|
const getKb = () => {
|
|
621
742
|
kbPromise ??= (async () => {
|
|
622
743
|
const mainModule = globalThis.Deno?.mainModule;
|
|
623
744
|
if (!mainModule) return KnowledgeBase.build([]);
|
|
624
745
|
try {
|
|
625
|
-
|
|
746
|
+
const docs = await loadDocsFromDeno(cfg.docs, new URL(mainModule));
|
|
747
|
+
docCount = docs.length;
|
|
748
|
+
return KnowledgeBase.build(docs);
|
|
626
749
|
} catch {
|
|
627
750
|
return KnowledgeBase.build([]);
|
|
628
751
|
}
|
|
@@ -657,6 +780,14 @@ function aiReady(options = {}) {
|
|
|
657
780
|
const incoming = req.headers;
|
|
658
781
|
const headers = buildPostgrestHeaders(resolvedAuth.get(req) ?? incoming, anonKey);
|
|
659
782
|
const ladder = await getLadder(postgrestUrl);
|
|
783
|
+
const kb = await getKb();
|
|
784
|
+
if (!coldStartLogged) {
|
|
785
|
+
coldStartLogged = true;
|
|
786
|
+
try {
|
|
787
|
+
logColdStart({ ladder, cfg, docs: docCount, endpoint, auth: authLabel, logReady });
|
|
788
|
+
} catch {
|
|
789
|
+
}
|
|
790
|
+
}
|
|
660
791
|
const client = createPostgrestClient(postgrestUrl, headers, fetchImpl);
|
|
661
792
|
const invokeFunction = async (name, body) => {
|
|
662
793
|
const res = await fetchImpl(`${supabaseUrl.replace(/\/+$/, "")}/functions/v1/${name}`, {
|
|
@@ -673,7 +804,7 @@ function aiReady(options = {}) {
|
|
|
673
804
|
cfg,
|
|
674
805
|
client,
|
|
675
806
|
openapi: ladder.openapi,
|
|
676
|
-
kb
|
|
807
|
+
kb,
|
|
677
808
|
incoming,
|
|
678
809
|
invokeFunction,
|
|
679
810
|
warnings: ladder.warnings
|
|
@@ -682,12 +813,19 @@ function aiReady(options = {}) {
|
|
|
682
813
|
},
|
|
683
814
|
{ rateLimit: cfg.rateLimit, rateWindowMs: cfg.rateWindowMs }
|
|
684
815
|
);
|
|
816
|
+
if (gate) {
|
|
817
|
+
return async (req) => {
|
|
818
|
+
if (new URL(req.url).pathname.endsWith(PRM_SUFFIX)) return gate.metadataResponse(req.method);
|
|
819
|
+
const result = await gate.check(req.headers);
|
|
820
|
+
return result.ok ? core(req) : result.response;
|
|
821
|
+
};
|
|
822
|
+
}
|
|
685
823
|
const auth = options.auth;
|
|
686
824
|
if (!auth) return core;
|
|
687
825
|
return async (req) => {
|
|
688
826
|
let resolved;
|
|
689
827
|
try {
|
|
690
|
-
resolved = await auth(req.headers);
|
|
828
|
+
resolved = await auth(req.headers, {});
|
|
691
829
|
} catch (err) {
|
|
692
830
|
console.error("exposify-supabase: auth resolver failed", err);
|
|
693
831
|
return Response.json({ error: "internal MCP server error" }, { status: 503 });
|
|
@@ -709,5 +847,6 @@ export {
|
|
|
709
847
|
fetchOpenApi,
|
|
710
848
|
introspectTables,
|
|
711
849
|
loadManifest,
|
|
712
|
-
rpcArgSchema
|
|
850
|
+
rpcArgSchema,
|
|
851
|
+
supabaseAuth
|
|
713
852
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exposify/supabase",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.8",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -27,12 +27,13 @@
|
|
|
27
27
|
"prepack": "yarn build"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@exposify/core": "0.3.
|
|
30
|
+
"@exposify/core": "0.3.12",
|
|
31
31
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
32
32
|
"zod": "^3.24.0"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@types/node": "^22.0.0",
|
|
36
|
+
"jose": "^6.1.3",
|
|
36
37
|
"tsup": "^8.3.0",
|
|
37
38
|
"typescript": "^5.6.0",
|
|
38
39
|
"vitest": "^3.0.0"
|