Docs
MCP Server

MCP Server

Add a user-scoped MCP server to your product

Give embedded product agents and external clients such as Codex, Claude Code, and ChatGPT secure, user-scoped access to your product through MCP tools. This block runs as a Supabase Edge Function, verifies Supabase user access tokens, and gives every tool an RLS-scoped client.

Installation

Installs Deno Edge Function files into a Supabase project or empty directory. No components.json is required.

Folder structure

  • supabase
    • functions
      • mcp-server
        • tools
# Copy this file to supabase/functions/.env before serving locally:
#   cp supabase/functions/mcp-server/.env.example supabase/functions/.env
#   supabase functions serve mcp-server --env-file supabase/functions/.env

# Keep the protocol-level server name short and project-specific.
MCP_SERVER_NAME=supabase-mcp
MCP_SERVER_DESCRIPTION="MCP access to this Supabase project for the signed-in user."

Configure the project

The function verifies access tokens itself, so disable the gateway JWT check:

[functions.mcp-server]
verify_jwt = false

The project must sign JWTs with an asymmetric key. Projects that still use the legacy HS256 secret do not expose signing keys from the JWKS endpoint, so the function cannot authenticate embedded product sessions or external MCP clients. Switch to an ES256 or RS256 key in JWT Keys.

Choose how agents authenticate

Embedded product agents

A trusted product backend can forward its signed-in user's Supabase access token as Authorization: Bearer <token>. This reuses the product session, so the user does not need to authorize their own product again.

Keep the token inside your backend or agent orchestrator. Never place it in a prompt or expose it directly to a model provider.

External MCP clients

External clients authenticate with OAuth, so users approve and revoke each client separately. Install the OAuth Consent block, then enable OAuth in supabase/config.toml:

[auth.oauth_server]
enabled = true
authorization_url_path = "/oauth/consent"
allow_dynamic_registration = true

Set the Auth Site URL to the origin that serves /oauth/consent. Use HTTPS in production. Run supabase config push or restart the local stack to apply the change.

allow_dynamic_registration lets any compatible client register itself. Set it to false if you register clients yourself.

Authentication

withOAuthProtectedResource serves RFC 9728 metadata at /functions/v1/mcp-server/oauth-protected-resource and adds a WWW-Authenticate challenge to 401 responses so MCP clients can discover the authorization server.

withSupabase({ auth: 'user' }) verifies the JWT and provides an RLS-scoped client. It accepts both product session tokens and OAuth access tokens. OAuth tokens include client_id; ordinary product sessions do not. The included whoami tool exposes that difference.

Any holder of a valid user token can call this function directly. Treat its tools as an authenticated product API: keep RLS enabled, check authorization for business operations, and do not add admin clients to the shared tool context.

OAuth scopes control identity, not database or tool access. Use client_id for client-specific policies when it is present, and define the intended behavior for product sessions where it is null. Never use user-editable metadata for authorization decisions.

Add tools

Each tool module exports one registration function:

// supabase/functions/mcp-server/tools/tasks.ts
import type { McpServer } from 'npm:@modelcontextprotocol/server@2.0.0'
import { z } from 'npm:zod@4.4.3'
 
import { jsonResult, runtimeErrorResult } from './result.ts'
import type { ToolContext } from './types.ts'
 
export function registerTasksTools(server: McpServer, { supabase }: ToolContext): void {
  server.registerTool(
    'close_task',
    {
      description: 'Mark a task as closed.',
      inputSchema: z.object({ id: z.string().uuid() }),
      annotations: { readOnlyHint: false, idempotentHint: true },
    },
    async ({ id }) => {
      try {
        const { data, error } = await supabase
          .from('tasks')
          .update({ closed: true })
          .eq('id', id)
          .select()
        if (error) throw error
        return jsonResult(data)
      } catch (error) {
        return runtimeErrorResult(error)
      }
    }
  )
}

Then add one call in tools/index.ts, the server's composition point:

import type { McpServer } from 'npm:@modelcontextprotocol/server@2.0.0'
 
import { registerTasksTools } from './tasks.ts'
import type { ToolContext } from './types.ts'
import { registerWhoamiTool } from './whoami.ts'
 
export function registerTools(server: McpServer, context: ToolContext): void {
  registerWhoamiTool(server, context)
  registerTasksTools(server, context)
}

Each registration function receives:

  • supabase, a user-scoped client for Database, Auth, Storage, and Functions
  • userClaims, the normalized signed-in user identity
  • jwtClaims, including client_id when the caller used OAuth

The context deliberately excludes supabaseAdmin. The MCP SDK rejects duplicate tool names, and jsonResult returns both structured data and a text fallback for older clients.

For typed table and column autocomplete, generate database.types.ts and make the SupabaseClient in tools/types.ts a SupabaseClient<Database>.

Environment

VariableDefaultPurpose
MCP_SERVER_NAMEsupabase-mcpServer name shown to MCP clients
MCP_SERVER_DESCRIPTIONGeneric sentenceInstructions shown to clients

Deploy

Check the function before serving or deploying it:

cd supabase/functions/mcp-server
deno task check
cd ../../..
supabase functions serve mcp-server --env-file supabase/functions/.env

Then deploy:

supabase config push
supabase functions deploy mcp-server

Further reading