docs: give your app an MCP server (BYO-MCP guide rewrite) (#50218)

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Fixes AI-1009

Updates the BYO-MCP guide so it includes information about the new
middleware that will let users authenticate much more easily when
building their own MCP server. This one includes a couple of
clarifications which are important to document (use of environment
variables, etc.)

## What is the new behavior?


- Updated the existing guide (and example) for deploying an MCP server
to use `@modelcontextprotocol/server` v2 with `createMcpHandler`.
- Added new bits related to the new middleware which helps with
authentication specifying the required versions of supabase/server and
supabase/middleware, and also the auth prerequisites
- Includes a table of where each MCP client takes the URL.
- Added a new example to
`examples/edge-functions/supabase/functions/mcp/` to illustrate the
authentication example `authenticated-mcp-server`.

## Publish order

> [!IMPORTANT]  
> There will be a companion PR to include the library components so this
PR is blocked until https://github.com/supabase/supabase/pull/49579
ships.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added comprehensive guidance for deploying authenticated MCP servers
with OAuth 2.1, Supabase Auth, and user-scoped data access.
* Added an authenticated MCP server example with `list_todos` and
`create_todo` tools, protected by row-level security.
* Added setup instructions for OAuth configuration, consent screens,
local testing, and deployment.

* **Documentation**
* Updated authentication guidance and MCP security warnings across
related guides.
  * Added links to MCP server and OAuth consent resources.

* **Refactor**
* Simplified the unauthenticated MCP server example and updated its
tooling configuration.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Raúl Barroso authored and GitHub committed 2026-09-15 09:45:14 +02:00
1 parent 1caeb92122
commit cad51d85fd
11 files changed
+469 -196

No files matched your search

+307 -150
View File
@@ -4,229 +4,386 @@ title: 'Deploy MCP servers'
description: 'Build and deploy remote MCP servers on Supabase Edge Functions'
---
Build and deploy [Model Context Protocol](https://modelcontextprotocol.io/specification/2025-11-25) (MCP) servers on Supabase using [Edge Functions](/docs/guides/functions).
Build and deploy [Model Context Protocol](https://modelcontextprotocol.io/specification/2026-07-28) (MCP) servers on Supabase using [Edge Functions](/docs/guides/functions). MCP clients such as Claude, ChatGPT, Cursor, or VS Code call the tools you define.
This guide has two parts. [Deploy a public MCP server](#deploy-a-public-mcp-server) gets a server with no authentication running in a few minutes. [Add authentication](#add-authentication) puts Supabase Auth in front of it, so users sign in with their existing accounts and every tool call runs as that user under your Row Level Security (RLS) policies.
<Admonition type="note">
This guide covers MCP servers that do not require authentication. Auth support for MCP on Edge Functions is coming soon.
This is the MCP server your app exposes to its users. The [Supabase MCP server](/docs/guides/ai-tools/mcp) is different: it connects your own coding agent to your project so you can build the app.
</Admonition>
## Prerequisites
Before you begin, make sure you have:
- [Docker](https://docs.docker.com/get-docker/) or a compatible runtime, running (for local development)
- [Deno](https://deno.land/)
- [Supabase CLI](/docs/guides/local-development) 2.117.0 or later, installed and authenticated
- [Node.js 20 or later](https://nodejs.org/) (required by the Supabase CLI)
- [Docker](https://docs.docker.com/get-docker/) or a compatible runtime installed and running (required for local development)
- [Deno](https://deno.land/) installed (Supabase Edge Functions runtime)
- [Supabase CLI](/docs/guides/local-development) installed and authenticated
- [Node.js 20 or later](https://nodejs.org/) (required by Supabase CLI)
The tutorial uses the official [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk). Any MCP framework that runs on the Edge Runtime works the same way, for example [mcp-lite](/docs/guides/functions/examples/mcp-server-mcp-lite).
## Deploy your MCP server
## Deploy a public MCP server
### Step 1: Create a new project
A public server needs no user. Every caller sees the same tools, so this fits open data, calculators, and anything you would otherwise expose as an unauthenticated API.
Start by creating a new Supabase project:
### Step 1: Create a project and a function
```bash
mkdir my-mcp-server
cd my-mcp-server
mkdir my-mcp-server && cd my-mcp-server
supabase init
```
<Admonition type="note">
After this step, you should have a project directory with a `supabase` folder containing `config.toml` and an empty `functions` directory.
</Admonition>
---
### Step 2: Create the MCP server function
Create a new Edge Function for your MCP server:
```bash
supabase functions new mcp
```
<Admonition type="note">
This tutorial uses the [official MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk) with the `WebStandardStreamableHTTPServerTransport`, but you can use any MCP framework that's compatible with the [Edge Runtime](/docs/guides/functions), such as [mcp-lite](https://github.com/fiberplane/mcp-lite) or [mcp-handler](https://github.com/vercel/mcp-handler).
</Admonition>
Replace the contents of `supabase/functions/mcp/index.ts` with:
```ts name=supabase/functions/mcp/index.ts
// Setup type definitions for built-in Supabase Runtime APIs
import 'jsr:@supabase/functions-js/edge-runtime.d.ts'
import { McpServer } from 'npm:@modelcontextprotocol/sdk@1.25.3/server/mcp.js'
import { WebStandardStreamableHTTPServerTransport } from 'npm:@modelcontextprotocol/sdk@1.25.3/server/webStandardStreamableHttp.js'
import { Hono } from 'npm:hono@^4.9.7'
import { z } from 'npm:zod@^4.1.13'
import { createMcpHandler, McpServer } from 'npm:@modelcontextprotocol/server@^2.0.0'
import { z } from 'npm:zod@^4.3.6'
// Create Hono app
const app = new Hono()
const handler = createMcpHandler(() => {
const server = new McpServer({ name: 'mcp', version: '0.1.0' })
// Create your MCP server
const server = new McpServer({
name: 'mcp',
version: '0.1.0',
server.registerTool(
'add',
{
title: 'Addition Tool',
description: 'Add two numbers together',
inputSchema: z.object({ a: z.number(), b: z.number() }),
},
({ a, b }) => ({ content: [{ type: 'text', text: String(a + b) }] })
)
return server
})
// Register an addition tool
server.registerTool(
'add',
{
title: 'Addition Tool',
description: 'Add two numbers together',
inputSchema: { a: z.number(), b: z.number() },
},
({ a, b }) => ({
content: [{ type: 'text', text: String(a + b) }],
})
)
// Handle MCP requests
app.all('*', async (c) => {
const transport = new WebStandardStreamableHTTPServerTransport()
await server.connect(transport)
return transport.handleRequest(c.req.raw)
})
Deno.serve(app.fetch)
Deno.serve((req) => handler.fetch(req))
```
<Admonition type="note">
`createMcpHandler` runs the Streamable HTTP transport and builds a fresh `McpServer` for each request, which suits the stateless Edge Functions runtime.
After this step, you should have a new file at `supabase/functions/mcp/index.ts`.
The gateway verifies a JWT on every request by default. A public server has no JWT, so turn that off for this function:
</Admonition>
```toml name=supabase/config.toml
[functions.mcp]
verify_jwt = false
```
<Admonition type="caution">
Within Edge Functions, paths are prefixed with the function name. If your function is named something other than `mcp`, configure Hono with a base path: `new Hono().basePath('/your-function-name')`.
</Admonition>
---
### Step 3: Test locally
Start the Supabase local development stack:
### Step 2: Test locally
```bash
supabase start
supabase functions serve mcp
```
In a separate terminal, serve your function:
Your MCP server is at `http://127.0.0.1:54321/functions/v1/mcp`. Call the `add` tool with curl:
```bash
supabase functions serve --no-verify-jwt mcp
```
Your MCP server is now running at:
```
http://localhost:54321/functions/v1/mcp
```
<Admonition type="note">
The `--no-verify-jwt` flag disables JWT verification at the Edge Function layer so your MCP server can accept unauthenticated requests. Authenticated MCP support is coming soon.
</Admonition>
#### Test with curl
You can also test your MCP server directly with curl. Call the `add` tool:
```bash
curl -X POST 'http://localhost:54321/functions/v1/mcp' \
curl -X POST 'http://127.0.0.1:54321/functions/v1/mcp' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "add",
"arguments": {
"a": 5,
"b": 3
}
}
}'
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"add","arguments":{"a":5,"b":3}}}'
```
<Admonition type="note">
The MCP Streamable HTTP transport requires the `Accept: application/json, text/event-stream` header to indicate the client supports both JSON and Server-Sent Events responses.
</Admonition>
**Expected response:**
The response uses Server-Sent Events (SSE) format:
```
event: message
data: {"result":{"content":[{"type":"text","text":"8"}]},"jsonrpc":"2.0","id":1}
```
#### Test with MCP Inspector
The `Accept` header tells the transport the client understands both JSON and Server-Sent Events. Without it the request is rejected.
Test your server with the official [MCP Inspector](https://github.com/modelcontextprotocol/inspector):
To explore the server from a UI, run the [MCP Inspector](https://github.com/modelcontextprotocol/inspector), choose the Streamable HTTP transport, and enter the URL above:
```bash
npx -y @modelcontextprotocol/inspector
```
Use the local endpoint `http://localhost:54321/functions/v1/mcp` in the inspector UI to explore available tools and test them interactively.
<Admonition type="note">
After this step, you should have your MCP server running locally and be able to test the `add` tool in the MCP Inspector.
</Admonition>
### Step 4: Deploy to production
When you're ready to deploy, link your project and deploy the function:
### Step 3: Deploy
```bash
supabase link --project-ref <your-project-ref>
supabase functions deploy --no-verify-jwt mcp
supabase config push
supabase functions deploy mcp
```
Your MCP server will be available at:
Your MCP server is at `https://<your-project-ref>.supabase.co/functions/v1/mcp`. Point any client from the [table below](#connect-from-mcp-clients) at it; no sign-in is involved.
## Add authentication
Most servers act on user data, and then the question is who the caller is. With Supabase Auth as the OAuth 2.1 authorization server, users sign in with their existing accounts, approve the MCP client once, and every tool call runs as that user. Your RLS policies decide what each client can see, with no per-tool authorization code.
### How it works
The authenticated function composes two pieces of middleware from [`@supabase/server`](/docs/reference/server/introduction) into a `pipeline` from `@supabase/middleware`, followed by your MCP handler:
```
https://<your-project-ref>.supabase.co/functions/v1/mcp
pipeline([...], handler)
withOAuthProtectedResource() OAuth discovery for MCP clients (RFC 9728, WWW-Authenticate on 401)
withSupabase({ auth: 'user' }) verifies the user's token, hands you an RLS-scoped client
handler MCP transport and your tools
```
Update your MCP client configuration to use the production URL.
`withOAuthProtectedResource()` runs before the auth gate. It serves the OAuth Protected Resource Metadata document so clients can find your authorization server, and it adds the `WWW-Authenticate` challenge to unauthenticated responses. `withSupabase({ auth: 'user' })` rejects requests without a valid user token and gives your handler a Supabase client scoped to that user. Anything the tools read or write goes through RLS.
Two things must be in place beyond the prerequisites above:
- A Supabase project that signs JWTs with an asymmetric key (ES256 or RS256). `withSupabase` verifies user tokens against the project JWKS and rejects legacy HS256 tokens; switch in [JWT Signing Keys](/docs/guides/auth/signing-keys) if your project still uses the legacy secret.
- A web frontend where users sign in. The OAuth consent screen is hosted there, not by Supabase.
### Step 1: Configure Supabase Auth
MCP clients authenticate through OAuth 2.1, with Supabase Auth as the authorization server. Three settings need to be on.
1. **Enable the OAuth 2.1 server.** Follow the [getting started guide](/docs/guides/auth/oauth-server/getting-started).
2. **Enable dynamic client registration.** MCP clients register themselves before starting an OAuth flow. Enable it under **Authentication** > **OAuth Server** in the dashboard. It lets any compatible client register, so review registered clients and let users revoke grants.
3. **Host a consent screen.** Auth redirects users to your frontend to approve the client. The [OAuth Consent block](/library/docs/nextjs/oauth-consent) in the Supabase Library installs a ready-made `/oauth/consent` route for Next.js, React, React Router, and TanStack Start. Set the Auth **Site URL** to the origin that serves it.
For local development, the same settings live in `supabase/config.toml`:
```toml name=supabase/config.toml
[auth]
site_url = "http://localhost:3000"
[auth.oauth_server]
enabled = true
authorization_url_path = "/oauth/consent"
allow_dynamic_registration = true
```
### Step 2: Create the MCP server
The fastest path is the [MCP Server block](/library/docs/headless/mcp-server) in the Supabase Library. It installs an Edge Function with the middleware already wired, a `whoami` tool, and a small tool registry to extend:
```bash
npx shadcn@latest add https://supabase.com/library/r/mcp-server.json
```
To write the function yourself, start with a table for the tools to work on. Create it with RLS so each user only sees their own rows; the `user_id` default means inserts don't need to pass it. Save this as a migration with `supabase migration new create_todos` and paste it into the generated file:
```sql
create table public.todos (
id uuid primary key default gen_random_uuid(),
user_id uuid not null default auth.uid() references auth.users (id) on delete cascade,
title text not null,
done boolean not null default false,
created_at timestamptz not null default now()
);
alter table public.todos enable row level security;
create policy "Users manage their own todos"
on public.todos for all to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);
```
If you skipped the public part, create the function now with `supabase functions new mcp`. The `verify_jwt = false` setting from that part is needed here too: the function verifies tokens itself, and the gateway would otherwise reject the unauthenticated discovery request before `withOAuthProtectedResource` can answer it.
The function imports a `Database` type so the Supabase client inside the tools knows the table's columns. Start the local stack, which applies the migration, then generate the type from it. If the stack is already running, apply the migration with `supabase migration up` first:
```bash
supabase start
supabase gen types typescript --local > supabase/functions/mcp/database.types.ts
```
Replace the contents of `supabase/functions/mcp/index.ts`. Compared with the public server, the handler moves inside a `pipeline` so it receives the caller's Supabase client, and the tools query a table instead of adding numbers:
```ts name=supabase/functions/mcp/index.ts
// Setup type definitions for built-in Supabase Runtime APIs
import 'jsr:@supabase/functions-js/edge-runtime.d.ts'
import { createMcpHandler, McpServer } from 'npm:@modelcontextprotocol/server@^2.0.0'
import { pipeline } from 'npm:@supabase/middleware@^0.5.0'
import { withOAuthProtectedResource, withSupabase } from 'npm:@supabase/server@^1.6.0'
import { z } from 'npm:zod@^4.3.6'
import type { Database } from './database.types.ts'
Deno.serve(
pipeline(
// 1. OAuth discovery for MCP clients, 2. verify the user's token and scope a client to them
[withOAuthProtectedResource(), withSupabase<Database>({ auth: 'user' })],
async (req, { supabase }) => {
// A fresh server per request: Edge Functions are stateless
const handler = createMcpHandler(() => {
const server = new McpServer({ name: 'todos', version: '0.1.0' })
server.registerTool(
'list_todos',
{
description: 'List the todos of the signed-in user',
inputSchema: z.object({ limit: z.number().int().min(1).max(100).default(20) }),
annotations: { readOnlyHint: true },
},
async ({ limit }) => {
// RLS scopes this query to the signed-in user
const { data, error } = await supabase
.from('todos')
.select('id, title, done')
.order('created_at', { ascending: false })
.limit(limit)
if (error) throw new Error(error.message)
return { content: [{ type: 'text', text: JSON.stringify(data) }] }
}
)
server.registerTool(
'create_todo',
{
description: 'Create a todo for the signed-in user',
inputSchema: z.object({ title: z.string().min(1).max(200) }),
},
async ({ title }) => {
const { data, error } = await supabase.from('todos').insert({ title }).select().single()
if (error) throw new Error(error.message)
return { content: [{ type: 'text', text: JSON.stringify(data) }] }
}
)
return server
})
return handler.fetch(req)
}
)
)
```
<Admonition type="note">
After this step, you have a fully deployed MCP server accessible from anywhere. You can test it using the MCP Inspector with your production URL.
Composing `withSupabase` as a `pipeline` entry is alpha and tracks `@supabase/middleware` 0.x. The nested form, `withOAuthProtectedResource(withSupabase({ auth: 'user' }, handler))`, is stable and behaves the same. Both need `@supabase/server` 1.6.0 or later.
</Admonition>
### Step 3: Test locally
With the local stack still running from Step 2, serve the function:
```bash
supabase functions serve mcp
```
The `tools/call` request from the public part now fails, because the caller has no token. Check the OAuth handshake instead. An unauthenticated request returns `401` with a `WWW-Authenticate` header naming the metadata document:
```bash
curl -si -X POST 'http://127.0.0.1:54321/functions/v1/mcp' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}' \
| grep -i '^HTTP\|www-authenticate'
```
```
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer resource_metadata="http://127.0.0.1:54321/functions/v1/mcp/oauth-protected-resource"
```
The metadata document points clients at your project's Auth server:
```bash
curl -s 'http://127.0.0.1:54321/functions/v1/mcp/oauth-protected-resource'
```
```json
{
"resource": "http://127.0.0.1:54321/functions/v1/mcp",
"authorization_servers": ["http://127.0.0.1:54321/auth/v1"],
"bearer_methods_supported": ["header"]
}
```
Both URLs use the public origin of your local stack, taken from the headers the gateway forwards, not the Docker-internal `http://kong:8000` that `SUPABASE_URL` holds inside the function. Supabase CLI 2.117.0 and later also injects the function slug, so the `resource` path stays canonical whatever sub-path a request arrives on.
#### Test with MCP Inspector
The official [MCP Inspector](https://github.com/modelcontextprotocol/inspector) runs the OAuth flow and lets you call tools from a UI. Your frontend must be running so the consent screen is reachable.
```bash
npx -y @modelcontextprotocol/inspector
```
In the Inspector, choose the Streamable HTTP transport, enter `http://127.0.0.1:54321/functions/v1/mcp`, and connect. The browser opens your sign-in page, then the consent screen. After you approve, the Tools tab lists `list_todos` and `create_todo`; call them from there.
#### Test with Claude Code
Add the server to [Claude Code](https://docs.anthropic.com/en/docs/claude-code/mcp):
```bash
claude mcp add --transport http todos http://127.0.0.1:54321/functions/v1/mcp
```
Run `/mcp` in Claude Code and authenticate. The same sign-in and consent flow runs in the browser. After you approve, ask Claude to list your todos or create one.
### Step 4: Deploy
Link your project, push the Auth settings from `config.toml`, and deploy the function:
```bash
supabase link --project-ref <your-project-ref>
supabase config push
supabase functions deploy mcp
```
Your MCP server is now at `https://<your-project-ref>.supabase.co/functions/v1/mcp`. Deploy your frontend with the consent route to the origin configured as the Auth Site URL.
## Connect from MCP clients
Every client that implements the MCP authorization specification discovers your Auth server from the `WWW-Authenticate` challenge, registers itself, and runs the OAuth flow. The server URL is the only configuration they need.
| Client | Where to add the URL |
| ----------- | ------------------------------------------------------------------------------------- |
| Claude Code | `claude mcp add --transport http <name> <url>` |
| Claude | Settings > Connectors > Add custom connector |
| Cursor | `.cursor/mcp.json`: `{ "mcpServers": { "<name>": { "url": "<url>" } } }` |
| VS Code | `.vscode/mcp.json`: `{ "servers": { "<name>": { "type": "http", "url": "<url>" } } }` |
| ChatGPT | Settings > Connectors, with developer mode enabled. Requires a public HTTPS URL. |
Users can review and revoke connected clients through the [OAuth grant management](/docs/guides/auth/oauth-server/oauth-flows#managing-user-grants) endpoints. The [Headless App block](/library/docs/tanstack/headless-app) ships an `/agents` page that does this.
## Run it outside Edge Functions
The same pipeline mounts in any runtime that speaks `Request` in, `Response` out: a Next.js route handler, a SvelteKit endpoint, Cloudflare Workers, or a plain Node, Bun, or Deno server. Off Edge Functions there are no forwarded headers to derive the public URLs from, so pass them explicitly:
```ts
import { pipeline } from '@supabase/middleware'
import { fromSupabaseUrl, withOAuthProtectedResource, withSupabase } from '@supabase/server'
export default {
fetch: pipeline(
[
withOAuthProtectedResource({
resourceServer: (req) => new URL(req.url).origin + '/api/mcp',
authorizationServer: fromSupabaseUrl('https://<your-project-ref>.supabase.co'),
}),
withSupabase({ auth: 'user' }),
],
handler
),
}
```
`resourceServer` is the public URL of the MCP endpoint. `authorizationServer` is the Auth issuer; `fromSupabaseUrl` derives it from your project URL. Both accept a string or a function of the request, so you can also point at a non-Supabase OAuth 2.1 server.
## Limitations
Edge Functions are stateless. The server answers one HTTP request at a time with no open channel back to the client, which rules out MCP sampling (the server asking the client to run an LLM completion). Tools that need more input from the user should return a message asking for it instead.
## Examples
You can find ready-to-use MCP server implementations here:
Both examples in this guide are in the `supabase/supabase` repository, ready to serve or deploy:
{/* supa-mdx-lint-disable-next-line Rule004ExcludeWords */}
- [Simple MCP server](https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/mcp/simple-mcp-server) - Unauthenticated example
- [Public MCP server](https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/mcp/simple-mcp-server): the `add` tool, no authentication
- [Authenticated MCP server](https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/mcp/authenticated-mcp-server): the `todos` tools behind Supabase Auth, with the migration and RLS policy
## Resources
- [Model Context Protocol Specification](https://modelcontextprotocol.io/specification/2025-11-25)
- [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk)
- [Supabase Edge Functions](/docs/guides/functions)
- [OAuth 2.1 Server](/docs/guides/auth/oauth-server)
- [MCP Authentication](/docs/guides/auth/oauth-server/mcp-authentication)
- [Building MCP servers with mcp-lite](/docs/guides/functions/examples/mcp-server-mcp-lite) - Alternative lightweight framework
- [MCP Server block](/library/docs/headless/mcp-server) and [Headless App block](/library/docs/tanstack/headless-app) in the Supabase Library
- [`@supabase/server` reference](/docs/reference/server/introduction)
- [MCP authentication with Supabase Auth](/docs/guides/auth/oauth-server/mcp-authentication)
- [OAuth 2.1 server](/docs/guides/auth/oauth-server)
- [Token security and RLS](/docs/guides/auth/oauth-server/token-security)
- [Model Context Protocol specification](https://modelcontextprotocol.io/specification/2026-07-28)
- [Building MCP servers with mcp-lite](/docs/guides/functions/examples/mcp-server-mcp-lite): an alternative lightweight framework
@@ -76,7 +76,9 @@ export default {
}
```
See the [`@supabase/server` reference](/docs/reference/server) for the full API.
See the [`@supabase/server` reference](/docs/reference/server/introduction) for the full API.
`@supabase/server` is also the package for MCP servers. Its `withOAuthProtectedResource` middleware handles OAuth discovery for MCP clients, and composed with `withSupabase({ auth: 'user' })` every tool call runs as the signed-in user. See [Deploy MCP servers](/docs/guides/ai-tools/byo-mcp).
<Admonition type="note">
@@ -91,5 +93,5 @@ In a cookie-based framework you can compose the two — let `@supabase/ssr` own
## Next steps
- [Server-side rendering](/guides/auth/server-side) — set up `@supabase/ssr` for your framework.
- [`@supabase/server` reference](/docs/reference/server) — API for header-based server auth.
- [`@supabase/server` reference](/docs/reference/server/introduction) — API for header-based server auth.
- [`supabase-js` reference](/docs/reference/javascript/introduction) — the base JavaScript client.
@@ -4,11 +4,11 @@ title: 'Model Context Protocol (MCP) Authentication'
description: 'Integrate Supabase Auth with MCP servers to authenticate AI agents using your existing user base'
---
The Model Context Protocol (MCP) is an open standard for connecting AI agents and LLM tools to data sources and services. While Supabase doesn't provide MCP server functionality, you can build your own MCP servers that connect to your Supabase project and leverage Supabase Auth's OAuth 2.1 capabilities to authenticate AI agents using your existing user base.
The Model Context Protocol (MCP) is an open standard for connecting AI agents and LLM tools to data sources and services. You can give your own app an MCP server that runs on Supabase and uses Supabase Auth's OAuth 2.1 capabilities to authenticate AI agents as your existing users. This page covers the authentication side. For the end-to-end walkthrough, including the Edge Function that hosts the server, see [Deploy MCP servers](/docs/guides/ai-tools/byo-mcp).
## Why use Supabase Auth for MCP?
When building MCP servers that connect to your Supabase project, you can leverage your existing Supabase Auth infrastructure to authenticate AI agents:
When building MCP servers that connect to your Supabase project, you can use your existing Supabase Auth infrastructure to authenticate AI agents:
- **Use your existing user base** - No need to create separate authentication systems; AI agents authenticate as your existing users
- **Standards-compliant OAuth 2.1** - Full implementation with PKCE that MCP clients expect
@@ -28,7 +28,7 @@ When you build an MCP server that connects to your Supabase project, authenticat
4. **Token exchange**: Supabase issues access and refresh tokens for the authenticated user
5. **Authenticated access**: The MCP server can now make requests to your Supabase APIs on behalf of the user
By leveraging Supabase Auth, your MCP server can authenticate AI agents using your existing user accounts without building a separate authentication system.
With Supabase Auth, your MCP server can authenticate AI agents using your existing user accounts without building a separate authentication system.
## Prerequisites
@@ -73,7 +73,26 @@ Dynamic registration allows any MCP client to register with your project. Consid
## Building an MCP server with Supabase Auth
When building your own MCP server, integrate with Supabase Auth to authenticate AI agents as your existing users and leverage your RLS policies.
When building your own MCP server, integrate with Supabase Auth to authenticate AI agents as your existing users and apply your RLS policies.
On Supabase Edge Functions, or any runtime with a `fetch`-style handler, [`@supabase/server`](/docs/reference/server/introduction) does the OAuth plumbing for you. `withOAuthProtectedResource()` publishes the protected resource metadata and the `WWW-Authenticate` challenge that MCP clients use to find your Auth server; `withSupabase({ auth: 'user' })` verifies the token and gives your tools a client scoped to that user:
```ts
import { pipeline } from 'npm:@supabase/middleware@^0.5.0'
import { withOAuthProtectedResource, withSupabase } from 'npm:@supabase/server@^1.6.0'
Deno.serve(
pipeline(
[withOAuthProtectedResource(), withSupabase({ auth: 'user' })],
async (req, { supabase }) => {
// supabase is scoped to the signed-in user; hand it to your MCP tools
return mcpHandler(req, supabase)
}
)
)
```
The [MCP Server block](/library/docs/headless/mcp-server) in the Supabase Library packages this as an installable Edge Function, and the [OAuth Consent block](/library/docs/nextjs/oauth-consent) provides the consent screen. See [Deploy MCP servers](/docs/guides/ai-tools/byo-mcp) for the full setup.
<Admonition type="note">
@@ -262,9 +262,9 @@ https://your-project-ref.supabase.co/functions/v1/mcp-server/mcp
<Admonition type="caution">
The template uses `--no-verify-jwt` for quick development. This means authentication is not enforced by Supabase's JWT layer.
The template uses `--no-verify-jwt` for quick development. This means authentication is not enforced by Supabase's JWT layer, and anyone who finds the URL can call your tools.
For production, you should implement authentication at the MCP server level following the [MCP Authorization specification](https://modelcontextprotocol.io/specification/draft/basic/authorization). This gives you control over who can access your MCP tools.
For production, authenticate at the MCP server level following the [MCP Authorization specification](https://modelcontextprotocol.io/specification/draft/basic/authorization). On Supabase, `withOAuthProtectedResource` and `withSupabase` from `@supabase/server` do this with Supabase Auth as the OAuth 2.1 server, so each tool call runs as the signed-in user. They wrap any MCP library, including mcp-lite. See [Deploy MCP servers](/docs/guides/ai-tools/byo-mcp).
</Admonition>
@@ -80,6 +80,11 @@ verify_jwt = true
[functions.simple-mcp-server]
verify_jwt = false
entrypoint = "./functions/mcp/simple-mcp-server/index.ts"
import_map = "./functions/mcp/simple-mcp-server/deno.json"
[functions.authenticated-mcp-server]
verify_jwt = false
entrypoint = "./functions/mcp/authenticated-mcp-server/index.ts"
import_map = "./functions/mcp/authenticated-mcp-server/deno.json"
[functions.read-storage]
[functions.restful-tasks]
[functions.select-from-table-with-auth-rls]
@@ -0,0 +1,29 @@
// Minimal stand-in for `supabase gen types typescript --local`, enough to type-check the snippet.
export type Database = {
public: {
Tables: {
todos: {
Row: { id: string; user_id: string; title: string; done: boolean; created_at: string }
Insert: {
id?: string
user_id?: string
title: string
done?: boolean
created_at?: string
}
Update: {
id?: string
user_id?: string
title?: string
done?: boolean
created_at?: string
}
Relationships: []
}
}
Views: Record<string, never>
Functions: Record<string, never>
Enums: Record<string, never>
CompositeTypes: Record<string, never>
}
}
@@ -0,0 +1,8 @@
{
"imports": {
"@modelcontextprotocol/server": "npm:@modelcontextprotocol/server@^2.0.0",
"@supabase/middleware": "npm:@supabase/middleware@^0.5.0",
"@supabase/server": "npm:@supabase/server@^1.6.0",
"zod": "npm:zod@^4.3.6"
}
}
@@ -0,0 +1,58 @@
// Setup type definitions for built-in Supabase Runtime APIs
import 'jsr:@supabase/functions-js/edge-runtime.d.ts'
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'
import { pipeline } from '@supabase/middleware'
import { withOAuthProtectedResource, withSupabase } from '@supabase/server'
import { z } from 'zod'
import type { Database } from './database.types.ts'
Deno.serve(
pipeline(
// 1. OAuth discovery for MCP clients, 2. verify the user's token and scope a client to them
[withOAuthProtectedResource(), withSupabase<Database>({ auth: 'user' })],
async (req, { supabase }) => {
// A fresh server per request: Edge Functions are stateless
const handler = createMcpHandler(() => {
const server = new McpServer({ name: 'todos', version: '0.1.0' })
server.registerTool(
'list_todos',
{
description: 'List the todos of the signed-in user',
inputSchema: z.object({ limit: z.number().int().min(1).max(100).default(20) }),
annotations: { readOnlyHint: true },
},
async ({ limit }) => {
// RLS scopes this query to the signed-in user
const { data, error } = await supabase
.from('todos')
.select('id, title, done')
.order('created_at', { ascending: false })
.limit(limit)
if (error) throw new Error(error.message)
return { content: [{ type: 'text', text: JSON.stringify(data) }] }
}
)
server.registerTool(
'create_todo',
{
description: 'Create a todo for the signed-in user',
inputSchema: z.object({ title: z.string().min(1).max(200) }),
},
async ({ title }) => {
const { data, error } = await supabase.from('todos').insert({ title }).select().single()
if (error) throw new Error(error.message)
return { content: [{ type: 'text', text: JSON.stringify(data) }] }
}
)
return server
})
return handler.fetch(req)
}
)
)
@@ -1,8 +1,6 @@
{
"imports": {
"@hono/mcp": "npm:@hono/mcp@^0.1.1",
"@modelcontextprotocol/sdk": "npm:/@modelcontextprotocol/sdk@^1.24.3",
"hono": "npm:hono@^4.9.2",
"zod": "npm:zod@^4.1.13"
"@modelcontextprotocol/server": "npm:@modelcontextprotocol/server@^2.0.0",
"zod": "npm:zod@^4.3.6"
}
}
@@ -1,42 +1,23 @@
// Setup type definitions for built-in Supabase Runtime APIs
import 'jsr:@supabase/functions-js/edge-runtime.d.ts'
import { StreamableHTTPTransport } from '@hono/mcp'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { Hono } from 'hono'
import { withSupabase } from 'npm:@supabase/server@^1'
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'
import { z } from 'zod'
// Create Hono app
const app = new Hono()
const handler = createMcpHandler(() => {
const server = new McpServer({ name: 'mcp', version: '0.1.0' })
// Create your MCP server
const server = new McpServer({
name: 'mcp',
version: '0.1.0',
server.registerTool(
'add',
{
title: 'Addition Tool',
description: 'Add two numbers together',
inputSchema: z.object({ a: z.number(), b: z.number() }),
},
({ a, b }) => ({ content: [{ type: 'text', text: String(a + b) }] })
)
return server
})
// Register a simple addition tool
server.registerTool(
'add',
{
title: 'Addition Tool',
description: 'Add two numbers together',
inputSchema: { a: z.number(), b: z.number() },
},
({ a, b }) => ({
content: [{ type: 'text', text: String(a + b) }],
})
)
// Handle MCP requests at the root path
app.all('/', async (c) => {
const transport = new StreamableHTTPTransport()
await server.connect(transport)
return transport.handleRequest(c)
})
// Public endpoint, so deploy with verify_jwt = false.
export default {
fetch: withSupabase({ auth: 'none' }, app.fetch),
}
Deno.serve((req) => handler.fetch(req))
@@ -0,0 +1,16 @@
-- Table for the authenticated-mcp-server example. RLS scopes every row to its owner,
-- so the MCP tools need no per-tool authorization code.
create table public.todos (
id uuid primary key default gen_random_uuid(),
user_id uuid not null default auth.uid() references auth.users (id) on delete cascade,
title text not null,
done boolean not null default false,
created_at timestamptz not null default now()
);
alter table public.todos enable row level security;
create policy "Users manage their own todos"
on public.todos for all to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);