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)
})
},
},
}
)
}