# MCP servers

> Tools from your tables and your own code, running as the signed-in user.

Source: https://bettersupabase.com/docs/frameworks/mcp

```ts title="supabase/functions/mcp/index.ts"
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:

```toml title="supabase/config.toml"
[functions.mcp]
verify_jwt = false

[auth.oauth_server]
enabled = true
```

If you started from the MCP server or headless app block in the Supabase
library, [Supabase library MCP blocks](/docs/guides/supabase-blocks) shows
how to add typed repositories to its pipeline or replace its tools with
`createMcp`.

## Protocol versions [#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 [#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
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 [#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:

* `resource` is the public origin plus `/functions/v1/<slug>`. The origin is
  `SUPABASE_PUBLIC_URL` when set, otherwise the gateway's `X-Forwarded-Host`,
  `X-Forwarded-Proto` and `X-Forwarded-Port` headers.
* 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/v1` rather than the
  internal `SUPABASE_URL`.
* `allowedHosts` is checked against `X-Forwarded-Host`, since `Host` is
  internal there. Elsewhere the server ignores that header, which a page on a
  rebinding host could set.

```http
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 [#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](#bearer-callers)).

### CORS [#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](/docs/guides/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
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 [#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:

```ts title="supabase/functions/mcp/index.ts"
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](/docs/auth/server#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 [#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`](/docs/specs) publishes.
Rows come back as `structuredContent` with a matching `outputSchema`.

Table tools run through the same code as [REST resources](/docs/specs/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 [#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](#protocol-versions)).

## Authorizing tools [#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:

```ts
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`:

```ts title="supabase/functions/mcp/index.ts"
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 every `tools/call`, after the
  arguments are validated and before `run`. It returns `{ allowed: true }` or
  `{ allowed: false, reason?, scopes? }`. A refusal is a tool error with
  `kind: "forbidden"` and the `reason`. A refusal with `scopes` answers 403
  with an `insufficient_scope` challenge whose `scope` lists `scopes` and
  the server's `scopes`, so the client can ask the user for more access.
* `visible(ctx, tool)` filters `tools/list`. A hidden tool is called like an
  unknown one. Lists carry `cacheScope: "private"`, so a client caches them
  per token, and the server reuses a user's list for the same `ttlMs` (five
  minutes). `tools/call` runs `visible` on 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 [#permissions]

To check a permission per tool, give the tool a `permission` and pass an
[authorizer](/docs/extending/authorizers) to `createMcp`:

```ts title="supabase/functions/mcp/index.ts"
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/call` checks the permission after the arguments are validated and
  before `run`, with `{ type: "mcp_tool", id: <tool name>, properties: <arguments> }`
  as the resource. A denial is a tool error with `kind: "forbidden"`,
  `code: "PERMISSION_DENIED"` (or `APPROVAL_REQUIRED`) and the permission
  key.
* `tools/list` hides the tools the caller is denied. Tools that share a
  permission are checked in one batch.
* Without an authorizer, a tool with a `permission` is 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)`](/docs/auth#typed-claims) 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:

```ts title="supabase/functions/_shared/supabase.ts"
const RoleClaims = v.looseObject({
  user_role: v.fallback(v.optional(v.string()), undefined),
});

export const betterSupabase = defineSupabase(schema).claims(RoleClaims);
```

```ts
const isAdmin = (auth: AuthState<v.InferOutput<typeof RoleClaims>>) =>
  auth.kind === "user" && auth.claims.user_role === "admin";
```

## Errors [#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.

```ts title="src/mcp.ts"
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](/docs/standards/events#sends-after-the-response) a tool
started.

## Official MCP SDK [#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:

```ts title="src/mcp.ts"
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:

```ts
const server = withBetterSupabaseMcp(protect(new McpServer(info)), auth);
```

### Supabase's MCP server [#supabases-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:

```bash
pnpm add @modelcontextprotocol/server @supabase/mcp-server-supabase
```

```ts title="src/ops-mcp.ts"
import { 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](/docs/extending/credentials) 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 [#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`    |