shadimage
supabase
blockheadless-app-tanstacksource: ok · hit

Headless App for TanStack Start

A backend with an agent as the primary interface, combining auth, OAuth consent, and an MCP server.

npx shadcn add "https://supabase.com/ui/r/headless-app-tanstack.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
@supabase/ssr@latest@supabase/supabase-js@latest

Registry dependencies

buttonhttps://supabase.com/library/r/password-based-auth-tanstack.jsonhttps://supabase.com/library/r/oauth-consent-tanstack.json

Files16 · source cached 10/9/2026, 12:05:06 AM

registry/default/blocks/headless-app-tanstack/supabase/functions/mcp/tools/index.ts
import type { McpServer } from 'npm:@modelcontextprotocol/server@2.0.0'

import { registerTaskTools } from './tasks.ts'
import type { ToolContext } from './types.ts'
import { registerWhoamiTool } from './whoami.ts'

export type { ToolContext } from './types.ts'

// Add your product's tool modules here. The shared MCP runtime supplies the
// authenticated context for each request.
export function registerTools(server: McpServer, context: ToolContext): void {
  registerWhoamiTool(server, context)
  registerTaskTools(server, context)
}
registry/default/blocks/headless-app-tanstack/supabase/functions/mcp/tools/tasks.ts
import type { McpServer } from 'npm:@modelcontextprotocol/server@2.0.0'
import { z } from 'npm:zod@4.4.3'

import { errorResult, jsonResult, runtimeErrorResult } from './result.ts'
import type { ToolContext } from './types.ts'

const taskFields = 'id, title, closed, created_at'
const taskId = z.uuid().describe('The task ID returned by list_tasks or create_task.')
const taskTitle = z
  .string()
  .trim()
  .min(1)
  .max(200)
  .describe('A task title, 1–200 characters after trimming surrounding whitespace.')
const taskNotFound = 'Task not found or you do not have access.'

// Use only the caller's client. Ownership comes from auth.uid() in the schema,
// and RLS applies to reads and writes, including queries by a supplied task ID.
export function registerTaskTools(server: McpServer, { supabase }: ToolContext): void {
  server.registerTool(
    'list_tasks',
    {
      description:
        'List your tasks, newest first. Optionally filter by closed status. Pass next_offset as offset to fetch another page; null means there are no more tasks.',
      inputSchema: z.strictObject({
        closed: z.boolean().optional().describe('False for open tasks, true for closed tasks.'),
        limit: z.int().min(1).max(100).default(20).describe('Maximum tasks per page (1–100).'),
        offset: z.int().min(0).default(0).describe('Number of tasks to skip.'),
      }),
      annotations: {
        readOnlyHint: true,
        destructiveHint: false,
        idempotentHint: true,
        openWorldHint: false,
      },
    },
    async ({ closed, limit, offset }) => {
      try {
        let query = supabase
          .from('tasks')
          .select(taskFields)
          .order('created_at', { ascending: false })
          .order('id', { ascending: false })
          .range(offset, offset + limit)

        if (closed !== undefined) query = query.eq('closed', closed)

        const { data } = await query.throwOnError()
        return jsonResult({
          tasks: data.slice(0, limit),
          next_offset: data.length > limit ? offset + limit : null,
        })
      } catch (error) {
        return runtimeErrorResult(error)
      }
    }
  )

  server.registerTool(
    'create_task',
    {
      description:
        'Create an open task for yourself and return it. Each call creates a new task; do not retry blindly after a connection failure.',
      inputSchema: z.strictObject({ title: taskTitle }),
      annotations: {
        readOnlyHint: false,
        destructiveHint: false,
        idempotentHint: false,
        openWorldHint: false,
      },
    },
    async ({ title }) => {
      try {
        const { data } = await supabase
          .from('tasks')
          .insert({ title })
          .select(taskFields)
          .single()
          .throwOnError()

        return jsonResult({ task: data })
      } catch (error) {
        return runtimeErrorResult(error)
      }
    }
  )

  server.registerTool(
    'update_task',
    {
      description:
        'Rename, close, or reopen one of your tasks and return it. Supply title, closed, or both. Fields you omit keep their current values.',
      inputSchema: z
        .strictObject({
          id: taskId,
          title: taskTitle.optional(),
          closed: z.boolean().optional().describe('True to close the task; false to reopen it.'),
        })
        .refine(({ title, closed }) => title !== undefined || closed !== undefined, {
          message: 'Supply title or closed to update a task.',
        }),
      annotations: {
        readOnlyHint: false,
        destructiveHint: true,
        idempotentHint: true,
        openWorldHint: false,
      },
    },
    async ({ id, title, closed }) => {
      try {
        const changes = {
          ...(title !== undefined ? { title } : {}),
          ...(closed !== undefined ? { closed } : {}),
        }
        const { data } = await supabase
          .from('tasks')
          .update(changes)
          .eq('id', id)
          .select(taskFields)
          .maybeSingle()
          .throwOnError()

        return data ? jsonResult({ task: data }) : errorResult(taskNotFound)
      } catch (error) {
        return runtimeErrorResult(error)
      }
    }
  )

  server.registerTool(
    'delete_task',
    {
      description:
        'Permanently delete one of your tasks by ID. Use update_task with closed: true to keep a completed task instead.',
      inputSchema: z.strictObject({ id: taskId }),
      annotations: {
        readOnlyHint: false,
        destructiveHint: true,
        idempotentHint: true,
        openWorldHint: false,
      },
    },
    async ({ id }) => {
      try {
        const { data } = await supabase
          .from('tasks')
          .delete()
          .eq('id', id)
          .select('id')
          .maybeSingle()
          .throwOnError()

        return data ? jsonResult({ deleted: true, id: data.id }) : errorResult(taskNotFound)
      } catch (error) {
        return runtimeErrorResult(error)
      }
    }
  )
}
registry/default/blocks/headless-app-tanstack/routes/_protected/agents.tsx
/// <reference types="vite/types/importMeta.d.ts" />
import { createFileRoute } from '@tanstack/react-router'

import { ConnectedAgents } from '@/registry/default/blocks/headless-app-tanstack/components/connected-agents'

const PRODUCT_NAME = import.meta.env.VITE_PRODUCT_NAME?.trim() || 'Your product'
const MCP_SERVER_URL = `${import.meta.env.VITE_SUPABASE_URL}/functions/v1/mcp`

// Nested under _protected, so the layout route redirects signed-out visitors
// before this renders.
export const Route = createFileRoute('/_protected/agents')({
  component: AgentsPage,
})

function AgentsPage() {
  return (
    <main className="flex min-h-svh items-center justify-center p-6 md:p-10">
      <ConnectedAgents
        className="w-full max-w-lg"
        mcpServerUrl={MCP_SERVER_URL}
        productName={PRODUCT_NAME}
      />
    </main>
  )
}
registry/default/blocks/headless-app-tanstack/components/connected-agents.tsx
import { Check, Copy } from 'lucide-react'
import { useEffect, useState } from 'react'

import { cn } from '@/lib/utils'
import {
  useOAuthGrants,
  type OAuthGrant,
} from '@/registry/default/blocks/headless-app-tanstack/hooks/use-oauth-grants'
import { Button } from '@/registry/default/components/ui/button'

const buildPrompt = (productName: string, mcpServerUrl: string) =>
  `Connect to ${productName} using this MCP server:\n\n${mcpServerUrl}\n\nUse your MCP connection setup to authorize access in my browser. Then call whoami to verify the connection and list the available tools.`

const formatDate = (value: string) =>
  new Date(value).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })

function ConnectAgent({
  productName,
  mcpServerUrl,
}: {
  productName: string
  mcpServerUrl: string
}) {
  const [copied, setCopied] = useState<'url' | 'prompt' | null>(null)
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    if (!copied) return
    const timeout = window.setTimeout(() => setCopied(null), 2000)
    return () => window.clearTimeout(timeout)
  }, [copied])

  const copy = async (type: 'url' | 'prompt') => {
    setError(null)
    try {
      await navigator.clipboard.writeText(
        type === 'url' ? mcpServerUrl : buildPrompt(productName, mcpServerUrl)
      )
      setCopied(type)
    } catch {
      setCopied(null)
      setError('Unable to copy. Select and copy the server URL above.')
    }
  }

  return (
    <section className="flex flex-col gap-3" aria-label="Connect an agent">
      <h2 className="font-medium">Connect an agent</h2>
      <p className="text-sm text-muted-foreground">
        Add this server URL in your agent’s MCP settings, then sign in and approve access.
      </p>
      <div className="overflow-hidden rounded-lg border bg-muted">
        <pre className="overflow-x-auto whitespace-pre-wrap break-all p-4 font-mono text-sm">
          {mcpServerUrl}
        </pre>
        <div className="flex flex-wrap justify-end gap-2 border-t p-2">
          <Button type="button" size="sm" variant="outline" onClick={() => void copy('prompt')}>
            {copied === 'prompt' ? 'Prompt copied' : 'Copy prompt'}
          </Button>
          <Button type="button" size="sm" variant="outline" onClick={() => void copy('url')}>
            {copied === 'url' ? <Check className="size-4" /> : <Copy className="size-4" />}
            {copied === 'url' ? 'URL copied' : 'Copy URL'}
          </Button>
        </div>
      </div>
      <p className="text-xs text-muted-foreground">
        Use the prompt if your agent supports adding MCP servers through chat.
      </p>
      {/* Always mounted so screen readers announce the change. */}
      <span role="status" className="sr-only">
        {copied ? 'Copied to clipboard' : ''}
      </span>
      {error && (
        <p role="alert" className="text-sm text-destructive">
          {error}
        </p>
      )}
    </section>
  )
}

function GrantRow({
  grant,
  isRevoking,
  disabled,
  onRevoke,
}: {
  grant: OAuthGrant
  isRevoking: boolean
  disabled: boolean
  onRevoke: () => void
}) {
  return (
    <li className="flex items-center gap-4 p-4">
      <div className="flex min-w-0 flex-1 flex-col gap-1">
        <span className="truncate font-medium">{grant.client.name}</span>
        <span className="truncate text-muted-foreground">
          Authorized {formatDate(grant.granted_at)}
          {grant.scopes.length > 0 && ` · ${grant.scopes.join(', ')}`}
        </span>
      </div>
      <Button type="button" size="sm" variant="outline" disabled={disabled} onClick={onRevoke}>
        {isRevoking ? 'Revoking access...' : 'Revoke access'}
      </Button>
    </li>
  )
}

export interface ConnectedAgentsViewProps extends React.ComponentPropsWithoutRef<'div'> {
  mcpServerUrl: string
  productName?: string
  grants?: OAuthGrant[] | null
  isLoading?: boolean
  error?: string | null
  revokingClientId?: string | null
  onRefresh?: () => void
  onRevoke?: (clientId: string) => void
}

export function ConnectedAgentsView({
  mcpServerUrl,
  productName = 'this app',
  grants = null,
  isLoading = false,
  error = null,
  revokingClientId = null,
  onRefresh,
  onRevoke,
  className,
  ...props
}: ConnectedAgentsViewProps) {
  return (
    <div className={cn('flex flex-col gap-6', className)} {...props}>
      <div className="flex flex-col space-y-1.5">
        <h1 className="text-2xl font-semibold leading-none tracking-tight">Connected agents</h1>
        <p className="text-sm text-muted-foreground">
          Authorize agents to use {productName} on your behalf.
        </p>
      </div>

      <ConnectAgent productName={productName} mcpServerUrl={mcpServerUrl} />

      <section className="flex flex-col gap-3" aria-label="Authorized agents">
        <div className="flex items-center justify-between gap-4">
          <h2 className="font-medium">Authorized agents</h2>
          {onRefresh && (
            <Button
              type="button"
              size="sm"
              variant="outline"
              disabled={isLoading || revokingClientId !== null}
              onClick={onRefresh}
            >
              {isLoading ? 'Refreshing...' : 'Refresh'}
            </Button>
          )}
        </div>
        {/* Always mounted so screen readers announce the change. */}
        <p role="status" className="text-sm text-muted-foreground">
          {isLoading ? 'Loading connected agents...' : ''}
        </p>
        {grants && grants.length > 0 && (
          <ul className="divide-y rounded-lg border bg-muted text-sm">
            {grants.map((grant) => (
              <GrantRow
                key={grant.client.id}
                grant={grant}
                isRevoking={revokingClientId === grant.client.id}
                disabled={isLoading || revokingClientId !== null || !onRevoke}
                onRevoke={() => onRevoke?.(grant.client.id)}
              />
            ))}
          </ul>
        )}
        {!isLoading && !error && grants?.length === 0 && (
          <p className="text-sm text-muted-foreground">
            No agents authorized yet. Connect an agent using the server URL above.
          </p>
        )}
        {error && (
          <p role="alert" className="text-sm text-destructive">
            {error}
          </p>
        )}
        {grants && grants.length > 0 && (
          <p className="text-xs text-muted-foreground">
            Revoking access prevents an agent from renewing its session. Its current access token
            may work until it expires.
          </p>
        )}
      </section>
    </div>
  )
}

interface ConnectedAgentsProps extends React.ComponentPropsWithoutRef<'div'> {
  mcpServerUrl: string
  productName?: string
}

export function ConnectedAgents(props: ConnectedAgentsProps) {
  const { grants, error, isLoading, revokingClientId, refresh, revoke } = useOAuthGrants()

  return (
    <ConnectedAgentsView
      grants={grants}
      error={error}
      isLoading={isLoading}
      revokingClientId={revokingClientId}
      onRefresh={() => void refresh()}
      onRevoke={(clientId) => void revoke(clientId)}
      {...props}
    />
  )
}
registry/default/blocks/headless-app-tanstack/hooks/use-oauth-grants.ts
import type { OAuthGrant } from '@supabase/supabase-js'
import { useCallback, useEffect, useRef, useState } from 'react'

import { createClient } from '@/registry/default/clients/tanstack/lib/supabase/client'

// Revocation invalidates refresh tokens. Already-issued access tokens can still
// be accepted by the MCP server until they expire.
const useOAuthGrants = () => {
  const [grants, setGrants] = useState<OAuthGrant[] | null>(null)
  const [error, setError] = useState<string | null>(null)
  const [isLoading, setIsLoading] = useState(true)
  const [revokingClientId, setRevokingClientId] = useState<string | null>(null)
  const mounted = useRef(false)
  const requestId = useRef(0)
  const isRevoking = useRef(false)

  const refresh = useCallback(async () => {
    if (!mounted.current || isRevoking.current) return
    const id = ++requestId.current
    setIsLoading(true)
    setError(null)

    try {
      const { data, error } = await createClient().auth.oauth.listGrants()
      if (error) throw error
      if (mounted.current && id === requestId.current) setGrants(data)
    } catch (error) {
      if (mounted.current && id === requestId.current) {
        setError(
          `Unable to load connected agents. ${error instanceof Error ? error.message : 'Try refreshing the list.'}`
        )
      }
    } finally {
      if (mounted.current && id === requestId.current) setIsLoading(false)
    }
  }, [])

  useEffect(() => {
    mounted.current = true
    const onFocus = () => void refresh()
    void refresh()
    window.addEventListener('focus', onFocus)
    return () => {
      mounted.current = false
      window.removeEventListener('focus', onFocus)
    }
  }, [refresh])

  const revoke = useCallback(async (clientId: string) => {
    if (!mounted.current || isRevoking.current) return
    isRevoking.current = true
    // A list request started before revocation must not restore the removed grant.
    const id = ++requestId.current
    setIsLoading(false)
    setRevokingClientId(clientId)
    setError(null)

    try {
      const { error } = await createClient().auth.oauth.revokeGrant({ clientId })
      if (error) throw error
      if (mounted.current && id === requestId.current) {
        setGrants((current) => current?.filter((grant) => grant.client.id !== clientId) ?? null)
      }
    } catch (error) {
      if (mounted.current && id === requestId.current) {
        setError(
          `Unable to revoke access. ${error instanceof Error ? error.message : 'Try revoking access again.'}`
        )
      }
    } finally {
      isRevoking.current = false
      if (mounted.current && id === requestId.current) setRevokingClientId(null)
    }
  }, [])

  return { grants, error, isLoading, revokingClientId, refresh, revoke }
}

type UseOAuthGrantsReturn = ReturnType<typeof useOAuthGrants>

export { useOAuthGrants, type OAuthGrant, type UseOAuthGrantsReturn }
registry/default/blocks/headless-app-tanstack/supabase/config.toml
# Supabase configuration for a headless app: password auth for the product
# session, an OAuth 2.1 server for external agents, and the MCP server that both
# call. Values below are for local development. Set production Auth URLs and
# enable email confirmations before running `supabase config push`.

project_id = "headless-app"

[api]
enabled = true
port = 54321
schemas = ["public", "graphql_public"]
extra_search_path = ["public", "extensions"]
max_rows = 1000

[db]
port = 54322
# Used to diff ./schemas into a migration.
shadow_port = 54320
major_version = 17

# One directory per schema, then one file per object, in dependency order.
[db.migrations]
schema_paths = [
    "./schemas/*/tables/*.sql",
    "./schemas/*/views/*.sql",
    "./schemas/*/functions/*.sql",
]

[studio]
enabled = true
port = 54323

[inbucket]
enabled = true
port = 54324

[auth]
enabled = true
# Origin that serves this app, including /oauth/consent. Use HTTPS in production.
site_url = "http://localhost:3000"
additional_redirect_urls = ["http://localhost:3000/**"]
jwt_expiry = 3600
enable_signup = true

[auth.email]
enable_signup = true
# Confirmations are off so local sign-ups can reach the app immediately.
enable_confirmations = false

# External MCP clients authorize here. Auth sends the user to
# authorization_url_path, which the OAuth Consent block serves.
[auth.oauth_server]
enabled = true
authorization_url_path = "/oauth/consent"
# Lets any compatible client register itself. Set to false to register clients
# yourself.
allow_dynamic_registration = true

# The MCP server verifies user access tokens itself, so skip the gateway check.
[functions.mcp]
verify_jwt = false
registry/default/blocks/headless-app-tanstack/supabase/schemas/public/tables/tasks.sql
-- Declarative schema. This file is the source of truth for the tasks table:
-- edit it, then run `supabase db diff -f <name>` to generate the migration.
-- Changes made in Studio or the SQL editor are not picked up by the diff.

create table public.tasks (
  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 constraint tasks_title_length check (
    char_length(title) between 1 and 200 and title ~ '[^[:space:]]'
  ),
  closed boolean not null default false,
  created_at timestamptz not null default now()
);

-- Support the ownership policies and the tools' newest-first ordering.
create index tasks_user_id_created_at_id_idx on public.tasks (user_id, created_at desc, id desc);

-- Agents call the MCP server with the user's access token, so every tool runs
-- under these policies. A tool cannot reach another user's rows.
alter table public.tasks enable row level security;

-- Expose task operations to signed-in users even when automatic API grants are disabled.
grant select, insert, update, delete on table public.tasks to authenticated;

create policy "Users can view their own tasks"
on public.tasks
for select
to authenticated
using ((select auth.uid()) = user_id);

create policy "Users can create their own tasks"
on public.tasks
for insert
to authenticated
with check ((select auth.uid()) = user_id);

create policy "Users can update their own tasks"
on public.tasks
for update
to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);

create policy "Users can delete their own tasks"
on public.tasks
for delete
to authenticated
using ((select auth.uid()) = user_id);
registry/default/blocks/mcp/supabase/functions/mcp/index.ts
import 'jsr:@supabase/functions-js@2.108.2/edge-runtime.d.ts'

import { createMcpHandler, McpServer } from 'npm:@modelcontextprotocol/server@2.0.0'
import { pipeline } from 'npm:@supabase/middleware@1'
import {
  withOAuthProtectedResource,
  withSupabase,
  type SupabaseContext,
} from 'npm:@supabase/server@1'

import { registerTools, type ToolContext } from './tools/index.ts'

// An MCP server as a single Supabase Edge Function, composed as a pipeline:
//
//   withOAuthProtectedResource  OAuth discovery for external MCP clients. Runs
//                               before the auth gate so unauthenticated clients
//                               can fetch the RFC 9728 metadata, and adds the
//                               WWW-Authenticate challenge to the gate's 401.
//   withSupabase                Verifies the user access token and builds an
//                               RLS-scoped client, so both embedded product
//                               agents and external OAuth clients act as the
//                               signed-in user.
//   handleMcp                   MCP transport and tools (./tools/index.ts).
//
// On Supabase Edge Functions the public URLs in the OAuth metadata are derived
// automatically, locally and hosted. Off Edge Functions, pass `resourceServer`
// and `authorizationServer` to withOAuthProtectedResource.

function readTextEnv(name: string, fallback: string): string {
  return Deno.env.get(name)?.trim() || fallback
}

const SERVER_NAME = readTextEnv('MCP_SERVER_NAME', 'supabase-mcp')
const SERVER_DESCRIPTION = readTextEnv(
  'MCP_SERVER_DESCRIPTION',
  'MCP access to this Supabase project for the signed-in user.'
)

const SERVER_INSTRUCTIONS =
  `${SERVER_DESCRIPTION} ` +
  'Every tool runs as the signed-in Supabase user, so role grants and Row Level Security apply. ' +
  "Call tools/list to discover what this project exposes, and read a tool's description and " +
  'annotations before calling it — some tools have side effects.'

const CORS_HEADERS: Record<string, string> = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
  'Access-Control-Allow-Headers':
    'Authorization, Content-Type, Accept, Mcp-Protocol-Version, Mcp-Session-Id, Mcp-Method, Mcp-Name',
  'Access-Control-Expose-Headers': 'WWW-Authenticate, Mcp-Session-Id',
}

function createServer(context: ToolContext): McpServer {
  const server = new McpServer(
    { name: SERVER_NAME, version: '1.0.0' },
    { instructions: SERVER_INSTRUCTIONS }
  )

  registerTools(server, context)
  return server
}

async function handleMcp(request: Request, ctx: SupabaseContext): Promise<Response> {
  // The server and its tools are bound to this caller for exactly one request.
  const handler = createMcpHandler(
    () =>
      createServer({
        supabase: ctx.supabase,
        // auth: 'user' guarantees both claim shapes before this handler runs.
        userClaims: ctx.userClaims!,
        jwtClaims: ctx.jwtClaims!,
      }),
    { onerror: (error) => console.error('MCP request failed', error) }
  )

  return handler.fetch(request)
}

// The handler is passed inline so TypeScript infers its context from the entries.
// Passing `handleMcp` directly collapses the inferred context to `object`.
Deno.serve(
  pipeline(
    [withOAuthProtectedResource(), withSupabase({ auth: 'user', cors: { headers: CORS_HEADERS } })],
    (request, ctx) => handleMcp(request, ctx)
  )
)
registry/default/blocks/mcp/supabase/functions/mcp/deno.json
{
  "nodeModulesDir": "none",
  "compilerOptions": {
    "strict": true
  },
  "tasks": {
    "check": "deno check index.ts"
  }
}
registry/default/blocks/mcp/supabase/functions/mcp/deno.lock
{
  "version": "5",
  "specifiers": {
    "jsr:@supabase/functions-js@2.108.2": "2.108.2",
    "npm:@modelcontextprotocol/server@2.0.0": "2.0.0",
    "npm:@supabase/middleware@1": "1.0.0",
    "npm:@supabase/server@1": "1.9.0_@supabase+supabase-js@2.108.2",
    "npm:@supabase/supabase-js@2.108.2": "2.108.2",
    "npm:openai@^4.52.5": "4.104.0"
  },
  "jsr": {
    "@supabase/functions-js@2.108.2": {
      "integrity": "39665d68e1cb721b0714ed1f55c075fba16f8d992672b51458660b3c1ec77c8f",
      "dependencies": [
        "npm:openai"
      ]
    }
  },
  "npm": {
    "@modelcontextprotocol/core@2.0.0": {
      "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==",
      "dependencies": [
        "zod"
      ]
    },
    "@modelcontextprotocol/server@2.0.0": {
      "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==",
      "dependencies": [
        "@modelcontextprotocol/core",
        "zod"
      ]
    },
    "@supabase/auth-js@2.108.2": {
      "integrity": "sha512-tNaQmBgodDZwgB40mRwVbxFy8IDYwjdpcZ0BYrWiwlULCSQoJj4QoG4zgJT7QRPXcqipefNOzvO/qAu4dF98ag==",
      "dependencies": [
        "tslib"
      ]
    },
    "@supabase/functions-js@2.108.2": {
      "integrity": "sha512-RNUX8EiBy3iLwAX19jtRzLyePnl11/fHcgwDHLnpKcDSXt/5qBnh3LUwAtIjT21Q66QsmNUR2esrHziLCpNubw==",
      "dependencies": [
        "tslib"
      ]
    },
    "@supabase/middleware@1.0.0": {
      "integrity": "sha512-ecA+z/o5E9sB+quuupGt8d7QjrnD4BBwZr+Qthm0uj6CtL/+yvAUTDlknhGuTbUidvx5CxBHJ1R3H96UjDpgyg==",
      "dependencies": [
        "std-env"
      ]
    },
    "@supabase/phoenix@0.4.5": {
      "integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw=="
    },
    "@supabase/postgrest-js@2.108.2": {
      "integrity": "sha512-GQ28/Y8hk3CFmkb3kXH1h/AQx6JIYSQfO0CJMRVBcEKZoNy6C45cXAZ4fcJvRC5Id0cs6xnkUV0+c0rIocigsw==",
      "dependencies": [
        "tslib"
      ]
    },
    "@supabase/realtime-js@2.108.2": {
      "integrity": "sha512-aAGxCSUemZvQIibnCdvNvgaKib28I4rfrNjKbQ9cG1uBLwUsI7hVpGXgEbypCCDhLjQlDTAiJlu7rgljYUT73g==",
      "dependencies": [
        "@supabase/phoenix",
        "tslib"
      ]
    },
    "@supabase/server@1.9.0_@supabase+supabase-js@2.108.2": {
      "integrity": "sha512-EhgfKFWjB6bCWy2UR2JdExnK+B8WBdwiG90+bLTt0GkdAuHZZSooNxOsn/fP3UlcXUNJ6xfAkYlvGzN8NUpz1w==",
      "dependencies": [
        "@supabase/middleware",
        "@supabase/supabase-js",
        "jose"
      ]
    },
    "@supabase/storage-js@2.108.2": {
      "integrity": "sha512-TVZPQxXGxY2+A6yTtm77zUHsh70lBhYUEaJL8RQC+BghcX/ygiMG/rmXrNVBce30/WAeNPa8FiG8HbqlGeV05g==",
      "dependencies": [
        "iceberg-js",
        "tslib"
      ]
    },
    "@supabase/supabase-js@2.108.2": {
      "integrity": "sha512-hFhnPveb5JQg4a0QYicM0swT253YHMdfeRAl2BKHOlI5VAzuHxUGSr8RbwNLYNPauWOgQMS1H8sz8bvYlgwUfQ==",
      "dependencies": [
        "@supabase/auth-js",
        "@supabase/functions-js",
        "@supabase/postgrest-js",
        "@supabase/realtime-js",
        "@supabase/storage-js"
      ]
    },
    "@types/node-fetch@2.6.13": {
      "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==",
      "dependencies": [
        "@types/node",
        "form-data"
      ]
    },
    "@types/node@18.19.130": {
      "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
      "dependencies": [
        "undici-types"
      ]
    },
    "abort-controller@3.0.0": {
      "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
      "dependencies": [
        "event-target-shim"
      ]
    },
    "agentkeepalive@4.6.0": {
      "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==",
      "dependencies": [
        "humanize-ms"
      ]
    },
    "asynckit@0.4.0": {
      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
    },
    "call-bind-apply-helpers@1.0.2": {
      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
      "dependencies": [
        "es-errors",
        "function-bind"
      ]
    },
    "combined-stream@1.0.8": {
      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
      "dependencies": [
        "delayed-stream"
      ]
    },
    "delayed-stream@1.0.0": {
      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="
    },
    "dunder-proto@1.0.1": {
      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
      "dependencies": [
        "call-bind-apply-helpers",
        "es-errors",
        "gopd"
      ]
    },
    "es-define-property@1.0.1": {
      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="
    },
    "es-errors@1.3.0": {
      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="
    },
    "es-object-atoms@1.1.2": {
      "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
      "dependencies": [
        "es-errors"
      ]
    },
    "es-set-tostringtag@2.1.0": {
      "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
      "dependencies": [
        "es-errors",
        "get-intrinsic",
        "has-tostringtag",
        "hasown"
      ]
    },
    "event-target-shim@5.0.1": {
      "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="
    },
    "form-data-encoder@1.7.2": {
      "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="
    },
    "form-data@4.0.6": {
      "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
      "dependencies": [
        "asynckit",
        "combined-stream",
        "es-set-tostringtag",
        "hasown",
        "mime-types"
      ]
    },
    "formdata-node@4.4.1": {
      "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==",
      "dependencies": [
        "node-domexception",
        "web-streams-polyfill"
      ]
    },
    "function-bind@1.1.2": {
      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="
    },
    "get-intrinsic@1.3.0": {
      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
      "dependencies": [
        "call-bind-apply-helpers",
        "es-define-property",
        "es-errors",
        "es-object-atoms",
        "function-bind",
        "get-proto",
        "gopd",
        "has-symbols",
        "hasown",
        "math-intrinsics"
      ]
    },
    "get-proto@1.0.1": {
      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
      "dependencies": [
        "dunder-proto",
        "es-object-atoms"
      ]
    },
    "gopd@1.2.0": {
      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="
    },
    "has-symbols@1.1.0": {
      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="
    },
    "has-tostringtag@1.0.2": {
      "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
      "dependencies": [
        "has-symbols"
      ]
    },
    "hasown@2.0.4": {
      "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
      "dependencies": [
        "function-bind"
      ]
    },
    "humanize-ms@1.2.1": {
      "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
      "dependencies": [
        "ms"
      ]
    },
    "iceberg-js@0.8.1": {
      "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="
    },
    "jose@6.2.12": {
      "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw=="
    },
    "math-intrinsics@1.1.0": {
      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="
    },
    "mime-db@1.52.0": {
      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="
    },
    "mime-types@2.1.35": {
      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
      "dependencies": [
        "mime-db"
      ]
    },
    "ms@2.1.3": {
      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
    },
    "node-domexception@1.0.0": {
      "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
      "deprecated": true
    },
    "node-fetch@2.7.0": {
      "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
      "dependencies": [
        "whatwg-url"
      ]
    },
    "openai@4.104.0": {
      "integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==",
      "dependencies": [
        "@types/node",
        "@types/node-fetch",
        "abort-controller",
        "agentkeepalive",
        "form-data-encoder",
        "formdata-node",
        "node-fetch"
      ],
      "bin": true
    },
    "std-env@4.2.0": {
      "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="
    },
    "tr46@0.0.3": {
      "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
    },
    "tslib@2.8.1": {
      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
    },
    "undici-types@5.26.5": {
      "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="
    },
    "web-streams-polyfill@4.0.0-beta.3": {
      "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="
    },
    "webidl-conversions@3.0.1": {
      "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
    },
    "whatwg-url@5.0.0": {
      "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
      "dependencies": [
        "tr46",
        "webidl-conversions"
      ]
    },
    "zod@4.6.2": {
      "integrity": "sha512-lh5RCAGFa1Cm2hjtNwLQhSs/AsqdWnTQaBER9fEwN/88pSh7KOtJavtBx/0VlkN/uFd61SwYmljLMDAsHlvzBQ=="
    }
  }
}
registry/default/blocks/mcp/supabase/functions/mcp/.env.example
# Copy this file to supabase/functions/.env before serving locally:
#   cp supabase/functions/mcp/.env.example supabase/functions/.env
#   supabase functions serve mcp --env-file supabase/functions/.env

# Deploy these values after linking your project:
#   supabase secrets set --env-file supabase/functions/.env
# Supabase provides the project URL and API keys automatically.

# 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."
registry/default/blocks/mcp/supabase/functions/mcp/tools/types.ts
import type { SupabaseContext } from 'npm:@supabase/server@1'
import type { SupabaseClient } from 'npm:@supabase/supabase-js@2.108.2'

// Only expose the user-scoped client and verified identity to tools. Keeping
// supabaseAdmin out of this type makes bypassing RLS an explicit design choice.
export type ToolContext = {
  supabase: SupabaseClient
  userClaims: NonNullable<SupabaseContext['userClaims']>
  jwtClaims: NonNullable<SupabaseContext['jwtClaims']>
}
registry/default/blocks/mcp/supabase/functions/mcp/tools/result.ts
import type { CallToolResult } from 'npm:@modelcontextprotocol/server@2.0.0'

// Shared helpers for building MCP tool results, so every tool returns the same
// shape and signals failure the same way.

/**
 * A successful structured result with a JSON text fallback for older clients.
 */
export function jsonResult(value: unknown): CallToolResult {
  return {
    content: [{ type: 'text', text: JSON.stringify(value) ?? 'null' }],
    structuredContent: value ?? null,
  }
}

/**
 * A failed result. The message goes back to the model so it can correct itself,
 * so keep it actionable — and free of credentials, claims, and stack traces.
 */
export function errorResult(message: string): CallToolResult {
  return {
    isError: true,
    content: [{ type: 'text', text: message }],
  }
}

function readString(value: unknown, key: string): string | null {
  if (!value || typeof value !== 'object' || !(key in value)) return null
  const property = (value as Record<string, unknown>)[key]
  return typeof property === 'string' && property ? property : null
}

/**
 * Turn an unknown thrown value into a safe MCP error. Supabase API errors often
 * carry a `code` and `hint`, both of which help a model fix its next call.
 */
export function runtimeErrorResult(error: unknown): CallToolResult {
  const message = error instanceof Error ? error.message : String(error)
  const code = readString(error, 'code')
  const hint = readString(error, 'hint')

  return errorResult(
    [code ? `[${code}]` : null, message, hint ? `Hint: ${hint}` : null].filter(Boolean).join(' ')
  )
}
registry/default/blocks/mcp/supabase/functions/mcp/tools/whoami.ts
import type { McpServer } from 'npm:@modelcontextprotocol/server@2.0.0'

import { jsonResult } from './result.ts'
import type { ToolContext } from './types.ts'

// Answers from verified claims, demonstrating that every tool runs as the
// signed-in user. client_id is present for OAuth tokens and null for ordinary
// product sessions.
export function registerWhoamiTool(
  server: McpServer,
  { userClaims, jwtClaims }: ToolContext
): void {
  const clientId =
    typeof jwtClaims?.client_id === 'string' && jwtClaims.client_id ? jwtClaims.client_id : null

  server.registerTool(
    'whoami',
    {
      description: "Return the signed-in user's identity and OAuth client id, when present.",
      annotations: {
        readOnlyHint: true,
        destructiveHint: false,
        openWorldHint: false,
      },
    },
    () =>
      jsonResult({
        id: userClaims.id,
        email: userClaims.email ?? null,
        role: userClaims.role ?? null,
        client_id: clientId,
      })
  )
}
registry/default/clients/tanstack/lib/supabase/client.ts
/// <reference types="vite/types/importMeta.d.ts" />
import { createBrowserClient } from '@supabase/ssr'

export function createClient() {
  return createBrowserClient(
    import.meta.env.VITE_SUPABASE_URL!,
    import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY!
  )
}
registry/default/clients/tanstack/lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { getCookies, setCookie } from '@tanstack/react-start/server'

export function createClient() {
  return createServerClient(
    process.env.VITE_SUPABASE_URL!,
    process.env.VITE_SUPABASE_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll() {
          return Object.entries(getCookies()).map(
            ([name, value]) =>
              ({
                name,
                value,
              }) as { name: string; value: string }
          )
        },
        setAll(cookies) {
          cookies.forEach((cookie) => {
            setCookie(cookie.name, cookie.value)
          })
        },
      },
    }
  )
}

More from supabase

Browse all components in this library →