From 924b3f57930f4aa5b04830af871a680a48b4de06 Mon Sep 17 00:00:00 2001
From: Danny White <3104761+dnywh@users.noreply.github.com>
Date: Wed, 2 Sep 2026 14:25:03 +1000
Subject: [PATCH 1/6] feat(ui-patterns): add async selection feedback (#49843)
## What kind of change does this PR introduce?
Shared UI pattern and Studio UX improvement.
## What is the current behavior?
Async selectors use bespoke loading and error layouts. Some replace the
entire field while fetching, and opening a selector does not
consistently refresh its options.
## What is the new behavior?
Adds shared loading, error, and empty states for Select, command, and
MultiSelector lists, including a persistent polite live region and
reduced-motion support. Analytics Bucket and DuckLake selectors keep
their controls in place, retain populated options during background
refreshes, and refresh when reopened.
## To test
Open the [Replication
preview](https://studio-staging-git-dnywh-featasync-selection-feedback-supabase.vercel.app/dashboard/project/_/database/replication?destinationType=Analytics%20Bucket).
The destination sheet should already be open on **Analytics Bucket**.
You do not need to create a bucket, configure a destination, or start a
pipeline. Open **Select a bucket**, then review these outcomes:
1. **The trigger stays put.** Opening the picker must not replace the
form field with a full-width loading placeholder.
2. **Loading belongs inside the menu.** While options are fetched, the
open menu shows a compact skeleton list.
3. **No resources has a clear explanation.** If the project has no
Analytics Buckets, the menu says **No buckets available**. It still
offers **Create a new bucket** beneath that message.
4. **Existing options do not disappear on refresh.** If the project does
have buckets, close and reopen the picker. Its current options remain
visible while the refresh happens in the background, rather than
flashing back to skeletons.
5. **The pattern is consistent.** If convenient, select a bucket and
open the namespace or access-key picker. The same in-menu loading,
empty, and error treatment applies there too.
The deterministic request-error and reduced-motion cases are covered by
focused unit tests because the deploy preview cannot reliably force
those states.
---
.../AnalyticsBucket/Fields.tsx | 307 +++++++++---------
.../DestinationForm/DuckLake/Fields.tsx | 151 ++++-----
.../DestinationForm/useRefreshOnOpen.test.ts | 27 ++
.../DestinationForm/useRefreshOnOpen.ts | 37 +++
.../BillingSettings/BillingEmail.test.tsx | 8 +-
packages/ui-patterns/package.json | 8 +
.../SelectionListState.test.tsx | 22 ++
.../SelectionListState/SelectionListState.tsx | 49 +++
.../src/SelectionListState/index.ts | 1 +
.../src/ShimmeringLoader/index.css | 7 +
.../src/ShimmeringLoader/index.tsx | 36 ++
.../src/multi-select/multi-select.test.tsx | 45 ++-
.../src/multi-select/multi-select.tsx | 159 ++++++---
13 files changed, 561 insertions(+), 296 deletions(-)
create mode 100644 apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useRefreshOnOpen.test.ts
create mode 100644 apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useRefreshOnOpen.ts
create mode 100644 packages/ui-patterns/src/SelectionListState/SelectionListState.test.tsx
create mode 100644 packages/ui-patterns/src/SelectionListState/SelectionListState.tsx
create mode 100644 packages/ui-patterns/src/SelectionListState/index.ts
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AnalyticsBucket/Fields.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AnalyticsBucket/Fields.tsx
index 4c66ffe335275..06943c0636f5f 100644
--- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AnalyticsBucket/Fields.tsx
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AnalyticsBucket/Fields.tsx
@@ -1,5 +1,5 @@
import { useParams } from 'common'
-import { Eye, EyeOff, Loader2 } from 'lucide-react'
+import { Eye, EyeOff } from 'lucide-react'
import { useState } from 'react'
import { useWatch, type UseFormReturn } from 'react-hook-form'
import {
@@ -13,11 +13,11 @@ import {
SelectItem,
SelectSeparator,
SelectTrigger,
- WarningIcon,
} from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import { Input as PasswordInput } from 'ui-patterns/DataInputs/Input'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
+import { SelectionListState } from 'ui-patterns/SelectionListState'
import {
CREATE_NEW_KEY,
@@ -25,6 +25,11 @@ import {
STORED_SECRET_PLACEHOLDER,
} from '../DestinationForm.constants'
import type { DestinationPanelSchemaType } from '../DestinationForm.schema'
+import {
+ isMetadataListErrorVisible,
+ isMetadataListLoading,
+ useRefreshOnOpen,
+} from '../useRefreshOnOpen'
import { InlineLink } from '@/components/ui/InlineLink'
import { useAnalyticsBucketsQuery } from '@/data/storage/analytics-buckets-query'
import { useIcebergNamespacesQuery } from '@/data/storage/iceberg-namespaces-query'
@@ -52,6 +57,19 @@ const getS3AccessKeyTriggerLabel = ({
return value
}
+const getNamespaceTriggerLabel = ({
+ canSelectNamespace,
+ value,
+}: {
+ canSelectNamespace: boolean
+ value: string | undefined
+}) => {
+ if (!canSelectNamespace) return 'Select a bucket first'
+ if (value === CREATE_NEW_NAMESPACE) return 'Create a new namespace'
+
+ return value || 'Select a namespace'
+}
+
export const AnalyticsBucketFields = ({
form,
editMode,
@@ -73,10 +91,14 @@ export const AnalyticsBucketFields = ({
const {
data: keysData,
isSuccess: isSuccessKeys,
- isPending: isLoadingKeys,
+ isPending: isPendingKeys,
+ isFetching: isFetchingKeys,
isError: isErrorKeys,
+ refetch: refetchKeys,
} = useStorageCredentialsQuery({ projectRef })
const s3Keys = keysData?.data ?? []
+ const isLoadingKeys = isMetadataListLoading(isPendingKeys || isFetchingKeys, s3Keys.length)
+ const isKeysErrorVisible = isMetadataListErrorVisible(isErrorKeys, s3Keys.length)
const keyNoLongerExists =
(s3AccessKeyId ?? '').length > 0 &&
s3AccessKeyId !== CREATE_NEW_KEY &&
@@ -84,20 +106,44 @@ export const AnalyticsBucketFields = ({
const {
data: analyticsBuckets = [],
- isPending: isLoadingBuckets,
+ isPending: isPendingBuckets,
+ isFetching: isFetchingBuckets,
isError: isErrorBuckets,
+ refetch: refetchBuckets,
} = useAnalyticsBucketsQuery({ projectRef })
+ const isLoadingBuckets = isMetadataListLoading(
+ isPendingBuckets || isFetchingBuckets,
+ analyticsBuckets.length
+ )
+ const isBucketsErrorVisible = isMetadataListErrorVisible(isErrorBuckets, analyticsBuckets.length)
const canSelectNamespace = !!warehouseName
const {
data: namespaces = [],
- isPending: isLoadingNamespaces,
+ isPending: isPendingNamespaces,
+ isFetching: isFetchingNamespaces,
isError: isErrorNamespaces,
+ refetch: refetchNamespaces,
} = useIcebergNamespacesQuery(
{ projectRef, warehouse: warehouseName },
{ enabled: !!warehouseName }
)
+ const isLoadingNamespaces = isMetadataListLoading(
+ isPendingNamespaces || isFetchingNamespaces,
+ namespaces.length
+ )
+ const isNamespacesErrorVisible = isMetadataListErrorVisible(isErrorNamespaces, namespaces.length)
+ const { handleOpenChange: handleRefreshBucketsOnOpen } = useRefreshOnOpen({
+ refetch: refetchBuckets,
+ })
+ const { handleOpenChange: handleRefreshNamespacesOnOpen } = useRefreshOnOpen({
+ isEnabled: canSelectNamespace,
+ refetch: refetchNamespaces,
+ })
+ const { handleOpenChange: handleRefreshKeysOnOpen } = useRefreshOnOpen({
+ refetch: refetchKeys,
+ })
return (
@@ -113,61 +159,45 @@ export const AnalyticsBucketFields = ({
layout="horizontal"
description="The Analytics Bucket where data will be stored"
>
- {isLoadingBuckets ? (
- }
- >
- Retrieving buckets
-
- ) : isErrorBuckets ? (
- }
+
+
+
)}
/>
@@ -181,62 +211,42 @@ export const AnalyticsBucketFields = ({
layout="horizontal"
description="The namespace within the bucket where tables will be organized"
>
- {isLoadingNamespaces && canSelectNamespace ? (
- }
+
+
- Retrieving namespaces
-
- ) : isErrorNamespaces ? (
- }
- >
- Failed to retrieve namespaces
-
- ) : (
-
-
-
- {!canSelectNamespace
- ? 'Select a warehouse first'
- : field.value === CREATE_NEW_NAMESPACE
- ? 'Create a new namespace'
- : field.value || 'Select a namespace'}
-
-
-
- {namespaces.length === 0 ? (
-
- No namespaces available
-
- ) : (
- namespaces.map((namespace) => (
-
- {namespace}
-
- ))
- )}
-
-
- Create a new namespace
+
+ {getNamespaceTriggerLabel({ canSelectNamespace, value: field.value })}
+
+
+
+
+ {namespaces.map((namespace) => (
+
+ {namespace}
-
-
-
-
- )}
+ ))}
+
+
+ Create a new namespace
+
+
+
+
+
)}
/>
@@ -341,49 +351,38 @@ export const AnalyticsBucketFields = ({
}
>
- {isLoadingKeys ? (
- }
- >
- Retrieving keys
-
- ) : isErrorKeys ? (
- }
+
+
- Failed to retrieve keys
-
- ) : (
-
-
-
- {getS3AccessKeyTriggerLabel({ value: field.value, editMode })}
-
-
-
- {s3Keys.map((key) => (
-
- {key.access_key}
- {key.description}
-
- ))}
-
-
- Create a new key
+
+ {getS3AccessKeyTriggerLabel({ value: field.value, editMode })}
+
+
+
+
+ {s3Keys.map((key) => (
+
+ {key.access_key}
+ {key.description}
-
-
-
-
- )}
+ ))}
+
+
+ Create a new key
+
+
+
+
+
)}
/>
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DuckLake/Fields.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DuckLake/Fields.tsx
index 7f29d23598cb9..eaac72f1fee6e 100644
--- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DuckLake/Fields.tsx
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DuckLake/Fields.tsx
@@ -1,4 +1,4 @@
-import { Check, Database, Eye, EyeOff, Loader2, Plus, SlidersHorizontal } from 'lucide-react'
+import { Check, Database, Eye, EyeOff, Plus, SlidersHorizontal } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useWatch, type UseFormReturn } from 'react-hook-form'
import { toast } from 'sonner'
@@ -20,14 +20,19 @@ import {
SelectGroup,
SelectItem,
SelectTrigger,
- WarningIcon,
} from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import { Input as PasswordInput } from 'ui-patterns/DataInputs/Input'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
+import { SelectionListState } from 'ui-patterns/SelectionListState'
import { DEFAULT_DUCKLAKE_POOL_SIZE, STORED_SECRET_PLACEHOLDER } from '../DestinationForm.constants'
import type { DestinationPanelSchemaType } from '../DestinationForm.schema'
+import {
+ isMetadataListErrorVisible,
+ isMetadataListLoading,
+ useRefreshOnOpen,
+} from '../useRefreshOnOpen'
import {
DUCKLAKE_MODE_CUSTOM,
DUCKLAKE_MODE_SUPABASE,
@@ -690,8 +695,10 @@ const ProjectSelection = ({
const {
data: projectsData,
- isPending: isLoadingProjects,
+ isPending: isPendingProjects,
+ isFetching: isFetchingProjects,
isError: isErrorProjects,
+ refetch: refetchProjects,
} = useOrgProjectsInfiniteQuery(
{ slug: organization?.slug, statuses: [PROJECT_STATUS.ACTIVE_HEALTHY] },
{ enabled: !!organization?.slug }
@@ -704,6 +711,7 @@ const ProjectSelection = ({
),
[projectsData]
)
+ const isProjectsErrorVisible = isMetadataListErrorVisible(isErrorProjects, projects.length)
const projectsByRef = useMemo(
() => new Map(projects.map((project) => [project.ref, project])),
@@ -715,54 +723,40 @@ const ProjectSelection = ({
const project = projectsByRef.get(ref)
return project ? `${project.name} · ${project.ref}` : ref
}
+ const { handleOpenChange: handleRefreshProjectsOnOpen } = useRefreshOnOpen({
+ isEnabled: !!organization?.slug,
+ refetch: refetchProjects,
+ })
- if (isLoadingProjects) {
- return (
- }
- >
- Retrieving projects
-
- )
- }
- if (isErrorProjects) {
- return (
- }
- >
- Failed to retrieve projects
-
- )
- }
return (
-
+
{projectLabel(value) ?? placeholder}
- {projects.length === 0 ? (
-
- No active projects available
+
+ {projects.map((project) => (
+
+
+ {project.name}
+
+ {project.ref} · {project.region}
+
+
- ) : (
- projects.map((project) => (
-
-
- {project.name}
-
- {project.ref} · {project.region}
-
-
-
- ))
- )}
+ ))}
@@ -785,8 +779,10 @@ const BucketSelection = ({
const {
data: bucketsData,
- isPending: isLoadingBuckets,
+ isPending: isPendingBuckets,
+ isFetching: isFetchingBuckets,
isError: isErrorBuckets,
+ refetch: refetchBuckets,
} = usePaginatedBucketsQuery(
{ projectRef: ducklakeStorageProjectRef },
{ enabled: !!ducklakeStorageProjectRef }
@@ -799,44 +795,24 @@ const BucketSelection = ({
),
[bucketsData]
)
+ const isBucketsErrorVisible = isMetadataListErrorVisible(isErrorBuckets, buckets.length)
+ const { handleOpenChange: handleRefreshBucketsOnOpen } = useRefreshOnOpen({
+ isEnabled: !!ducklakeStorageProjectRef,
+ refetch: refetchBuckets,
+ })
if (!ducklakeStorageProjectRef) {
return (
-
- )
- }
- if (isLoadingBuckets) {
- return (
- }
- >
- Retrieving buckets
-
- )
- }
- if (isErrorBuckets) {
- return (
- }
- >
- Failed to retrieve buckets
-
+
+ Select a storage project first
+
)
}
return (
{
if (e) onChange(e)
}}
@@ -844,17 +820,22 @@ const BucketSelection = ({
{value || 'Select a bucket'}
- {buckets.length === 0 ? (
-
- No buckets available
+
+ {buckets.map((bucket) => (
+
+ {bucket.name}
- ) : (
- buckets.map((bucket) => (
-
- {bucket.name}
-
- ))
- )}
+ ))}
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useRefreshOnOpen.test.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useRefreshOnOpen.test.ts
new file mode 100644
index 0000000000000..d3577c581291b
--- /dev/null
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useRefreshOnOpen.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ isMetadataListErrorVisible,
+ isMetadataListLoading,
+ isMetadataValueLoading,
+} from './useRefreshOnOpen'
+
+describe('replication metadata loading UI', () => {
+ it('shows a list skeleton only while pending or fetching an empty list', () => {
+ expect(isMetadataListLoading(true, 0)).toBe(true)
+ expect(isMetadataListLoading(true, 3)).toBe(false)
+ expect(isMetadataListLoading(false, 0)).toBe(false)
+ })
+
+ it('shows a list error only when the request failed and the list is empty', () => {
+ expect(isMetadataListErrorVisible(true, 0)).toBe(true)
+ expect(isMetadataListErrorVisible(true, 3)).toBe(false)
+ expect(isMetadataListErrorVisible(false, 0)).toBe(false)
+ })
+
+ it('shows a record skeleton only while fetching a missing value', () => {
+ expect(isMetadataValueLoading(true, undefined)).toBe(true)
+ expect(isMetadataValueLoading(true, { name: 'analytics' })).toBe(false)
+ expect(isMetadataValueLoading(false, undefined)).toBe(false)
+ })
+})
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useRefreshOnOpen.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useRefreshOnOpen.ts
new file mode 100644
index 0000000000000..16394d33fa59a
--- /dev/null
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useRefreshOnOpen.ts
@@ -0,0 +1,37 @@
+import { useCallback } from 'react'
+
+// Replication metadata (publication names, publication tables, source tables,
+// columns) and similar destination-form option lists follow one rule:
+//
+// - Opening a picker always refetches.
+// - Loading and error UI only appear when there is nothing to show, so a
+// background refresh never replaces existing options with a skeleton.
+//
+// Pass `isPending || isFetching` into the loading helpers. `isPending` covers
+// "query not started yet" (including while a parent id is still loading);
+// `isFetching` covers an in-flight request. A populated list stays visible.
+
+export const isMetadataListLoading = (isPendingOrFetching: boolean, itemCount: number) =>
+ isPendingOrFetching && itemCount === 0
+
+export const isMetadataListErrorVisible = (isError: boolean, itemCount: number) =>
+ isError && itemCount === 0
+
+export const isMetadataValueLoading = (isPendingOrFetching: boolean, value: unknown) =>
+ isPendingOrFetching && value == null
+
+interface UseRefreshOnOpenProps {
+ isEnabled?: boolean
+ refetch: () => unknown
+}
+
+export const useRefreshOnOpen = ({ isEnabled = true, refetch }: UseRefreshOnOpenProps) => {
+ const handleOpenChange = useCallback(
+ (isOpen: boolean) => {
+ if (isOpen && isEnabled) void refetch()
+ },
+ [isEnabled, refetch]
+ )
+
+ return { handleOpenChange }
+}
diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.test.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.test.tsx
index 83f8d59289d2d..a5692cdce14e2 100644
--- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.test.tsx
+++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.test.tsx
@@ -72,7 +72,13 @@ const mockUpdateCustomerProfile = () => {
}
const addRecipient = async (email: string) => {
- const input = screen.getByPlaceholderText('Add additional recipients')
+ const input = screen
+ .getAllByRole('combobox')
+ .find((element): element is HTMLInputElement => element instanceof HTMLInputElement)
+
+ expect(input).toBeDefined()
+ if (!input) return
+
await userEvent.click(input)
await waitFor(() => expect(input).toHaveAttribute('aria-expanded', 'true'))
// fireEvent.change (rather than userEvent.type) avoids racing the popover's open-state
diff --git a/packages/ui-patterns/package.json b/packages/ui-patterns/package.json
index 8ecf537b45c8a..9acb4eed9a0ee 100644
--- a/packages/ui-patterns/package.json
+++ b/packages/ui-patterns/package.json
@@ -582,6 +582,14 @@
"import": "./src/Row/index.tsx",
"types": "./src/Row/index.tsx"
},
+ "./SelectionListState/SelectionListState": {
+ "import": "./src/SelectionListState/SelectionListState.tsx",
+ "types": "./src/SelectionListState/SelectionListState.tsx"
+ },
+ "./SelectionListState": {
+ "import": "./src/SelectionListState/index.ts",
+ "types": "./src/SelectionListState/index.ts"
+ },
"./ShimmeringLoader/index.css": {
"import": "./src/ShimmeringLoader/index.css",
"types": "./src/ShimmeringLoader/index.css"
diff --git a/packages/ui-patterns/src/SelectionListState/SelectionListState.test.tsx b/packages/ui-patterns/src/SelectionListState/SelectionListState.test.tsx
new file mode 100644
index 0000000000000..2cac72c194c9c
--- /dev/null
+++ b/packages/ui-patterns/src/SelectionListState/SelectionListState.test.tsx
@@ -0,0 +1,22 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+
+import { SelectionListState } from './SelectionListState'
+
+describe('SelectionListState', () => {
+ it('keeps one persistent live region while its state changes', () => {
+ const { container, rerender } = render()
+
+ const liveRegion = container.querySelector('[aria-live="polite"]')
+ expect(liveRegion).not.toBeNull()
+ if (!liveRegion) throw new Error('Expected an aria-live status region')
+ expect(liveRegion).toHaveAttribute('aria-live', 'polite')
+ expect(liveRegion).toHaveClass('sr-only')
+ expect(liveRegion).toHaveTextContent('Loading options')
+
+ rerender()
+
+ expect(screen.getByText('No columns found')).toBe(liveRegion)
+ expect(liveRegion).not.toHaveClass('sr-only')
+ })
+})
diff --git a/packages/ui-patterns/src/SelectionListState/SelectionListState.tsx b/packages/ui-patterns/src/SelectionListState/SelectionListState.tsx
new file mode 100644
index 0000000000000..37eeaf58275ef
--- /dev/null
+++ b/packages/ui-patterns/src/SelectionListState/SelectionListState.tsx
@@ -0,0 +1,49 @@
+import { cn } from 'ui'
+
+import { GenericSelectionSkeletonLoader } from '../ShimmeringLoader'
+
+interface SelectionListStateProps {
+ className?: string
+ emptyLabel?: string
+ errorLabel?: string
+ isEmpty?: boolean
+ isError?: boolean
+ isLoading?: boolean
+ skeletonVariant?: 'command' | 'multi-select' | 'select'
+}
+
+export const SelectionListState = ({
+ className,
+ emptyLabel = 'No options available',
+ errorLabel = 'Unable to load options',
+ isEmpty = false,
+ isError = false,
+ isLoading = false,
+ skeletonVariant = 'select',
+}: SelectionListStateProps) => {
+ let statusLabel: string | undefined
+ if (isLoading) statusLabel = 'Loading options'
+ else if (isError) statusLabel = errorLabel
+ else if (isEmpty) statusLabel = emptyLabel
+
+ return (
+ <>
+ {isLoading && (
+
+ )}
+
+ {statusLabel}
+
+ >
+ )
+}
diff --git a/packages/ui-patterns/src/SelectionListState/index.ts b/packages/ui-patterns/src/SelectionListState/index.ts
new file mode 100644
index 0000000000000..58af712bc1272
--- /dev/null
+++ b/packages/ui-patterns/src/SelectionListState/index.ts
@@ -0,0 +1 @@
+export * from './SelectionListState'
diff --git a/packages/ui-patterns/src/ShimmeringLoader/index.css b/packages/ui-patterns/src/ShimmeringLoader/index.css
index d538577cbf891..00ca24a44625f 100644
--- a/packages/ui-patterns/src/ShimmeringLoader/index.css
+++ b/packages/ui-patterns/src/ShimmeringLoader/index.css
@@ -29,3 +29,10 @@
background-position: 1000px 0;
}
}
+
+@media (prefers-reduced-motion: reduce) {
+ .shimmering-loader,
+ .dark .shimmering-loader {
+ animation: none;
+ }
+}
diff --git a/packages/ui-patterns/src/ShimmeringLoader/index.tsx b/packages/ui-patterns/src/ShimmeringLoader/index.tsx
index 59d229a8f22e1..02192b946505c 100644
--- a/packages/ui-patterns/src/ShimmeringLoader/index.tsx
+++ b/packages/ui-patterns/src/ShimmeringLoader/index.tsx
@@ -37,6 +37,42 @@ export const GenericSkeletonLoader = ({ className }: GenericSkeletonLoaderProps)
)
+interface GenericSelectionSkeletonLoaderProps extends GenericSkeletonLoaderProps {
+ // Selection primitives have different row indicators: command lists have none, Select uses
+ // radio circles, and MultiSelector uses checkboxes.
+ variant?: 'command' | 'multi-select' | 'select'
+}
+
+export const GenericSelectionSkeletonLoader = ({
+ className,
+ variant = 'multi-select',
+}: GenericSelectionSkeletonLoaderProps) => {
+ const hasIndicator = variant !== 'command'
+ const isSelect = variant === 'select'
+
+ return (
+
+ {['w-2/3', 'w-1/2', 'w-3/4'].map((width, index) => (
+
+ {hasIndicator && (
+
+ )}
+
+
+ ))}
+
+ )
+}
+
export const GenericTableLoader = ({
headers = [],
numRows = 3,
diff --git a/packages/ui-patterns/src/multi-select/multi-select.test.tsx b/packages/ui-patterns/src/multi-select/multi-select.test.tsx
index 6fa101b670df8..5dfed06154b94 100644
--- a/packages/ui-patterns/src/multi-select/multi-select.test.tsx
+++ b/packages/ui-patterns/src/multi-select/multi-select.test.tsx
@@ -61,11 +61,13 @@ describe('multi-select', () => {
it('renders selected values with a custom label', () => {
render(
undefined}>
- `public.table_${value}`} />
+ `Public.MixedCase_${value}`} />
)
- expect(screen.getByRole('combobox')).toHaveTextContent('public.table_101')
+ const badge = screen.getByText('Public.MixedCase_101').closest('[class*=rounded]')
+ expect(screen.getByRole('combobox')).toHaveTextContent('Public.MixedCase_101')
+ expect(badge).toHaveClass('normal-case', 'tracking-normal')
})
it('opens the dropdown when the MultiSelectorTrigger is clicked', () => {
@@ -78,6 +80,45 @@ describe('multi-select', () => {
expect(screen.getByText('Apple')).toBeInTheDocument() // Apple should be visible in the dropdown
})
+ it('shows loading rows only inside the open dropdown', () => {
+ render(
+ undefined}>
+
+
+
+ Apple
+
+
+
+ )
+
+ expect(document.querySelector('.shimmering-loader')).not.toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('combobox'))
+
+ expect(document.querySelector('.shimmering-loader')).toBeInTheDocument()
+ expect(screen.queryByText('Apple')).not.toBeInTheDocument()
+ })
+
+ it('shows a custom loading error instead of an empty result', () => {
+ render(
+ undefined}>
+
+
+
+ Apple
+
+
+
+ )
+
+ fireEvent.click(screen.getByRole('combobox'))
+
+ expect(screen.getByText('Unable to load fruits')).toBeInTheDocument()
+ expect(screen.queryByText('Apple')).not.toBeInTheDocument()
+ expect(screen.queryByText('No results found')).not.toBeInTheDocument()
+ })
+
it('adds and removes value when toggling MultiSelectorItem', () => {
render()
diff --git a/packages/ui-patterns/src/multi-select/multi-select.tsx b/packages/ui-patterns/src/multi-select/multi-select.tsx
index b74627751c21b..d19adecb19170 100644
--- a/packages/ui-patterns/src/multi-select/multi-select.tsx
+++ b/packages/ui-patterns/src/multi-select/multi-select.tsx
@@ -21,13 +21,15 @@ import {
SIZE_VARIANTS_DEFAULT,
} from 'ui'
+import { SelectionListState } from '../SelectionListState'
+
interface MultiSelectContextProps {
id: string
values: string[]
onValuesChange: (value: string[]) => void
toggleValue: (values: string) => void
open: boolean
- setOpen: React.Dispatch>
+ setOpen: (open: boolean) => void
inputValue: string
setInputValue: React.Dispatch>
activeIndex: number
@@ -41,6 +43,7 @@ const MultiSelectContext = React.createContext(n
const DROPDOWN_MAX_HEIGHT = 300
const DROPDOWN_GAP = 8
+const DROPDOWN_BORDER_HEIGHT = 2
const commandItemClass = cn(
'relative text-foreground-light text-left px-2 py-1.5 rounded-xs',
@@ -73,6 +76,7 @@ type MultiSelectorProps = {
mode?: MultiSelectorMode
values: string[]
onValuesChange: (value: string[]) => void
+ onOpenChange?: (open: boolean) => void
disabled?: boolean
} & React.ComponentPropsWithoutRef &
VariantProps
@@ -80,6 +84,7 @@ type MultiSelectorProps = {
function MultiSelector({
values = [],
onValuesChange,
+ onOpenChange,
disabled,
dir,
size,
@@ -89,13 +94,24 @@ function MultiSelector({
...props
}: MultiSelectorProps) {
const ref = React.useRef(null)
- const [open, setOpen] = React.useState(false)
+ const [open, setOpenState] = React.useState(false)
const [inputValue, setInputValue] = React.useState('')
const [activeIndex, setActiveIndex] = React.useState(-1)
const [dropdownMaxHeight, setDropdownMaxHeight] = React.useState(DROPDOWN_MAX_HEIGHT)
+ const openRef = React.useRef(false)
const generatedId = React.useId()
const id = idProp ?? generatedId
+ const handleOpenChange = React.useCallback(
+ (nextOpen: boolean) => {
+ if (openRef.current === nextOpen) return
+ openRef.current = nextOpen
+ setOpenState(nextOpen)
+ onOpenChange?.(nextOpen)
+ },
+ [onOpenChange]
+ )
+
const toggleValue = React.useCallback(
(toggledValue: string) => {
if (values.includes(toggledValue)) {
@@ -104,7 +120,7 @@ function MultiSelector({
onValuesChange([...values, toggledValue])
}
},
- [values]
+ [onValuesChange, values]
)
useEffect(() => {
@@ -154,18 +170,18 @@ function MultiSelector({
}
break
case 'Escape':
- activeIndex !== -1 ? setActiveIndex(-1) : setOpen(false)
+ activeIndex !== -1 ? setActiveIndex(-1) : handleOpenChange(false)
if (ref.current) {
const button = (ref.current as HTMLDivElement).querySelector('button[role="combobox"]')
button && (button as HTMLButtonElement).focus()
}
break
case 'Enter':
- setOpen(true)
+ handleOpenChange(true)
break
}
},
- [values, inputValue, activeIndex]
+ [values, inputValue, activeIndex, handleOpenChange]
)
return (
@@ -176,7 +192,7 @@ function MultiSelector({
toggleValue,
onValuesChange,
open,
- setOpen,
+ setOpen: handleOpenChange,
inputValue,
setInputValue,
activeIndex,
@@ -186,7 +202,7 @@ function MultiSelector({
dropdownMaxHeight,
}}
>
-
+
= React.useCallback(
(event) => {
@@ -374,7 +391,7 @@ const MultiSelectorTrigger = React.forwardRef,
React.ComponentPropsWithoutRef & {
creatable?: boolean
+ emptyLabel?: string
+ error?: boolean
+ errorLabel?: string
+ loading?: boolean
}
->(({ className, children, creatable = false, ...props }, ref) => {
- const { open, inputValue, setInputValue, toggleValue, dropdownMaxHeight } = useMultiSelect()
+>(
+ (
+ {
+ className,
+ children,
+ creatable = false,
+ emptyLabel = 'No results found',
+ error = false,
+ errorLabel,
+ loading = false,
+ ...props
+ },
+ ref
+ ) => {
+ const { open, inputValue, setInputValue, toggleValue, dropdownMaxHeight } = useMultiSelect()
- const options = Children.toArray(children)
- const availableOptions = options
- .filter((x: any) => !!x.props.value)
- .map((x: any) => x.props.value.toLowerCase())
- const isOptionExists = availableOptions.some((x: string) => x === inputValue.toLowerCase())
+ const options = Children.toArray(children)
+ const availableOptions = options
+ .filter((x: any) => !!x.props.value)
+ .map((x: any) => x.props.value.toLowerCase())
+ const isOptionExists = availableOptions.some((x: string) => x === inputValue.toLowerCase())
- return (
- e.stopPropagation()}
- {...props}
- >
- {children}
- {creatable && inputValue.length > 0 && !isOptionExists ? (
- {
- open && toggleValue(inputValue)
- setInputValue('')
- }}
- className={commandItemClass}
- >
- Create "{inputValue}"
-
- ) : creatable && options.length === 0 ? (
-
- Type to add a value
-
- ) : (
-
- No results found
-
- )}
-
- )
-})
+ return (
+ e.stopPropagation()}
+ {...props}
+ >
+
+ {!loading && !error && (options.length > 0 || creatable) && (
+ <>
+ {children}
+ {creatable && inputValue.length > 0 && !isOptionExists ? (
+ {
+ open && toggleValue(inputValue)
+ setInputValue('')
+ }}
+ className={commandItemClass}
+ >
+ Create "{inputValue}"
+
+ ) : creatable && options.length === 0 ? (
+
+ Type to add a value
+
+ ) : (
+
+ {emptyLabel}
+
+ )}
+ >
+ )}
+
+ )
+ }
+)
MultiSelectorList.displayName = 'MultiSelectorList'
MultiSelector.List = MultiSelectorList
From b04e26872b11b897181065b86aa8f2734adf9d61 Mon Sep 17 00:00:00 2001
From: Saxon Fletcher
Date: Wed, 2 Sep 2026 14:53:38 +1000
Subject: [PATCH 2/6] feat(ui-library): add MCP server block (#49573)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.
YES
## What kind of change does this PR introduce?
Feature — a new UI Library block. Bottom of a two-PR stack; #49579
builds on it.
## What is the new behavior?
Adds an `mcp-server` block: a Supabase Edge Function that exposes MCP
tools scoped to the signed-in user. It is backend-only, so every file
has an explicit target and no `components.json` is needed.
- `withSupabase({ auth: 'user' })` verifies the access token and gives
each tool an RLS-scoped client. Both product session tokens and OAuth
tokens work; only the latter carry `client_id`.
- `withOAuthProtectedResource` serves RFC 9728 metadata and adds a
`WWW-Authenticate` challenge to `401`s, so external MCP clients can
discover the authorization server.
- Tools are composed in `tools/index.ts`. One is included, `whoami`,
which shows the caller's identity and OAuth client.
Docs at `/library/docs/headless/mcp-server`, under a new MCP group in
the sidebar. `BlockItem` gained a `showOpenInV0` flag (v0 cannot take
Deno functions), and the file-tree viewer now picks a language per file
instead of always TypeScript.
## To test
1. `npx shadcn@latest add
http://localhost:3004/library/r/mcp-server.json` into a Supabase project
or empty directory.
2. Add `[functions.mcp-server] verify_jwt = false` to
`supabase/config.toml`, then:
```bash
supabase start
supabase functions serve mcp-server --env-file supabase/functions/.env
```
3. **Unauthenticated:** `curl -i
localhost:54321/functions/v1/mcp-server` returns `401` with a
`WWW-Authenticate` header, and
`/functions/v1/mcp-server/oauth-protected-resource` returns the metadata
document.
4. **Product session:** sign up a user, then call the endpoint with
`Authorization: Bearer `. `tools/list` shows
`whoami`; calling it returns that user's id and `client_id: null`.
5. **External client:** enable `[auth.oauth_server]` with
`allow_dynamic_registration = true`, install the OAuth Consent block,
point an MCP client (Claude Code, Codex) at the function URL, approve
the consent screen, and call `whoami` again. `client_id` is now
populated.
6. Confirm RLS holds: add a table with a user-scoped policy and a tool
that reads it, then check a second user cannot see the first user's
rows.
7. Docs page renders at `/library/docs/headless/mcp-server`, and
`deno.json` / `.env.example` in the folder tree highlight as JSON and
bash rather than TypeScript.
## Summary by CodeRabbit
- **New Features**
- Added an installable Supabase MCP Server block with user-scoped
authentication and a read-only identity tool.
- Added MCP Blocks to documentation navigation and setup guidance.
- Code blocks now automatically detect syntax highlighting from file
names.
- Added an option to hide the “Open in v0” button.
- **Documentation**
- Expanded MCP Server guidance covering installation, configuration,
validation, deployment, OAuth, and security.
---------
Co-authored-by: Cursor Agent
Co-authored-by: Saxon Fletcher
---
.../ui-library/components/block-item-code.tsx | 25 ++-
apps/ui-library/components/block-item.tsx | 5 +-
.../ui-library/components/side-navigation.tsx | 16 +-
apps/ui-library/config/docs.ts | 17 ++
.../content/docs/headless/mcp-server.mdx | 187 ++++++++++++++++++
apps/ui-library/public/r/mcp-server.json | 52 +++++
apps/ui-library/public/r/registry.json | 44 +++++
apps/ui-library/registry/blocks.ts | 5 +
.../blocks/mcp-server/registry-item.json | 44 +++++
.../functions/mcp-server/.env.example | 7 +
.../supabase/functions/mcp-server/deno.json | 9 +
.../supabase/functions/mcp-server/index.ts | 73 +++++++
.../functions/mcp-server/tools/index.ts | 12 ++
.../functions/mcp-server/tools/result.ts | 45 +++++
.../functions/mcp-server/tools/types.ts | 10 +
.../functions/mcp-server/tools/whoami.ts | 34 ++++
apps/ui-library/tsconfig.json | 6 +-
17 files changed, 585 insertions(+), 6 deletions(-)
create mode 100644 apps/ui-library/content/docs/headless/mcp-server.mdx
create mode 100644 apps/ui-library/public/r/mcp-server.json
create mode 100644 apps/ui-library/registry/default/blocks/mcp-server/registry-item.json
create mode 100644 apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/.env.example
create mode 100644 apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/deno.json
create mode 100644 apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/index.ts
create mode 100644 apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/index.ts
create mode 100644 apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/result.ts
create mode 100644 apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/types.ts
create mode 100644 apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/whoami.ts
diff --git a/apps/ui-library/components/block-item-code.tsx b/apps/ui-library/components/block-item-code.tsx
index b12512b6dddd5..acb6e8def4612 100644
--- a/apps/ui-library/components/block-item-code.tsx
+++ b/apps/ui-library/components/block-item-code.tsx
@@ -3,7 +3,7 @@
import { File } from 'lucide-react'
import { useState } from 'react'
import { flattenTree, TreeView, TreeViewItem } from 'ui'
-import { CodeBlock } from 'ui-patterns/CodeBlock'
+import { CodeBlock, type CodeBlockLang } from 'ui-patterns/CodeBlock'
import { RegistryNode } from '@/lib/process-registry'
@@ -27,6 +27,27 @@ const flattenChildren = (files: RegistryNode[]): TreeNode[] => {
)
}
+const LANGUAGES: Record = {
+ bash: 'bash',
+ html: 'html',
+ js: 'js',
+ json: 'json',
+ jsx: 'jsx',
+ sh: 'bash',
+ sql: 'sql',
+ toml: 'toml',
+ yaml: 'yaml',
+ yml: 'yaml',
+}
+
+const languageFor = (fileName: string | undefined): CodeBlockLang => {
+ const normalized = fileName?.toLowerCase() ?? ''
+ if (normalized.startsWith('.env')) return 'bash'
+ if (normalized === 'deno.lock') return 'json'
+
+ return LANGUAGES[normalized.split('.').pop() ?? ''] ?? 'ts'
+}
+
const findFirstFile = (nodes: RegistryNode[]): RegistryNode | null => {
for (const node of nodes) {
if (node.type === 'file') {
@@ -105,7 +126,7 @@ export function BlockItemCode({ files }: BlockItemCodeProps) {
{selectedFile?.content}
diff --git a/apps/ui-library/components/block-item.tsx b/apps/ui-library/components/block-item.tsx
index 4285aea9e7f69..26c6d9504ec02 100644
--- a/apps/ui-library/components/block-item.tsx
+++ b/apps/ui-library/components/block-item.tsx
@@ -9,15 +9,16 @@ const Command = dynamic(() => import('./command').then((mod) => mod.Command), {
interface BlockItemProps {
name: string
+ showOpenInV0?: boolean
}
-export const BlockItem = ({ name }: BlockItemProps) => {
+export const BlockItem = ({ name, showOpenInV0 = true }: BlockItemProps) => {
const framework = name.includes('vue') || name.includes('nuxtjs') ? 'vue' : 'react'
return (
-
+ {showOpenInV0 && }
)
}
diff --git a/apps/ui-library/components/side-navigation.tsx b/apps/ui-library/components/side-navigation.tsx
index b0932110c33b0..d24ee10c70ff7 100644
--- a/apps/ui-library/components/side-navigation.tsx
+++ b/apps/ui-library/components/side-navigation.tsx
@@ -3,7 +3,13 @@ import Link from 'next/link'
import { CommandMenu } from './command-menu'
import { ThemeSwitcherDropdown } from './theme-switcher-dropdown'
import NavigationItem from '@/components/side-navigation-item'
-import { componentPages, gettingStarted, oauthBlocks, platformBlocks } from '@/config/docs'
+import {
+ componentPages,
+ gettingStarted,
+ mcpBlocks,
+ oauthBlocks,
+ platformBlocks,
+} from '@/config/docs'
function SideNavigation() {
return (
@@ -96,6 +102,14 @@ function SideNavigation() {
))}
+
+
+ {mcpBlocks.title}
+
+ {mcpBlocks.items.map((item, i) => (
+
+ ))}
+
{platformBlocks.title}
diff --git a/apps/ui-library/config/docs.ts b/apps/ui-library/config/docs.ts
index 3e496318a66fb..7d7f24bb650e3 100644
--- a/apps/ui-library/config/docs.ts
+++ b/apps/ui-library/config/docs.ts
@@ -50,6 +50,19 @@ export const oauthBlocks: SidebarNavGroup = {
],
}
+export const mcpBlocks: SidebarNavGroup = {
+ title: 'MCP',
+ items: [
+ {
+ title: 'MCP Server',
+ href: '/docs/headless/mcp-server',
+ items: [],
+ new: true,
+ commandItemLabel: 'MCP Server',
+ },
+ ],
+}
+
// Component definitions with supported frameworks
export const componentPages: SidebarNavGroup = {
title: 'Components',
@@ -151,6 +164,10 @@ export const COMMAND_ITEMS = [
label: item.commandItemLabel,
href: item.href,
})),
+ ...mcpBlocks.items.map((item) => ({
+ label: item.commandItemLabel,
+ href: item.href,
+ })),
]
// Framework titles for display
diff --git a/apps/ui-library/content/docs/headless/mcp-server.mdx b/apps/ui-library/content/docs/headless/mcp-server.mdx
new file mode 100644
index 0000000000000..fc63aaceac6be
--- /dev/null
+++ b/apps/ui-library/content/docs/headless/mcp-server.mdx
@@ -0,0 +1,187 @@
+---
+title: MCP Server
+description: 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
+
+
+
+## Configure the project
+
+The function verifies access tokens itself, so disable the gateway JWT check:
+
+```toml
+[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](https://supabase.com/dashboard/project/_/settings/jwt).
+
+## Choose how agents authenticate
+
+### Embedded product agents
+
+A trusted product backend can forward its signed-in user's Supabase access
+token as `Authorization: Bearer
`. 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](../nextjs/oauth-consent),
+then enable OAuth in `supabase/config.toml`:
+
+```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:
+
+```ts
+// 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:
+
+```ts
+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`.
+
+## Environment
+
+| Variable | Default | Purpose |
+| ------------------------ | ---------------- | -------------------------------- |
+| `MCP_SERVER_NAME` | `supabase-mcp` | Server name shown to MCP clients |
+| `MCP_SERVER_DESCRIPTION` | Generic sentence | Instructions shown to clients |
+
+## Deploy
+
+Check the function before serving or deploying it:
+
+```bash
+cd supabase/functions/mcp-server
+deno task check
+cd ../../..
+supabase functions serve mcp-server --env-file supabase/functions/.env
+```
+
+Then deploy:
+
+```bash
+supabase config push
+supabase functions deploy mcp-server
+```
+
+## Further reading
+
+- [OAuth Consent block](../nextjs/oauth-consent)
+- [OAuth protected resource middleware](https://supabase.com/docs/reference/server/middleware-withoauthprotectedresource)
+- [MCP authentication](https://supabase.com/docs/guides/auth/oauth-server/mcp-authentication)
+- [OAuth 2.1 server](https://supabase.com/docs/guides/auth/oauth-server/getting-started)
+- [Token security and RLS](https://supabase.com/docs/guides/auth/oauth-server/token-security)
+- [OAuth grant management](https://supabase.com/docs/guides/auth/oauth-server/oauth-flows#managing-user-grants)
+- [Edge Functions](https://supabase.com/docs/guides/functions)
diff --git a/apps/ui-library/public/r/mcp-server.json b/apps/ui-library/public/r/mcp-server.json
new file mode 100644
index 0000000000000..d0a1b02387b22
--- /dev/null
+++ b/apps/ui-library/public/r/mcp-server.json
@@ -0,0 +1,52 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
+ "name": "mcp-server",
+ "type": "registry:item",
+ "title": "MCP Server",
+ "description": "Add a user-scoped MCP server to your product.",
+ "files": [
+ {
+ "path": "registry/default/blocks/mcp-server/supabase/functions/mcp-server/index.ts",
+ "content": "import 'jsr:@supabase/functions-js@2.108.2/edge-runtime.d.ts'\n\nimport { createMcpHandler, McpServer } from 'npm:@modelcontextprotocol/server@2.0.0'\nimport {\n withOAuthProtectedResource,\n withSupabase,\n type SupabaseContext,\n} from 'npm:@supabase/server@1.5.1'\n\nimport { registerTools, type ToolContext } from './tools/index.ts'\n\n// An MCP server as a single Supabase Edge Function. withSupabase accepts any\n// verified user access token and builds an RLS-scoped client, so both embedded\n// product agents and external OAuth clients can act as the signed-in user.\n//\n// withOAuthProtectedResource adds OAuth discovery for external MCP clients and\n// points authentication failures at it. Tools are composed in ./tools/index.ts.\n\nfunction readTextEnv(name: string, fallback: string): string {\n return Deno.env.get(name)?.trim() || fallback\n}\n\nconst SERVER_NAME = readTextEnv('MCP_SERVER_NAME', 'supabase-mcp')\nconst SERVER_DESCRIPTION = readTextEnv(\n 'MCP_SERVER_DESCRIPTION',\n 'MCP access to this Supabase project for the signed-in user.'\n)\n\nconst SERVER_INSTRUCTIONS =\n `${SERVER_DESCRIPTION} ` +\n 'Every tool runs as the signed-in Supabase user, so role grants and Row Level Security apply. ' +\n \"Call tools/list to discover what this project exposes, and read a tool's description and \" +\n 'annotations before calling it — some tools have side effects.'\n\nconst CORS_HEADERS: Record = {\n 'Access-Control-Allow-Origin': '*',\n 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',\n 'Access-Control-Allow-Headers':\n 'Authorization, Content-Type, Accept, Mcp-Protocol-Version, Mcp-Session-Id, Mcp-Method, Mcp-Name',\n 'Access-Control-Expose-Headers': 'WWW-Authenticate, Mcp-Session-Id',\n}\n\nfunction createServer(context: ToolContext): McpServer {\n const server = new McpServer(\n { name: SERVER_NAME, version: '1.0.0' },\n { instructions: SERVER_INSTRUCTIONS }\n )\n\n registerTools(server, context)\n return server\n}\n\nasync function handleMcp(request: Request, ctx: SupabaseContext): Promise {\n // The server and its tools are bound to this caller for exactly one request.\n const handler = createMcpHandler(\n () =>\n createServer({\n supabase: ctx.supabase,\n // auth: 'user' guarantees both claim shapes before this handler runs.\n userClaims: ctx.userClaims!,\n jwtClaims: ctx.jwtClaims!,\n }),\n { onerror: (error) => console.error('MCP request failed', error) }\n )\n\n return handler.fetch(request)\n}\n\nDeno.serve(\n withOAuthProtectedResource(\n withSupabase({ auth: 'user', cors: { headers: CORS_HEADERS } }, handleMcp)\n )\n)\n",
+ "type": "registry:file",
+ "target": "supabase/functions/mcp-server/index.ts"
+ },
+ {
+ "path": "registry/default/blocks/mcp-server/supabase/functions/mcp-server/deno.json",
+ "content": "{\n \"nodeModulesDir\": \"none\",\n \"compilerOptions\": {\n \"strict\": true\n },\n \"tasks\": {\n \"check\": \"deno check index.ts\"\n }\n}\n",
+ "type": "registry:file",
+ "target": "supabase/functions/mcp-server/deno.json"
+ },
+ {
+ "path": "registry/default/blocks/mcp-server/supabase/functions/mcp-server/.env.example",
+ "content": "# Copy this file to supabase/functions/.env before serving locally:\n# cp supabase/functions/mcp-server/.env.example supabase/functions/.env\n# supabase functions serve mcp-server --env-file supabase/functions/.env\n\n# Keep the protocol-level server name short and project-specific.\nMCP_SERVER_NAME=supabase-mcp\nMCP_SERVER_DESCRIPTION=\"MCP access to this Supabase project for the signed-in user.\"\n",
+ "type": "registry:file",
+ "target": "supabase/functions/mcp-server/.env.example"
+ },
+ {
+ "path": "registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/types.ts",
+ "content": "import type { SupabaseContext } from 'npm:@supabase/server@1.5.1'\nimport type { SupabaseClient } from 'npm:@supabase/supabase-js@2.108.2'\n\n// Only expose the user-scoped client and verified identity to tools. Keeping\n// supabaseAdmin out of this type makes bypassing RLS an explicit design choice.\nexport type ToolContext = {\n supabase: SupabaseClient\n userClaims: NonNullable\n jwtClaims: NonNullable\n}\n",
+ "type": "registry:file",
+ "target": "supabase/functions/mcp-server/tools/types.ts"
+ },
+ {
+ "path": "registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/result.ts",
+ "content": "import type { CallToolResult } from 'npm:@modelcontextprotocol/server@2.0.0'\n\n// Shared helpers for building MCP tool results, so every tool returns the same\n// shape and signals failure the same way.\n\n/**\n * A successful structured result with a JSON text fallback for older clients.\n */\nexport function jsonResult(value: unknown): CallToolResult {\n return {\n content: [{ type: 'text', text: JSON.stringify(value) ?? 'null' }],\n structuredContent: value ?? null,\n }\n}\n\n/**\n * A failed result. The message goes back to the model so it can correct itself,\n * so keep it actionable — and free of credentials, claims, and stack traces.\n */\nexport function errorResult(message: string): CallToolResult {\n return {\n isError: true,\n content: [{ type: 'text', text: message }],\n }\n}\n\nfunction readString(value: unknown, key: string): string | null {\n if (!value || typeof value !== 'object' || !(key in value)) return null\n const property = (value as Record)[key]\n return typeof property === 'string' && property ? property : null\n}\n\n/**\n * Turn an unknown thrown value into a safe MCP error. Supabase API errors often\n * carry a `code` and `hint`, both of which help a model fix its next call.\n */\nexport function runtimeErrorResult(error: unknown): CallToolResult {\n const message = error instanceof Error ? error.message : String(error)\n const code = readString(error, 'code')\n const hint = readString(error, 'hint')\n\n return errorResult(\n [code ? `[${code}]` : null, message, hint ? `Hint: ${hint}` : null].filter(Boolean).join(' ')\n )\n}\n",
+ "type": "registry:file",
+ "target": "supabase/functions/mcp-server/tools/result.ts"
+ },
+ {
+ "path": "registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/whoami.ts",
+ "content": "import type { McpServer } from 'npm:@modelcontextprotocol/server@2.0.0'\n\nimport { jsonResult } from './result.ts'\nimport type { ToolContext } from './types.ts'\n\n// Answers from verified claims, demonstrating that every tool runs as the\n// signed-in user. client_id is present for OAuth tokens and null for ordinary\n// product sessions.\nexport function registerWhoamiTool(\n server: McpServer,\n { userClaims, jwtClaims }: ToolContext\n): void {\n const clientId =\n typeof jwtClaims?.client_id === 'string' && jwtClaims.client_id ? jwtClaims.client_id : null\n\n server.registerTool(\n 'whoami',\n {\n description: \"Return the signed-in user's identity and OAuth client id, when present.\",\n annotations: {\n readOnlyHint: true,\n destructiveHint: false,\n openWorldHint: false,\n },\n },\n () =>\n jsonResult({\n id: userClaims.id,\n email: userClaims.email ?? null,\n role: userClaims.role ?? null,\n client_id: clientId,\n })\n )\n}\n",
+ "type": "registry:file",
+ "target": "supabase/functions/mcp-server/tools/whoami.ts"
+ },
+ {
+ "path": "registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/index.ts",
+ "content": "import type { McpServer } from 'npm:@modelcontextprotocol/server@2.0.0'\n\nimport type { ToolContext } from './types.ts'\nimport { registerWhoamiTool } from './whoami.ts'\n\nexport type { ToolContext } from './types.ts'\n\n// The one composition point for this server. Add one registration call for\n// each tool module; the MCP SDK rejects duplicate protocol tool names.\nexport function registerTools(server: McpServer, context: ToolContext): void {\n registerWhoamiTool(server, context)\n}\n",
+ "type": "registry:file",
+ "target": "supabase/functions/mcp-server/tools/index.ts"
+ }
+ ],
+ "docs": "Disable gateway JWT verification, then deploy the Edge Function. A trusted product backend can call it with the signed-in user's access token. For external clients, install the [OAuth Consent block](https://supabase.com/library/docs/nextjs/oauth-consent), enable OAuth and dynamic registration, and set the Auth Site URL to the consent app. Every call runs through the user's RLS-scoped client. OAuth tokens include `client_id`; product sessions do not, so define policies for both paths. See [MCP authentication](https://supabase.com/docs/guides/auth/oauth-server/mcp-authentication) and [token security](https://supabase.com/docs/guides/auth/oauth-server/token-security)."
+}
\ No newline at end of file
diff --git a/apps/ui-library/public/r/registry.json b/apps/ui-library/public/r/registry.json
index 4afb6f79ca526..297a50fdbdbaa 100644
--- a/apps/ui-library/public/r/registry.json
+++ b/apps/ui-library/public/r/registry.json
@@ -1746,6 +1746,50 @@
}
]
},
+ {
+ "name": "mcp-server",
+ "type": "registry:item",
+ "title": "MCP Server",
+ "description": "Add a user-scoped MCP server to your product.",
+ "docs": "Disable gateway JWT verification, then deploy the Edge Function. A trusted product backend can call it with the signed-in user's access token. For external clients, install the [OAuth Consent block](https://supabase.com/library/docs/nextjs/oauth-consent), enable OAuth and dynamic registration, and set the Auth Site URL to the consent app. Every call runs through the user's RLS-scoped client. OAuth tokens include `client_id`; product sessions do not, so define policies for both paths. See [MCP authentication](https://supabase.com/docs/guides/auth/oauth-server/mcp-authentication) and [token security](https://supabase.com/docs/guides/auth/oauth-server/token-security).",
+ "files": [
+ {
+ "path": "registry/default/blocks/mcp-server/supabase/functions/mcp-server/index.ts",
+ "type": "registry:file",
+ "target": "supabase/functions/mcp-server/index.ts"
+ },
+ {
+ "path": "registry/default/blocks/mcp-server/supabase/functions/mcp-server/deno.json",
+ "type": "registry:file",
+ "target": "supabase/functions/mcp-server/deno.json"
+ },
+ {
+ "path": "registry/default/blocks/mcp-server/supabase/functions/mcp-server/.env.example",
+ "type": "registry:file",
+ "target": "supabase/functions/mcp-server/.env.example"
+ },
+ {
+ "path": "registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/types.ts",
+ "type": "registry:file",
+ "target": "supabase/functions/mcp-server/tools/types.ts"
+ },
+ {
+ "path": "registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/result.ts",
+ "type": "registry:file",
+ "target": "supabase/functions/mcp-server/tools/result.ts"
+ },
+ {
+ "path": "registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/whoami.ts",
+ "type": "registry:file",
+ "target": "supabase/functions/mcp-server/tools/whoami.ts"
+ },
+ {
+ "path": "registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/index.ts",
+ "type": "registry:file",
+ "target": "supabase/functions/mcp-server/tools/index.ts"
+ }
+ ]
+ },
{
"name": "oauth-consent-nextjs",
"type": "registry:block",
diff --git a/apps/ui-library/registry/blocks.ts b/apps/ui-library/registry/blocks.ts
index f1c045df99922..73fcff5d5fc5f 100644
--- a/apps/ui-library/registry/blocks.ts
+++ b/apps/ui-library/registry/blocks.ts
@@ -4,6 +4,7 @@ import { clients } from './clients'
import currentUserAvatar from './default/blocks/current-user-avatar/registry-item.json' with { type: 'json' }
import dropzone from './default/blocks/dropzone/registry-item.json' with { type: 'json' }
import infiniteQueryHook from './default/blocks/infinite-query-hook/registry-item.json' with { type: 'json' }
+import mcpServer from './default/blocks/mcp-server/registry-item.json' with { type: 'json' }
import oauthConsentNextjs from './default/blocks/oauth-consent-nextjs/registry-item.json' with { type: 'json' }
import oauthConsentReactRouter from './default/blocks/oauth-consent-react-router/registry-item.json' with { type: 'json' }
import oauthConsentReact from './default/blocks/oauth-consent-react/registry-item.json' with { type: 'json' }
@@ -70,6 +71,10 @@ export const blocks = [
// infinite query hook is intentionally not combined with the clients since it depends on clients having database types.
infiniteQueryHook as RegistryItem,
+ // Backend-only Deno Edge Function block. Every file has an explicit target,
+ // so it can be installed directly into a Supabase project.
+ mcpServer as RegistryItem,
+
withClientAndDocs(oauthConsentNextjs as RegistryItem, nextjsClient!),
withClientAndDocs(oauthConsentReact as RegistryItem, reactClient!),
withClientAndDocs(oauthConsentReactRouter as RegistryItem, reactRouterClient!),
diff --git a/apps/ui-library/registry/default/blocks/mcp-server/registry-item.json b/apps/ui-library/registry/default/blocks/mcp-server/registry-item.json
new file mode 100644
index 0000000000000..db05367a31b9e
--- /dev/null
+++ b/apps/ui-library/registry/default/blocks/mcp-server/registry-item.json
@@ -0,0 +1,44 @@
+{
+ "name": "mcp-server",
+ "type": "registry:item",
+ "title": "MCP Server",
+ "description": "Add a user-scoped MCP server to your product.",
+ "docs": "Disable gateway JWT verification, then deploy the Edge Function. A trusted product backend can call it with the signed-in user's access token. For external clients, install the [OAuth Consent block](https://supabase.com/library/docs/nextjs/oauth-consent), enable OAuth and dynamic registration, and set the Auth Site URL to the consent app. Every call runs through the user's RLS-scoped client. OAuth tokens include `client_id`; product sessions do not, so define policies for both paths. See [MCP authentication](https://supabase.com/docs/guides/auth/oauth-server/mcp-authentication) and [token security](https://supabase.com/docs/guides/auth/oauth-server/token-security).",
+ "files": [
+ {
+ "path": "registry/default/blocks/mcp-server/supabase/functions/mcp-server/index.ts",
+ "type": "registry:file",
+ "target": "supabase/functions/mcp-server/index.ts"
+ },
+ {
+ "path": "registry/default/blocks/mcp-server/supabase/functions/mcp-server/deno.json",
+ "type": "registry:file",
+ "target": "supabase/functions/mcp-server/deno.json"
+ },
+ {
+ "path": "registry/default/blocks/mcp-server/supabase/functions/mcp-server/.env.example",
+ "type": "registry:file",
+ "target": "supabase/functions/mcp-server/.env.example"
+ },
+ {
+ "path": "registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/types.ts",
+ "type": "registry:file",
+ "target": "supabase/functions/mcp-server/tools/types.ts"
+ },
+ {
+ "path": "registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/result.ts",
+ "type": "registry:file",
+ "target": "supabase/functions/mcp-server/tools/result.ts"
+ },
+ {
+ "path": "registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/whoami.ts",
+ "type": "registry:file",
+ "target": "supabase/functions/mcp-server/tools/whoami.ts"
+ },
+ {
+ "path": "registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/index.ts",
+ "type": "registry:file",
+ "target": "supabase/functions/mcp-server/tools/index.ts"
+ }
+ ]
+}
diff --git a/apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/.env.example b/apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/.env.example
new file mode 100644
index 0000000000000..3e99789d9ee5a
--- /dev/null
+++ b/apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/.env.example
@@ -0,0 +1,7 @@
+# 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."
diff --git a/apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/deno.json b/apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/deno.json
new file mode 100644
index 0000000000000..193d6e74a80d6
--- /dev/null
+++ b/apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/deno.json
@@ -0,0 +1,9 @@
+{
+ "nodeModulesDir": "none",
+ "compilerOptions": {
+ "strict": true
+ },
+ "tasks": {
+ "check": "deno check index.ts"
+ }
+}
diff --git a/apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/index.ts b/apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/index.ts
new file mode 100644
index 0000000000000..95278a36be263
--- /dev/null
+++ b/apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/index.ts
@@ -0,0 +1,73 @@
+import 'jsr:@supabase/functions-js@2.108.2/edge-runtime.d.ts'
+
+import { createMcpHandler, McpServer } from 'npm:@modelcontextprotocol/server@2.0.0'
+import {
+ withOAuthProtectedResource,
+ withSupabase,
+ type SupabaseContext,
+} from 'npm:@supabase/server@1.5.1'
+
+import { registerTools, type ToolContext } from './tools/index.ts'
+
+// An MCP server as a single Supabase Edge Function. withSupabase accepts any
+// verified user access token and builds an RLS-scoped client, so both embedded
+// product agents and external OAuth clients can act as the signed-in user.
+//
+// withOAuthProtectedResource adds OAuth discovery for external MCP clients and
+// points authentication failures at it. Tools are composed in ./tools/index.ts.
+
+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 = {
+ '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 {
+ // 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)
+}
+
+Deno.serve(
+ withOAuthProtectedResource(
+ withSupabase({ auth: 'user', cors: { headers: CORS_HEADERS } }, handleMcp)
+ )
+)
diff --git a/apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/index.ts b/apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/index.ts
new file mode 100644
index 0000000000000..8106f70d70f88
--- /dev/null
+++ b/apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/index.ts
@@ -0,0 +1,12 @@
+import type { McpServer } from 'npm:@modelcontextprotocol/server@2.0.0'
+
+import type { ToolContext } from './types.ts'
+import { registerWhoamiTool } from './whoami.ts'
+
+export type { ToolContext } from './types.ts'
+
+// The one composition point for this server. Add one registration call for
+// each tool module; the MCP SDK rejects duplicate protocol tool names.
+export function registerTools(server: McpServer, context: ToolContext): void {
+ registerWhoamiTool(server, context)
+}
diff --git a/apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/result.ts b/apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/result.ts
new file mode 100644
index 0000000000000..33b7071810cb4
--- /dev/null
+++ b/apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/result.ts
@@ -0,0 +1,45 @@
+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)[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(' ')
+ )
+}
diff --git a/apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/types.ts b/apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/types.ts
new file mode 100644
index 0000000000000..e88542238b54b
--- /dev/null
+++ b/apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/types.ts
@@ -0,0 +1,10 @@
+import type { SupabaseContext } from 'npm:@supabase/server@1.5.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
+ jwtClaims: NonNullable
+}
diff --git a/apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/whoami.ts b/apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/whoami.ts
new file mode 100644
index 0000000000000..f01872073e49a
--- /dev/null
+++ b/apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/whoami.ts
@@ -0,0 +1,34 @@
+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,
+ })
+ )
+}
diff --git a/apps/ui-library/tsconfig.json b/apps/ui-library/tsconfig.json
index 6d096180fd06a..ea4050eb454c3 100644
--- a/apps/ui-library/tsconfig.json
+++ b/apps/ui-library/tsconfig.json
@@ -17,5 +17,9 @@
".next/types/**/*.ts",
".contentlayer/generated"
],
- "exclude": ["node_modules", "./scripts/build-registry.mts"]
+ "exclude": [
+ "node_modules",
+ "./scripts/build-registry.mts",
+ "registry/default/blocks/*/supabase/**"
+ ]
}
From d088ec6259a1ebce904e68c596b6867aec7cd8d7 Mon Sep 17 00:00:00 2001
From: "kemal.earth" <606977+kemaldotearth@users.noreply.github.com>
Date: Wed, 2 Sep 2026 09:29:58 +0100
Subject: [PATCH 3/6] fix(studio): project selector fetch on scoped pat
(#49865)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.
YES
## What kind of change does this PR introduce?
The scoped-access-token project selector fetched a single page of the
user's projects across all orgs and filtered client-side, so switching
to an org whose projects weren't in that page left the list empty with
no way to load more. Use the org-scoped projects query instead, keyed on
the selected org, and fix project search to match by name.
## Summary by CodeRabbit
* **Bug Fixes**
* Project selection now displays projects belonging to the currently
selected organization.
* Switching organizations refreshes the available project list,
preventing projects from another organization from appearing.
* **Tests**
* Added coverage for organization-specific project loading, organization
switching, pagination, and empty project lists.
---
.../AccessTokens/AccessToken.fixtures.ts | 34 ++++++++++
.../Scoped/Form/ResourceAccessStep.test.tsx | 62 +++++++++++++++++++
.../Scoped/Form/ResourceAccessStep.tsx | 40 ++++++------
.../Scoped/NewScopedTokenSheet.test.tsx | 25 ++++++++
4 files changed, 140 insertions(+), 21 deletions(-)
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.fixtures.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.fixtures.ts
index 830692e2b2c62..ca320cd6ab682 100644
--- a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.fixtures.ts
+++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.fixtures.ts
@@ -16,6 +16,8 @@ import type { Permission } from '@/types'
type AccessControlPermission = components['schemas']['AccessControlPermission']
type OrganizationResponse = components['schemas']['OrganizationResponse']
type ProjectsResponse = components['schemas']['ListProjectsPaginatedResponse']
+type OrganizationProjectsResponse = components['schemas']['OrganizationProjectsResponse']
+type OrganizationProject = OrganizationProjectsResponse['projects'][number]
/** Satisfies both Studio's `Permission` type and the API's `AccessControlPermission` row shape. */
export type PermissionRowFixture = Permission & {
@@ -73,7 +75,27 @@ export const ownerRows = (slug: string, refs: string[] = []) => [
]
export const MOCK_ORG = { slug: 'acme-prod', name: 'Acme Production' }
+export const MOCK_ORG_2 = { slug: 'acme-staging', name: 'Acme Staging' }
export const MOCK_PROJECT = { ref: 'project-1', name: 'Project 1' }
+export const MOCK_PROJECT_2 = { ref: 'project-2', name: 'Project 2' }
+
+const toOrganizationProject = (project: { ref: string; name: string }): OrganizationProject => ({
+ cloud_provider: 'AWS',
+ databases: [],
+ inserted_at: new Date().toISOString(),
+ integration_source: null,
+ is_branch: false,
+ name: project.name,
+ ref: project.ref,
+ region: 'us-east-1',
+ status: 'ACTIVE_HEALTHY',
+})
+
+/** Per-org project lists backing the `/platform/organizations/{slug}/projects` mock below. */
+const PROJECTS_BY_ORG: Record = {
+ [MOCK_ORG.slug]: [MOCK_PROJECT],
+ [MOCK_ORG_2.slug]: [MOCK_PROJECT_2],
+}
/**
* Registers the GET mocks every scoped-token surface fires on mount: one organization
@@ -103,6 +125,18 @@ export const mockScopedTokenEnvironment = () => {
],
}),
})
+ addAPIMock({
+ method: 'get',
+ path: '/platform/organizations/:slug/projects',
+ response: ({ params }) => {
+ const slug = (params as { slug: string }).slug
+ const projects = (PROJECTS_BY_ORG[slug] ?? []).map(toOrganizationProject)
+ return HttpResponse.json({
+ projects,
+ pagination: { count: projects.length, limit: 100, offset: 0 },
+ })
+ },
+ })
addAPIMock({
method: 'get',
// @ts-expect-error Studio API is missing from types
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccessStep.test.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccessStep.test.tsx
index d21edc783a82c..84f80fef84263 100644
--- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccessStep.test.tsx
+++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccessStep.test.tsx
@@ -1,18 +1,26 @@
import { fireEvent, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
+import { platformComponents as components } from 'api-types'
+import { HttpResponse } from 'msw'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import {
MOCK_ORG,
+ MOCK_ORG_2,
MOCK_PROJECT,
+ MOCK_PROJECT_2,
mockPermissionsApi,
mockScopedTokenEnvironment,
readonlyRows,
} from '../../AccessToken.fixtures'
import { NewScopedTokenSheet } from '../NewScopedTokenSheet'
+import { createMockOrganizationResponse } from '@/tests/helpers'
import { customRender } from '@/tests/lib/custom-render'
+import { addAPIMock } from '@/tests/lib/msw'
import { createMockProfileContext } from '@/tests/lib/profile-helpers'
+type OrganizationResponse = components['schemas']['OrganizationResponse']
+
// Disabling orgs for project-scoped members reads /platform/profile/permissions, which only
// fires on the platform for a logged-in user — neither is true in the default test environment.
vi.mock('common', async (importOriginal) => {
@@ -68,3 +76,57 @@ describe('ResourceAccessStep organization selector', () => {
).toBeNull()
})
})
+
+describe('ResourceAccessStep project selector', () => {
+ beforeEach(() => {
+ mockScopedTokenEnvironment()
+ })
+
+ const openTokenForm = async () => {
+ customRender( {}} />, {
+ profileContext: createMockProfileContext(),
+ })
+ fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' }))
+ await screen.findByRole('dialog')
+ }
+
+ const selectOrganization = async (name: string) => {
+ fireEvent.click(await screen.findByRole('combobox', { name: 'Organization' }))
+ fireEvent.click(await screen.findByRole('option', { name }))
+ }
+
+ test('loads projects scoped to the selected organization', async () => {
+ mockPermissionsApi(readonlyRows(MOCK_ORG.slug))
+ await openTokenForm()
+ await selectOrganization(MOCK_ORG.name)
+
+ fireEvent.click(await screen.findByRole('combobox', { name: 'Projects' }))
+ expect(await screen.findByRole('option', { name: MOCK_PROJECT.name })).toBeInTheDocument()
+ })
+
+ // Regression test: the project list used to be fetched cross-org (a single page of the user's
+ // first 100 projects, filtered client-side by org), so switching to an org whose projects
+ // didn't fall in that page left the selector permanently empty.
+ test('refreshes the project list when switching organizations', async () => {
+ addAPIMock({
+ method: 'get',
+ path: '/platform/organizations',
+ response: () =>
+ HttpResponse.json([
+ createMockOrganizationResponse({ slug: MOCK_ORG.slug, name: MOCK_ORG.name }),
+ createMockOrganizationResponse({ slug: MOCK_ORG_2.slug, name: MOCK_ORG_2.name }),
+ ]),
+ })
+ mockPermissionsApi([...readonlyRows(MOCK_ORG.slug), ...readonlyRows(MOCK_ORG_2.slug)])
+
+ await openTokenForm()
+ await selectOrganization(MOCK_ORG.name)
+ fireEvent.click(await screen.findByRole('combobox', { name: 'Projects' }))
+ expect(await screen.findByRole('option', { name: MOCK_PROJECT.name })).toBeInTheDocument()
+
+ await selectOrganization(MOCK_ORG_2.name)
+ fireEvent.click(await screen.findByRole('combobox', { name: 'Projects' }))
+ expect(await screen.findByRole('option', { name: MOCK_PROJECT_2.name })).toBeInTheDocument()
+ expect(screen.queryByRole('option', { name: MOCK_PROJECT.name })).toBeNull()
+ })
+})
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccessStep.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccessStep.tsx
index 0743b1c3201c8..e8678654068e5 100644
--- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccessStep.tsx
+++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccessStep.tsx
@@ -30,10 +30,10 @@ import { InlineLinkClassName } from '@/components/ui/InlineLink'
import { useOrganizationsQuery } from '@/data/organizations/organizations-query'
import { usePermissionsQuery } from '@/data/permissions/permissions-query'
import {
- ProjectInfoInfinite,
- ProjectsInfiniteData,
- useProjectsInfiniteQuery,
-} from '@/data/projects/projects-infinite-query'
+ OrgProject,
+ OrgProjectsResponse,
+ useOrgProjectsInfiniteQuery,
+} from '@/data/projects/org-projects-infinite-query'
import { Organization } from '@/types'
interface ResourceAccessStepProps {
@@ -69,15 +69,21 @@ export const ResourceAccessStep = ({
onSelectLegacyToken,
}: ResourceAccessStepProps) => {
const { data: organizations = [] } = useOrganizationsQuery()
+
+ const resourceAccess = useWatch({ control, name: 'resourceAccess' })
+ const organizationSlugs = useWatch({ control, name: 'organizationSlugs', defaultValue: [] })
+ const selectedOrgSlug = organizationSlugs[0]
+
const {
data: projectsData,
hasNextPage,
fetchNextPage,
- } = useProjectsInfiniteQuery({
+ } = useOrgProjectsInfiniteQuery({
+ slug: selectedOrgSlug,
limit: 100,
})
- const projects = useMemo(
+ const projectsForOrg = useMemo(
() => projectsData?.pages.flatMap((page) => page.projects) ?? [],
[projectsData]
)
@@ -94,23 +100,20 @@ export const ResourceAccessStep = ({
)
const projectsByRef = useMemo(
() =>
- projects.reduce(
+ projectsForOrg.reduce(
(acc, project) => {
acc[project.ref] = project
return acc
},
- {} as Record
+ {} as Record
),
- [projects]
+ [projectsForOrg]
)
- const resourceAccess = useWatch({ control, name: 'resourceAccess' })
- const organizationSlugs = useWatch({ control, name: 'organizationSlugs', defaultValue: [] })
-
// Users invited to specific projects (rather than the whole org) can't select that org for an
// org-wide token. Skipped while permissions are still loading so nothing gets disabled by
- // mistake. The project list itself needs no permission filter — /platform/projects is already
- // scoped server-side to what the user can access.
+ // mistake. The project list itself needs no permission filter — the org projects endpoint is
+ // already scoped server-side to what the user can access.
const { data: permissions } = usePermissionsQuery()
const projectScopedOrgSlugs = useMemo(() => {
if (permissions === undefined) return new Set()
@@ -121,11 +124,6 @@ export const ResourceAccessStep = ({
)
}, [permissions, organizations])
- const projectsForOrg = useMemo(
- () => projects.filter((project) => organizationSlugs.includes(project.organization_slug)),
- [projects, organizationSlugs]
- )
-
return (
void
}) => {
@@ -339,7 +337,7 @@ const ProjectMultiSelectList = ({
return (
{projects.map((project) => (
-
+
{project.name}
))}
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx
index 1b4c3072a2c3e..7f4480086bfeb 100644
--- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx
+++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx
@@ -12,6 +12,7 @@ import { addAPIMock } from '@/tests/lib/msw'
type OrganizationResponse = components['schemas']['OrganizationResponse']
type ProjectsResponse = components['schemas']['ListProjectsPaginatedResponse']
+type OrganizationProjectsResponse = components['schemas']['OrganizationProjectsResponse']
type CreateTokenResponse = components['schemas']['CreateScopedAccessTokenResponse']
type CreateClassicTokenResponse = components['schemas']['CreateAccessTokenResponse']
@@ -81,6 +82,29 @@ const mockProjects = () =>
}),
})
+const mockOrgProjects = () =>
+ addAPIMock({
+ method: 'get',
+ path: '/platform/organizations/:slug/projects',
+ response: () =>
+ HttpResponse.json({
+ pagination: { count: 1, limit: 100, offset: 0 },
+ projects: [
+ {
+ cloud_provider: 'AWS',
+ databases: [],
+ inserted_at: new Date().toISOString(),
+ integration_source: null,
+ is_branch: false,
+ name: 'Project 1',
+ ref: 'project-1',
+ region: 'us-east-1',
+ status: 'ACTIVE_HEALTHY',
+ },
+ ],
+ }),
+ })
+
const mockPermissionsMap = () =>
addAPIMock({
method: 'get',
@@ -145,6 +169,7 @@ describe('NewScopedTokenSheet', () => {
mockPermissionsMap()
mockOrganizations()
mockProjects()
+ mockOrgProjects()
mockCreateToken()
mockCreateClassicToken()
})
From cecb3281201d6990437ba01bf4518c0ba3598cc2 Mon Sep 17 00:00:00 2001
From: "kemal.earth" <606977+kemaldotearth@users.noreply.github.com>
Date: Wed, 2 Sep 2026 09:32:36 +0100
Subject: [PATCH 4/6] feat(studio): clean up free tier upgrade box (#49851)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.
YES
## What kind of change does this PR introduce?
Small update of the Projects overview UI to resolve the competing CTA's
as well as alignment. Open to tweak a little more here.
| Before | After |
|--------|--------|
|
|
|
## Summary by CodeRabbit
* **Style**
* Refined the plan usage card layout with larger metric values and
cleaner labels.
* Added dividers between usage metrics for improved readability.
* Simplified card and loading-state styling by removing unnecessary
borders, backgrounds, and padding.
* Updated the upgrade button’s visual treatment.
---
.../interfaces/ProjectHome/PlanUsageCard.tsx | 68 ++++++-------------
1 file changed, 20 insertions(+), 48 deletions(-)
diff --git a/apps/studio/components/interfaces/ProjectHome/PlanUsageCard.tsx b/apps/studio/components/interfaces/ProjectHome/PlanUsageCard.tsx
index 740ff099c64b2..2b9b91e3ce302 100644
--- a/apps/studio/components/interfaces/ProjectHome/PlanUsageCard.tsx
+++ b/apps/studio/components/interfaces/ProjectHome/PlanUsageCard.tsx
@@ -14,7 +14,6 @@ type MetricConfig = {
key: PricingMetric
label: string
unit: MetricUnit
- /** Anchor id of the matching section on the org usage page. */
anchor: string
}
@@ -40,26 +39,15 @@ const METRICS: MetricConfig[] = [
},
]
-const formatGigabytes = (value: number) => {
- if (value === 0) return '0 GB'
- if (value < 1) return `${(value * 1000).toFixed(0)} MB`
- return `${value.toFixed(value < 10 ? 2 : 1)} GB`
-}
-
-const formatGigabyteLimit = (limit: number) => {
- if (limit < 1) return `${(limit * 1000).toFixed(0)} MB`
- return `${limit} GB`
-}
-
-// Show counts in full with thousands separators (e.g. `50,000`) rather than abbreviated
-// (`50k`), to match the pricing page and avoid ambiguity around plan limits.
const formatCount = (value: number) => value.toLocaleString()
-const formatValue = (value: number, unit: MetricUnit) =>
- unit === 'gigabytes' ? formatGigabytes(value) : formatCount(value)
-
-const formatLimit = (limit: number, unit: MetricUnit) =>
- unit === 'gigabytes' ? formatGigabyteLimit(limit) : formatCount(limit)
+const formatUsagePair = (value: number, limit: number, unit: MetricUnit) => {
+ if (unit === 'count') return { value: formatCount(value), limit: formatCount(limit) }
+ if (limit < 1) {
+ return { value: (value * 1000).toFixed(0), limit: `${(limit * 1000).toFixed(0)} MB` }
+ }
+ return { value: value === 0 ? '0' : value.toFixed(value < 10 ? 2 : 1), limit: `${limit} GB` }
+}
const RING_RADIUS = 7
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS
@@ -113,8 +101,6 @@ const ProgressRing = ({
)
}
-// The upgrade CTA surface this card represents. Used as the telemetry `source` +
-// `placement` value. Kept as a constant so the tracking stays explicit.
const PLACEMENT = 'org_projects_list'
const CompactMetricRow = ({
@@ -131,26 +117,25 @@ const CompactMetricRow = ({
const ratio = limit > 0 ? current / limit : 0
const isOver = limit > 0 && current >= limit
const isApproaching = limit > 0 && ratio >= 0.8 && !isOver
+ const formatted = formatUsagePair(current, limit, config.unit)
return (
-
+
-
- {config.label}
-
+
{config.label}
-
+
- {formatValue(current, config.unit)}
+ {formatted.value}
/
- {formatLimit(limit, config.unit)}
+ {formatted.limit}
(
-
-
+
+
(
)
-/**
- * Renders the upgrade CTA's plan-usage card in the org project list (the
- * `org_projects_list` CTA placement). The parent is responsible for gating on free plan —
- * this component only renders the visual sections once usage data is available. Shaped
- * like a `ProjectCard` so it reads as another tile.
- */
export const PlanUsageCard = () => {
const track = useTrack()
const { data: organization } = useSelectedOrganizationQuery()
@@ -199,21 +178,13 @@ export const PlanUsageCard = () => {
}).filter((row): row is { config: MetricConfig; usageItem: OrgMetricsUsage } => row !== null)
: []
- // Hide entirely on hard error or when the org has zero applicable metrics — both
- // are extreme edge cases. Otherwise always render the card shell so the layout is
- // reserved from first paint and the usage rows fade in once the query resolves.
if (isError) return null
if (isSuccess && visibleRows.length === 0) return null
return (
-
-
+
+
Free plan usage
Current billing cycle
@@ -222,11 +193,12 @@ export const PlanUsageCard = () => {
track('upgrade_cta_clicked', { placement: PLACEMENT })}
/>
-
+
{isSuccess
? visibleRows.map(({ config, usageItem }) => (
Date: Wed, 2 Sep 2026 14:14:11 +0500
Subject: [PATCH 5/6] add Warda Bibi to humans.txt (#49823)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.
YES/NO
## What kind of change does this PR introduce?
Bug fix, feature, docs update, ...
## What is the current behavior?
Please link any relevant issues here.
## What is the new behavior?
Feel free to include screenshots if it includes visual changes.
## Additional context
Add any other context or screenshots.
## Summary by CodeRabbit
* **Documentation**
* Added Warda Bibi to the team member list.
---
apps/docs/public/humans.txt | 1 +
1 file changed, 1 insertion(+)
diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt
index 78de85cb5b91b..b76d83780bef5 100644
--- a/apps/docs/public/humans.txt
+++ b/apps/docs/public/humans.txt
@@ -318,6 +318,7 @@ Tyler Shukert
TzeYiing L
Utkarash Singh
Victor Farazdagi
+Warda Bibi
Warwick Mitchell
Wen Bo Xie
Wendie Cheung
From c7e181357c1f25936663a8d2a9c1b8423d0dc2d2 Mon Sep 17 00:00:00 2001
From: Dion Zeneli <101271736+Dionysos288@users.noreply.github.com>
Date: Wed, 2 Sep 2026 11:42:29 +0200
Subject: [PATCH 6/6] Add Dion Zeneli to humans.txt (#49878)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.
YES
## What kind of change does this PR introduce?
docs update
## Summary by CodeRabbit
* **Documentation**
* Added Dion Zeneli to the alphabetical list of Supabase team members in
the project’s public team information.
---
apps/docs/public/humans.txt | 1 +
1 file changed, 1 insertion(+)
diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt
index b76d83780bef5..712db4ab82876 100644
--- a/apps/docs/public/humans.txt
+++ b/apps/docs/public/humans.txt
@@ -89,6 +89,7 @@ Deji I
Dennis Senn
Dimitrios Liappis
Div Arora
+Dion Zeneli
Divit D
Divya Sharma
Donna Alexandra