MCP servers
Tools from your tables and your own code, running as the signed-in user.
import { createMcp } from "better-supabase/mcp";
import { toStandardJsonSchema } from "@valibot/to-json-schema";
import * as v from "valibot";
import { customerList, betterSupabase } from "../_shared/supabase.ts";
const bs = createMcp(betterSupabase, {
name: "crm",
version: "1.0.0",
instructions: "Customers and notes of the signed-in user’s organization.",
resources: {
customers: { list: customerList, select: ["id", "name", "status"] },
notes: { operations: ["list", "get", "create"] },
},
}).tool({
name: "archive_customer",
description: "Archive a customer and everything attached to it.",
input: toStandardJsonSchema(v.object({ id: v.pipe(v.string(), v.uuid()) })),
annotations: { destructiveHint: true, idempotentHint: true },
run: ({ id }, { db }) => db.customers.update(id, { status: "archived" }),
});
Deno.serve(bs.fetch);bs.fetch serves the MCP endpoint (Streamable HTTP, stateless JSON
responses) and the RFC 9728 metadata, at
/.well-known/oauth-protected-resource/... and at
<endpoint>/oauth-protected-resource.
The function verifies tokens itself, so turn off the gateway's JWT check. Otherwise the gateway answers unauthenticated requests with its own 401, without the challenge MCP clients need to sign in:
[functions.mcp]
verify_jwt = false
[auth.oauth_server]
enabled = trueIf you started from the MCP server or headless app block in the Supabase
library, Supabase library MCP blocks shows
how to add typed repositories to its pipeline or replace its tools with
createMcp.
Protocol versions
The server follows MCP 2026-07-28 (SPEC_PINS.mcp). Those requests carry
their protocol version and client capabilities in _meta, so there is no
handshake. Clients can call server/discover for the supported versions,
capabilities and instructions. Every result has resultType: "complete"
and the server's name and version in _meta, and tools/list includes a
ttlMs and cacheScope: "private" caching hint. The MCP-Protocol-Version,
Mcp-Method and Mcp-Name headers must match the body, or the request is
rejected with a HeaderMismatch error.
Clients on 2025-11-25, 2025-06-18 or 2025-03-26 open with initialize
as before, and ping still answers them. An unsupported version gets
UnsupportedProtocolVersionError with the list of supported versions.
Tool schemas follow the client's protocol version. From 2025-11-25 on,
tools/list sends JSON Schema 2020-12. Earlier revisions have no default
dialect and their clients validate draft-07, so they get draft-07 schemas:
the tool's Standard Schema library converts to draft-07 when it can, and
otherwise the 2020-12 schema is lowered ($defs become definitions).
Each tool is converted once per dialect.
Running as the caller
MCP clients send a Supabase access token as a Bearer header. It is verified locally against the JWKS, and every tool call goes through repositories bound to that user, so RLS decides what the model can see and change. Without a valid token the server answers:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://<host>/.well-known/oauth-protected-resource/mcp"The metadata names Supabase Auth (<SUPABASE_URL>/auth/v1) as the
authorization server. Clients that support MCP authorization then sign the
user in through Supabase's OAuth server and retry. Override resource and
authorizationServers when the server sits behind a proxy.
On Supabase Edge Functions
The Edge Functions gateway strips /functions/v1 before the function sees
the request, and only routes paths under it, so a root /.well-known URL
never reaches the function. When SUPABASE_FUNCTION_SLUG or
SB_EXECUTION_ID is set, the server works this out the way
withOAuthProtectedResource from @supabase/server does:
resourceis the public origin plus/functions/v1/<slug>. The origin isSUPABASE_PUBLIC_URLwhen set, otherwise the gateway'sX-Forwarded-Host,X-Forwarded-ProtoandX-Forwarded-Portheaders.- The challenge points to
<resource>/oauth-protected-resource, which the gateway routes to the function. - The authorization server is Auth on the same public origin, so local
development advertises
http://127.0.0.1:54321/auth/v1rather than the internalSUPABASE_URL. allowedHostsis checked againstX-Forwarded-Host, sinceHostis internal there. Elsewhere the server ignores that header, which a page on a rebinding host could set.
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://<ref>.supabase.co/functions/v1/mcp/oauth-protected-resource"The same rules apply to createMcpAuth.
Embedded product agents
An agent inside your own product doesn't need OAuth: your backend forwards
the signed-in user's Supabase access token as Authorization: Bearer, and
the tools run as that user. Keep the token in the backend or the agent
orchestrator, never in a prompt or a request to the model provider. A product
session token has no client_id, while an OAuth token does, so decide in
authorize how each kind of caller is treated (see
Bearer callers).
CORS
Browser-based MCP clients send a preflight before each call. The server
answers OPTIONS with the allowed methods and the MCP headers
(Authorization, Mcp-Protocol-Version, Mcp-Method, Mcp-Name and the
rest), and every response exposes WWW-Authenticate so the client can read
the challenge. Any origin may call by default, which is safe because the
token travels in a header, not a cookie. With allowedOrigins, only a listed
origin is echoed back. cors: false turns all of it off.
A tool that starts background work enqueues it with
{ context: db.$context } from run's db, and the worker runs it as the
same user with bs.forContext(job.context). See
Jobs, webhooks and agents without a session.
A signed-in caller that allow or requiredScopes turns away gets a 403 with
the same metadata URL, so the client can ask for more access:
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", error_description="...", scope="openid crm.read", resource_metadata="https://<host>/.well-known/oauth-protected-resource/mcp"advertisedScopes lists the scopes your tools need. They appear as
scopes_supported in the metadata and as scope in both challenges.
offline_access is always left out: whether a client gets a refresh token is
between the client and the authorization server. advertisedScopes does not
refuse calls by itself. requiredScopes refuses a delegated token (an OAuth
client or an act chain) that lacks one of them, and authorize refuses a
single call. 0.6 removes the older scopes alias; better-supabase codemod 0.5 renames it to advertisedScopes.
A session below aal gets a plain 403 without a scope challenge, since more
scopes would not help. When the server can't verify the token (the JWKS is
unreachable), the answer is a 503 Problem Details response rather than a
challenge.
Bearer callers
A token from the Supabase OAuth server carries the client's client_id and
the scope the user granted it. toSession(ctx.auth) from
better-supabase/server turns these into session.actor and
session.delegation, so authorize can check a scope per tool:
import { toSession } from "better-supabase/server";
const bs = createMcp(betterSupabase, {
name: "crm",
version: "1.0.0",
advertisedScopes: ["openid", "crm.read", "crm.write"],
resources: { customers: true },
authorize: (ctx, tool) => {
const session = toSession(ctx.auth);
const granted =
session.kind === "user" ? session.delegation?.scopes : undefined;
const needed = tool.info.annotations?.readOnlyHint
? "crm.read"
: "crm.write";
return !granted || granted.includes(needed)
? { allowed: true }
: { allowed: false, reason: `Needs ${needed}`, scopes: [needed] };
},
});Set the server's token audience to the
resource URL your authorization server puts in aud, so a token issued for
another resource is refused before any tool runs.
delegation is unset for the user's own token, which no scope limits. For an
agent that exchanged its token, session.actor is the outermost sub of the
RFC 8693 act chain and chain keeps the actors before it. A malformed
act chain is { kind: 'invalid', reason: 'actor' }: the server answers
401 with the metadata challenge before any tool runs.
Table tools
Each table in resources gets one tool per operation:
| Tool | Input | Annotations |
|---|---|---|
<table>_list | the list query's JSON Schema, or page/size | readOnlyHint |
<table>_get | the key | readOnlyHint |
<table>_create | the Insert JSON Schema | destructiveHint: false |
<table>_update | the key and patch (the Update schema) | destructiveHint, idempotentHint |
<table>_delete | the key | destructiveHint, idempotentHint |
With pagination: 'cursor' on the resource (or its list query), the list
tool takes after instead of page and returns nextCursor, which suits
agents that read a table one page at a time. The schemas are generated from
your database metadata, the same ones defineApi publishes.
Rows come back as structuredContent with a matching outputSchema.
Table tools run through the same code as REST resources,
so a resource's permissions and hooks apply to them too. Their
permissions are checked on each call, with the table and the row as the
resource; unlike a tool's permission, they don't hide the tool from
tools/list.
Custom tools
bs.tool(...) is typed against your repositories. Its input can be any
Standard Schema: the tool's JSON Schema comes from Standard JSON Schema
(zod 4, ArkType, Valibot), or from inputSchema if you pass one. The
arguments are validated before run.
run gets the same context as the other adapters (db, auth, supabase,
sql) plus the request and an abort signal. defineTool builds a tool
outside the server, so it can go in createMcp({ tools: [...] }).
jsonSchemaTarget sets the dialect the tool's Standard Schema is converted
to: draft-2020-12 (the default) or draft-07. Clients on protocol
versions before 2025-11-25 get draft-07 either way (see
Protocol versions).
Authorizing tools
requiredRoles refuses the whole server to a signed-in user without one of
the roles, with 403 and the code MISSING_ROLE; service-role callers pass.
The roles are read from app_metadata.role unless you name another claim,
which may hold a string or an array of strings:
createMcp(betterSupabase, {
name: "crm",
version: "1.0.0",
requiredRoles: { roles: ["admin", "support"], claim: "app_metadata.roles" },
});RLS decides which rows a tool reads and writes. To decide whether the caller
may use a tool at all, pass authorize and visible:
const bs = createMcp(betterSupabase, {
name: "crm",
version: "1.0.0",
advertisedScopes: ["openid", "crm.read"],
resources: { customers: true },
authorize: (ctx, tool, args) =>
tool.meta === "admin" && !isAdmin(ctx.auth)
? { allowed: false, reason: "Only admins can archive customers" }
: { allowed: true },
visible: (ctx, tool) => tool.meta !== "admin" || isAdmin(ctx.auth),
}).tool({
name: "archive_customer",
description: "Archive a customer.",
input: toStandardJsonSchema(v.object({ id: v.pipe(v.string(), v.uuid()) })),
meta: "admin",
run: ({ id }, { db }) => db.customers.update(id, { status: "archived" }),
});meta is opaque data on a tool, for example a permission, that both hooks
receive as tool.meta. It is never sent to clients. Table tools take theirs
from the resource's meta, keyed by operation:
resources: { customers: { meta: { delete: "admin" } } } gives
customers_delete the meta "admin", and an operation without an entry
gets undefined.
authorize(ctx, tool, args)runs on everytools/call, after the arguments are validated and beforerun. It returns{ allowed: true }or{ allowed: false, reason?, scopes? }. A refusal is a tool error withkind: "forbidden"and thereason. A refusal withscopesanswers 403 with aninsufficient_scopechallenge whosescopelistsscopesand the server'sscopes, so the client can ask the user for more access.visible(ctx, tool)filterstools/list. A hidden tool is called like an unknown one. Lists carrycacheScope: "private", so a client caches them per token, and the server reuses a user's list for the samettlMs(five minutes).tools/callrunsvisibleon every call, so hiding a tool takes effect at once for calls.
Both hooks can be async. An exception in either one fails the call, and the
error message is hidden unless exposeErrors.
Permissions
To check a permission per tool, give the tool a permission and pass an
authorizer to createMcp:
const bs = createMcp(betterSupabase, {
name: "crm",
version: "1.0.0",
authorizer,
}).tool({
name: "archive_customer",
description: "Archive a customer.",
input: ArchiveInput,
permission: "customers.archive",
run: ({ id }, { db }) => db.customers.update(id, { status: "archived" }),
});tools/callchecks the permission after the arguments are validated and beforerun, with{ type: "mcp_tool", id: <tool name>, properties: <arguments> }as the resource. A denial is a tool error withkind: "forbidden",code: "PERMISSION_DENIED"(orAPPROVAL_REQUIRED) and the permission key.tools/listhides the tools the caller is denied. Tools that share a permission are checked in one batch.- Without an authorizer, a tool with a
permissionis hidden and refused, so a forgotten setup never opens it.
permission is typed from the authorizer you pass. authorize and
visible still run, after the permission check, for anything an
authorizer doesn't decide.
With .claims(schema) on the definition,
ctx.auth in authorize, visible and run is typed by the schema's
output, so the hooks read a role without parsing the claims again:
const RoleClaims = v.looseObject({
user_role: v.fallback(v.optional(v.string()), undefined),
});
export const betterSupabase = defineSupabase(schema).claims(RoleClaims);const isAdmin = (auth: AuthState<v.InferOutput<typeof RoleClaims>>) =>
auth.kind === "user" && auth.claims.user_role === "admin";Errors
Failures are reported as tool results with isError: true, so the model can
read them and correct itself:
- A failed
Result. - Invalid arguments.
- A thrown
DbException. - Unexpected errors, whose messages are hidden unless
exposeErrors.
The error text is the RFC 9457 Problem Details: kind, detail, and
validation issues with paths. Protocol problems use JSON-RPC errors: an
unknown method, an unknown tool, a malformed message, headers that disagree
with the body, or an unsupported protocol version.
Set allowedOrigins to reject browser requests from other origins, and
allowedHosts to reject requests whose Host header names another host
(compared without the port). Together they protect against DNS rebinding.
List every host the server answers on: production, previews and local
development.
export const bs = createMcp(betterSupabase, {
env,
name: "crm",
version: "1.0.0",
allowedOrigins: ["https://claude.ai"],
allowedHosts: ["crm.example.com", "crm.localhost", "localhost"],
resourceDocumentation: "https://crm.example.com/docs/mcp",
});resourceDocumentation is published as resource_documentation in the
protected resource metadata, so a client that hits the 401 can link the user
to the page that explains how to connect.
On runtimes that stop the invocation after the response, pass waitUntil to
createMcp (or createMcpAuth); it receives the
event sink sends a tool
started.
Official MCP SDK
When the server already runs on the official SDK (@modelcontextprotocol/server
2.3 or later), keep its McpServer and add better-supabase through
better-supabase/mcp/sdk. createMcpAuth verifies the Supabase access token
locally and serves the RFC 9728 metadata; withBetterSupabaseMcp wraps
registerTool so every tool callback gets db, auth and bs for the
verified caller:
import { createMcpHandler, McpServer } from "@modelcontextprotocol/server";
import { toStandardJsonSchema } from "@valibot/to-json-schema";
import { createMcpAuth, withBetterSupabaseMcp } from "better-supabase/mcp/sdk";
import * as v from "valibot";
import { betterSupabase } from "./lib/supabase/schema";
const auth = createMcpAuth(betterSupabase, {
resource: "https://crm.example.com/mcp",
advertisedScopes: ["crm:read"],
});
const handler = createMcpHandler(() => {
const server = withBetterSupabaseMcp(
new McpServer({ name: "crm", version: "1.0.0" }),
auth,
);
server.registerTool(
"archive_customer",
{
description: "Archive a customer.",
inputSchema: toStandardJsonSchema(v.object({ id: v.string() })),
},
async ({ id }, { db }) => {
await db.customers.update(id, { status: "archived" }).orThrow();
return { content: [{ type: "text", text: `Archived ${id}` }] };
},
);
return server;
});
export default { fetch: auth.serve(handler) };The tool's db is bound to the verified caller, so a tool that enqueues a
job passes { context: db.$context } the same way, and the job runs as that
user.
auth.serve(handler) answers the metadata at
/.well-known/oauth-protected-resource/mcp, refuses a missing or invalid
token with a 401 that points to it, and passes the verified AuthInfo to
handler.fetch(request, { authInfo }). The allow, aal and
requiredScopes options work as they do for createMcp: a delegated token
without a required scope gets a 403 insufficient_scope challenge, and the
user's own session is not limited by scopes.
To keep your own HTTP wiring, use the pieces instead: auth.verifier is an
OAuthTokenVerifier for the SDK's requireBearerAuth and
verifyBearerToken, auth.metadata(request) is the metadata response, and
auth.contextOf(ctx) returns the caller's context from a tool's ctx. A tool
call whose authInfo did not come from auth.verifier is verified again from
its token, and one without a token runs as anon.
To check permissions with a library that wraps the SDK's McpServer, wrap
the server with it first and withBetterSupabaseMcp second. The library then
refuses a tool before the repositories are built:
const server = withBetterSupabaseMcp(protect(new McpServer(info)), auth);Supabase's MCP server
supabaseMcpHandler serves Supabase's own MCP server
(@supabase/mcp-server-supabase) from your app, behind createMcpAuth. The
caller signs in with your Supabase Auth, and the server reaches the
Management API with a token your app holds, never the caller's JWT:
pnpm add @modelcontextprotocol/server @supabase/mcp-server-supabaseimport { createMcpAuth, supabaseMcpHandler } from "better-supabase/mcp/sdk";
import { credentials } from "./lib/credentials";
import { betterSupabase } from "./lib/supabase/schema";
const auth = createMcpAuth(betterSupabase, {
resource: "https://ops.example.com/mcp",
});
export default {
fetch: supabaseMcpHandler(auth, {
credentials,
credentialRef: { provider: "vault", secret: "supabase-management" },
projectRef: "abcdefghijklmnopqrst",
features: ["database", "debugging", "docs"],
authorize: (caller) => caller.claims.app_metadata?.role === "admin",
}),
};The handler answers the metadata and the bearer check of auth.serve, then
calls authorize with the verified caller; anyone it turns down gets a 403
before the token is read. It resolves credentialRef through the
credential provider as the app, builds a
Supabase MCP server for the request and closes it when the response ends.
The server is read-only unless you pass readOnly: false.
Its tools administer the project, so keep authorize to the people who run
it. Supabase's handler speaks MCP 2026-07-28 only, so clients need a version
of the protocol that negotiates it (the official SDK client with
versionNegotiation: { mode: "auto" }, for example).
@supabase/mcp-server-supabase loads on the first request and imports Node
modules, so the handler runs on Node, Bun and Deno but not on Cloudflare
Workers.
Which one to use
| You need | Use |
|---|---|
| Tools generated from tables and list definitions | createMcp |
| No MCP dependency, or the smallest bundle on an edge function | createMcp |
The authorize and visible hooks | createMcp |
An existing McpServer, prompts, resources or SDK features | createMcpAuth and withBetterSupabaseMcp |
A permission library that wraps McpServer | createMcpAuth and withBetterSupabaseMcp |
| Project tools (SQL, migrations, logs) for the people who run it | createMcpAuth and supabaseMcpHandler |
Last updated on