docs: add MCP server guide and surface the OAuth entry point in the skill (#154)

* docs: add MCP server guide and surface the OAuth entry point in the skill

docs/mcp.md explains the pipeline form for MCP servers
(withOAuthProtectedResource before the withSupabase gate), how the
public URLs are derived on Edge Functions and configured elsewhere, the
Supabase Auth prerequisites, the inline-handler inference gotcha, and the
stateless limits.

The agent skill gains the @supabase/server/oauth-protected-resource entry
point and a docs-table row pointing at the new page.

docs(mcp): drop the private sandbox link, match the guide title

docs(mcp): address review on the MCP guide

- CLI 2.117.0 injects SUPABASE_FUNCTION_SLUG only; SUPABASE_PUBLIC_URL is
  honored when set but not set by the CLI.
- The asymmetric-key requirement comes from withSupabase verifying user
  JWTs against the JWKS, not from OAuth 2.1.
- Document OAuthProtectedResourceConfig, UrlOption, fromSupabaseUrl and the
  escape hatches in api-reference.md, and link there.
- Show withSupabase<Database> and a writing tool, the onerror hook for
  factory errors, the OPTIONS preflight row, the root-path-without-slug
  case, and that the one-shot limits belong to the handler, not the library.

* docs: list mcp.md in README and complete the metadata row

---------

Co-authored-by: Katerina Skroumpelou <sk.katherine@gmail.com>
This commit is contained in:
Raúl Barroso
2026-09-11 17:06:18 +02:00
committed by GitHub
parent 0be895115a
commit 06da558971
4 changed files with 227 additions and 52 deletions
+34 -33
View File
@@ -531,42 +531,43 @@ No. `@supabase/ssr` handles cookie-based session management for frameworks like
## Exports
| Export | What's in it |
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `@supabase/server` | `withSupabase`, `createSupabaseContext` |
| `@supabase/server/core` | `verifyAuth`, `verifyCredentials`, `extractCredentials`, `createContextClient`, `createAdminClient`, `resolveEnv` |
| `@supabase/server/adapters/hono` | `withSupabase` (Hono middleware) |
| `@supabase/server/adapters/h3` | `withSupabase` (H3 / Nuxt middleware) |
| `@supabase/server/adapters/elysia` | `withSupabase` (Elysia plugin) |
| `@supabase/server/adapters/nestjs` | `withSupabase` (NestJS guard), `SupabaseCtx` (param decorator) |
| `@supabase/server/middleware/client` | **Alpha.** `withSupabaseClient` (RLS-scoped `ctx.supabase` client) |
| `@supabase/server/middleware/admin-client` | **Alpha.** `withSupabaseAdminClient` (`ctx.supabaseAdmin`, bypasses RLS) |
| `@supabase/server/middleware/claims` | **Alpha.** `withClaims` (JWKS-verified `ctx.jwtClaims`) |
| `@supabase/server/middleware/required-claims` | **Alpha.** `withRequiredClaims` (user-mode auth gate, non-null `ctx.jwtClaims`) |
| `@supabase/server/middleware/postgres` | **Alpha.** `withPostgresClient` (RLS-scoped `ctx.postgres` client) |
| `@supabase/server/middleware/postgres-admin` | **Alpha.** `withPostgresAdminClient` (`ctx.postgresAdmin`, bypasses RLS) |
| `@supabase/server/oauth-protected-resource` | **Alpha.** `withOAuthProtectedResource`, `fromSupabaseUrl`, `resourceMetadataResponse`, `unauthorizedResponse` |
| `@supabase/server/peer/supabase-js` | Re-exported `supabase-js` types (`SupabaseClient`, `PostgrestError`, …) |
| Export | What's in it |
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `@supabase/server` | `withSupabase`, `createSupabaseContext` |
| `@supabase/server/core` | `verifyAuth`, `verifyCredentials`, `extractCredentials`, `createContextClient`, `createAdminClient`, `resolveEnv` |
| `@supabase/server/adapters/hono` | `withSupabase` (Hono middleware) |
| `@supabase/server/adapters/h3` | `withSupabase` (H3 / Nuxt middleware) |
| `@supabase/server/adapters/elysia` | `withSupabase` (Elysia plugin) |
| `@supabase/server/adapters/nestjs` | `withSupabase` (NestJS guard), `SupabaseCtx` (param decorator) |
| `@supabase/server/middleware/client` | **Alpha.** `withSupabaseClient` (RLS-scoped `ctx.supabase` client) |
| `@supabase/server/middleware/admin-client` | **Alpha.** `withSupabaseAdminClient` (`ctx.supabaseAdmin`, bypasses RLS) |
| `@supabase/server/middleware/claims` | **Alpha.** `withClaims` (JWKS-verified `ctx.jwtClaims`) |
| `@supabase/server/middleware/required-claims` | **Alpha.** `withRequiredClaims` (user-mode auth gate, non-null `ctx.jwtClaims`) |
| `@supabase/server/middleware/postgres` | **Alpha.** `withPostgresClient` (RLS-scoped `ctx.postgres` client) |
| `@supabase/server/middleware/postgres-admin` | **Alpha.** `withPostgresAdminClient` (`ctx.postgresAdmin`, bypasses RLS) |
| `@supabase/server/oauth-protected-resource` | **Alpha.** `withOAuthProtectedResource`, `fromSupabaseUrl`, `resourceMetadataResponse`, `unauthorizedResponse`; see [`docs/mcp.md`](docs/mcp.md) |
| `@supabase/server/peer/supabase-js` | Re-exported `supabase-js` types (`SupabaseClient`, `PostgrestError`, …) |
## Documentation
| Question | Doc file |
| ------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| How do I create a basic endpoint? | [`docs/getting-started.md`](docs/getting-started.md) |
| What auth modes are available? Array syntax? Named keys? | [`docs/auth-modes.md`](docs/auth-modes.md) |
| Which framework adapters exist? How do I contribute one? | [`src/adapters/README.md`](src/adapters/README.md) |
| How do I use this with Hono? | [`docs/adapters/hono.md`](docs/adapters/hono.md) |
| How do I use this with H3 / Nuxt? | [`docs/adapters/h3.md`](docs/adapters/h3.md) |
| How do I use this with Elysia? | [`docs/adapters/elysia.md`](docs/adapters/elysia.md) |
| How do I use this with NestJS? | [`docs/adapters/nestjs.md`](docs/adapters/nestjs.md) |
| How do I use low-level primitives for custom flows? | [`docs/core-primitives.md`](docs/core-primitives.md) |
| How do environment variables work across runtimes? | [`docs/environment-variables.md`](docs/environment-variables.md) |
| How do I handle errors? What codes exist? | [`docs/error-handling.md`](docs/error-handling.md) |
| How do I get typed database queries? | [`docs/typescript-generics.md`](docs/typescript-generics.md) |
| How do I run raw SQL scoped to the caller by RLS? | [`docs/postgres.md`](docs/postgres.md) |
| How do I use this with `@supabase/ssr` (Next.js, SvelteKit, Remix)? | [`docs/ssr-frameworks.md`](docs/ssr-frameworks.md) |
| What's the complete API surface? | [`docs/api-reference.md`](docs/api-reference.md) |
| Does this library support legacy API keys or HS256 JWTs? | [`docs/auth-modes.md`](docs/auth-modes.md#legacy-keys-and-jwts-are-not-supported) |
| Question | Doc file |
| ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| How do I create a basic endpoint? | [`docs/getting-started.md`](docs/getting-started.md) |
| What auth modes are available? Array syntax? Named keys? | [`docs/auth-modes.md`](docs/auth-modes.md) |
| Which framework adapters exist? How do I contribute one? | [`src/adapters/README.md`](src/adapters/README.md) |
| How do I use this with Hono? | [`docs/adapters/hono.md`](docs/adapters/hono.md) |
| How do I use this with H3 / Nuxt? | [`docs/adapters/h3.md`](docs/adapters/h3.md) |
| How do I use this with Elysia? | [`docs/adapters/elysia.md`](docs/adapters/elysia.md) |
| How do I use this with NestJS? | [`docs/adapters/nestjs.md`](docs/adapters/nestjs.md) |
| How do I use low-level primitives for custom flows? | [`docs/core-primitives.md`](docs/core-primitives.md) |
| How do environment variables work across runtimes? | [`docs/environment-variables.md`](docs/environment-variables.md) |
| How do I handle errors? What codes exist? | [`docs/error-handling.md`](docs/error-handling.md) |
| How do I get typed database queries? | [`docs/typescript-generics.md`](docs/typescript-generics.md) |
| How do I run raw SQL scoped to the caller by RLS? | [`docs/postgres.md`](docs/postgres.md) |
| How do I use this with `@supabase/ssr` (Next.js, SvelteKit, Remix)? | [`docs/ssr-frameworks.md`](docs/ssr-frameworks.md) |
| How do I build an MCP server my users connect to (OAuth discovery, RLS-scoped tools)? | [`docs/mcp.md`](docs/mcp.md) |
| What's the complete API surface? | [`docs/api-reference.md`](docs/api-reference.md) |
| Does this library support legacy API keys or HS256 JWTs? | [`docs/auth-modes.md`](docs/auth-modes.md#legacy-keys-and-jwts-are-not-supported) |
## Development
+56
View File
@@ -443,6 +443,62 @@ Defaults to the `SUPABASE_DB_URL` environment variable.
---
## @supabase/server/oauth-protected-resource
> **Alpha.** The config shape, the contributed context key, and the metadata
> route may change in a minor release.
Also re-exported from `@supabase/server`. See [`docs/mcp.md`](mcp.md) for the MCP server walkthrough.
### withOAuthProtectedResource
```ts
function withOAuthProtectedResource(
config?: OAuthProtectedResourceConfig,
): Entry<{ oauthProtectedResource: OAuthProtectedResourceContribution }>
function withOAuthProtectedResource(handler: FetchHandler): FetchHandler
function withOAuthProtectedResource(
config: OAuthProtectedResourceConfig,
handler: FetchHandler,
): FetchHandler
```
OAuth 2.1 Protected Resource behavior (RFC 9728) for the wrapped handler. Answers `GET` and `OPTIONS` on any path ending in `/oauth-protected-resource` with the metadata document and a permissive CORS preflight; adds `WWW-Authenticate: Bearer resource_metadata="…"` to a `401` from below unless the handler already set that header; passes everything else through. Runs before the `withSupabase` gate; placing it directly after `withSupabase` with a credentialed auth mode is refused when the stack is built.
Contributes `ctx.oauthProtectedResource.resourceMetadataUrl`, the resolved absolute URL of the metadata document.
### OAuthProtectedResourceConfig
| Option | Type | Default on Supabase Edge Functions | Default elsewhere |
| --------------------- | ----------- | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `resourceServer` | `UrlOption` | Public origin from `X-Forwarded-*` (or `SUPABASE_PUBLIC_URL`) + `/functions/v1/{SUPABASE_FUNCTION_SLUG}` | None. Throws `MissingResourceServerError` (`MISSING_RESOURCE_SERVER`). |
| `authorizationServer` | `UrlOption` | Public origin + `/auth/v1` | `SUPABASE_PUBLIC_URL`, then `SUPABASE_URL`, each + `/auth/v1`. Throws `MissingAuthorizationServerError` (`MISSING_AUTHORIZATION_SERVER`) if neither is set. |
`UrlOption` is `string | ((req: Request) => string)`. Without `SUPABASE_FUNCTION_SLUG` the resource path is reconstructed from the request path with `/functions/v1` restored; a request at the root path with no slug throws `MissingResourceServerError`.
### fromSupabaseUrl
```ts
function fromSupabaseUrl(supabaseUrl: string): string
```
Turns a project URL (`https://<ref>.supabase.co`) into its Auth issuer (`…/auth/v1`) for `authorizationServer`. Tolerates a value that already carries the `/auth/v1` path.
### resourceMetadataResponse / unauthorizedResponse
```ts
function resourceMetadataResponse(
req: Request,
options?: { resource?: string; authorizationServers?: string[] },
): Response
function unauthorizedResponse(
req: Request,
options?: { resourceMetadataUrl?: string },
): Response
```
The building blocks behind the middleware, for custom routing. Defaults derive from the request as above.
## Types
### AuthMode
+116
View File
@@ -0,0 +1,116 @@
# MCP servers
> **Alpha.** `withOAuthProtectedResource` and composing `withSupabase` as a
> `pipeline` entry track `@supabase/middleware` 0.x. The config shape, the
> contributed context key, and the metadata route may change in a minor
> release. The nested `withOAuthProtectedResource(withSupabase(config, handler))`
> form is stable.
An MCP server your app exposes to its users is an HTTP endpoint that MCP clients (Claude, ChatGPT, Cursor, VS Code, Claude Code) call after an OAuth 2.1 flow. `@supabase/server` covers the two Supabase-specific parts: OAuth discovery for the client, and turning the user's token into an RLS-scoped Supabase client for your tools. The MCP transport and the tools come from any MCP library.
```ts
import { pipeline } from '@supabase/middleware'
import { withOAuthProtectedResource, withSupabase } from '@supabase/server'
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'
import { z } from 'zod/v4'
import type { Database } from './database.types'
export default {
fetch: pipeline(
[withOAuthProtectedResource(), withSupabase<Database>({ auth: 'user' })],
async (req, { supabase }) => {
const handler = createMcpHandler(
() => {
const server = new McpServer({ name: 'todos', version: '0.1.0' })
server.registerTool(
'create_todo',
{
description: 'Create a todo',
inputSchema: z.object({ title: z.string() }),
},
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
},
// Factory errors (duplicate tool name, failed schema fetch) surface as a
// bare 500 otherwise; log them so they reach the function logs.
{ onerror: (error) => console.error('MCP request failed', error) },
)
return handler.fetch(req)
},
),
}
```
Pass the `Database` generic (from `supabase gen types typescript`) when tools write. Without it the client is `SupabaseClient<unknown>` and `insert({ title })` fails type-checking with the argument resolved to `never[]`; reads compile either way.
## The two entries
Order matters.
`withOAuthProtectedResource()` runs **before** the auth gate, so it sees unauthenticated requests. It does two things:
| Request | Response |
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `GET {resource}/oauth-protected-resource` | RFC 9728 Protected Resource Metadata (`resource`, `authorization_servers`, `bearer_methods_supported`) |
| `OPTIONS {resource}/oauth-protected-resource` | `204` with permissive CORS headers, so browser-based clients can read the document cross-origin |
| Any response from below with status `401` | Adds `WWW-Authenticate: Bearer resource_metadata="…"` unless the handler already set one |
That header is how a client that hit a `401` finds the metadata, and through it the authorization server, without guessing URLs. It is generic OAuth middleware; the only MCP-specific detail is that the preflight allows the `mcp-protocol-version` request header.
`withSupabase({ auth: 'user' })` is the gate. Requests without a valid user JWT get a `401` (which the entry above enriches); requests with one reach the handler with `ctx.supabase` scoped to that user, so RLS applies to everything the tools do.
The handler is passed inline. Passing a separately declared function typed `(req: Request, ctx: SupabaseContext) => Promise<Response>` makes TypeScript collapse the inferred context to `object` (TS2345); wrap it as `(req, ctx) => handleMcp(req, ctx)` instead.
## URLs
On Supabase Edge Functions the metadata is derived with no configuration: the public origin from the gateway's `X-Forwarded-*` headers (`SUPABASE_PUBLIC_URL` wins when set, but the CLI does not set it), the path as `/functions/v1/{SUPABASE_FUNCTION_SLUG}`, and the issuer as `{origin}/auth/v1`. Supabase CLI 2.117.0 or later injects the slug locally. Without a slug the path is reconstructed from the request, which works for a function served at `/functions/v1/{name}`; a function served at the root path `/` with no slug throws `MISSING_RESOURCE_SERVER`, because there is no function segment to restore.
Anywhere else, a Next.js route handler, a Worker, a plain server, the app's origin is unrelated to the project's, so pass both URLs:
```ts
import { withOAuthProtectedResource, fromSupabaseUrl } from '@supabase/server'
withOAuthProtectedResource({
resourceServer: (req) => new URL(req.url).origin + '/api/mcp',
authorizationServer: fromSupabaseUrl('https://<ref>.supabase.co'),
})
```
- `resourceServer`: the public URL of this endpoint. Required off Edge Functions; `MissingResourceServerError` (`MISSING_RESOURCE_SERVER`) otherwise.
- `authorizationServer`: the OAuth issuer. Falls back to `SUPABASE_PUBLIC_URL`, then `SUPABASE_URL`, each with `/auth/v1`; `MissingAuthorizationServerError` (`MISSING_AUTHORIZATION_SERVER`) if neither is set. `fromSupabaseUrl(projectUrl)` builds it from a project URL. A non-Supabase OAuth 2.1 server (Clerk, WorkOS, Auth0) works too.
Both accept a string or `(req: Request) => string` (`UrlOption`). The full config:
| Option | Type | Default on Edge Functions | Default elsewhere |
| --------------------- | ----------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `resourceServer` | `UrlOption` | `{public origin}/functions/v1/{slug}` | none; throws `MissingResourceServerError` |
| `authorizationServer` | `UrlOption` | `{public origin}/auth/v1` | `SUPABASE_PUBLIC_URL`, then `SUPABASE_URL`, each `+ /auth/v1`; else throws `MissingAuthorizationServerError` |
The middleware contributes `ctx.oauthProtectedResource.resourceMetadataUrl`, the resolved metadata URL, to the downstream context.
## Supabase Auth prerequisites
The project must have the OAuth 2.1 server enabled, dynamic client registration on (MCP clients register themselves), an asymmetric signing key (ES256 or RS256), and a consent screen hosted in the app's frontend. The signing key requirement comes from this library, not from OAuth: `withSupabase({ auth: 'user' })` verifies user JWTs against the project JWKS and rejects legacy HS256 tokens (see [`docs/auth-modes.md`](auth-modes.md#legacy-keys-and-jwts-are-not-supported)). On Edge Functions set `verify_jwt = false` for the function in `config.toml`; the gate does the verification, and the gateway must let the unauthenticated discovery request through.
## Escape hatches
For custom routing, the pieces behind the middleware are exported on `@supabase/server/oauth-protected-resource`: `resourceMetadataResponse(req, options?)` returns the metadata document, `unauthorizedResponse(req, options?)` returns a `401` with the `WWW-Authenticate` header.
## Limits
The one-request, one-response shape above is the handler's, not the library's: building a `McpServer` per request with `createMcpHandler` gives Streamable HTTP in its simplest form, with no server-initiated stream, so no MCP sampling and no elicitations that need an open channel. `@supabase/server` does not constrain the transport; a stateful transport composes the same way.
## See also
- [Deploy MCP servers](https://supabase.com/docs/guides/ai-tools/byo-mcp) on supabase.com, the end-to-end guide
- [MCP Server block](https://supabase.com/library/docs/headless/mcp-server), an installable Edge Function built on this
- [`docs/api-reference.md`](api-reference.md#withoauthprotectedresource) for `OAuthProtectedResourceConfig` and the error catalogue
+21 -19
View File
@@ -34,11 +34,12 @@ Server-side utilities for Supabase. Handles auth, client creation, and context i
## Entry points
| Import | Deno / Edge Functions | Provides |
| -------------------------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
| `@supabase/server` | `npm:@supabase/server` | `withSupabase`, `createSupabaseContext`, types, errors |
| `@supabase/server/core` | `npm:@supabase/server/core` | `verifyAuth`, `verifyCredentials`, `extractCredentials`, `resolveEnv`, `createContextClient`, `createAdminClient` |
| `@supabase/server/adapters/hono` | `npm:@supabase/server/adapters/hono` | `withSupabase` (Hono middleware variant) |
| Import | Deno / Edge Functions | Provides |
| ------------------------------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `@supabase/server` | `npm:@supabase/server` | `withSupabase`, `createSupabaseContext`, types, errors |
| `@supabase/server/core` | `npm:@supabase/server/core` | `verifyAuth`, `verifyCredentials`, `extractCredentials`, `resolveEnv`, `createContextClient`, `createAdminClient` |
| `@supabase/server/adapters/hono` | `npm:@supabase/server/adapters/hono` | `withSupabase` (Hono middleware variant) |
| `@supabase/server/oauth-protected-resource` | `npm:@supabase/server/oauth-protected-resource` | **Alpha.** `withOAuthProtectedResource`, `fromSupabaseUrl`, `resourceMetadataResponse`, `unauthorizedResponse` — OAuth 2.1 discovery for MCP servers; see `docs/mcp.md` |
## Quick starts
@@ -413,17 +414,18 @@ The full documentation lives in the `docs/` directory of the `@supabase/server`
- **If working inside the SDK repo:** `docs/` is at the project root.
- **If the package is installed as a dependency:** look in `node_modules/@supabase/server/docs/`.
| Question | Doc file |
| ------------------------------------------------------------------- | ------------------------------- |
| How do I create a basic endpoint? | `docs/getting-started.md` |
| What auth modes are available? Array syntax? Named keys? | `docs/auth-modes.md` |
| Which framework adapters exist? How do I contribute one? | `src/adapters/README.md` |
| How do I use this with Hono? | `docs/adapters/hono.md` |
| How do I use this with H3 / Nuxt? | `docs/adapters/h3.md` |
| How do I use low-level primitives for custom flows? | `docs/core-primitives.md` |
| How do environment variables work across runtimes? | `docs/environment-variables.md` |
| How do I handle errors? What codes exist? | `docs/error-handling.md` |
| How do I get typed database queries? | `docs/typescript-generics.md` |
| How do I use this with `@supabase/ssr` (Next.js, SvelteKit, Remix)? | `docs/ssr-frameworks.md` |
| What's the complete API surface? | `docs/api-reference.md` |
| What security decisions does this package make? | `docs/security.md` |
| Question | Doc file |
| ------------------------------------------------------------------------------------- | ------------------------------- |
| How do I create a basic endpoint? | `docs/getting-started.md` |
| What auth modes are available? Array syntax? Named keys? | `docs/auth-modes.md` |
| Which framework adapters exist? How do I contribute one? | `src/adapters/README.md` |
| How do I use this with Hono? | `docs/adapters/hono.md` |
| How do I use this with H3 / Nuxt? | `docs/adapters/h3.md` |
| How do I use low-level primitives for custom flows? | `docs/core-primitives.md` |
| How do environment variables work across runtimes? | `docs/environment-variables.md` |
| How do I handle errors? What codes exist? | `docs/error-handling.md` |
| How do I get typed database queries? | `docs/typescript-generics.md` |
| How do I use this with `@supabase/ssr` (Next.js, SvelteKit, Remix)? | `docs/ssr-frameworks.md` |
| How do I build an MCP server my users connect to (OAuth discovery, RLS-scoped tools)? | `docs/mcp.md` |
| What's the complete API surface? | `docs/api-reference.md` |
| What security decisions does this package make? | `docs/security.md` |