diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 0000000000000..b712dc09c0a96 --- /dev/null +++ b/.cursorignore @@ -0,0 +1 @@ +!.env.example \ No newline at end of file diff --git a/apps/docs/content/guides/getting-started/mcp.mdx b/apps/docs/content/guides/getting-started/mcp.mdx index e3dbd33fba978..737ae7347e74d 100644 --- a/apps/docs/content/guides/getting-started/mcp.mdx +++ b/apps/docs/content/guides/getting-started/mcp.mdx @@ -20,14 +20,6 @@ Choose your Supabase platform, project, and MCP client and follow the installati - - -Your MCP client will automatically prompt you to login to Supabase during setup. This will open a browser window where you can login to your Supabase account and grant access to the MCP client. Be sure to choose the organization that contains the project you wish to work with. In the future, we'll offer more fine grain control over these permissions. - -Previously Supabase MCP required you to generate a personal access token (PAT), but this is no longer required. - - - ### Next steps Your AI tool is now connected to your Supabase project or account using remote MCP. Try asking the AI tool to query your database using natural language commands. @@ -63,33 +55,3 @@ We recommend the following best practices to mitigate security risks when using - **Project scoping**: Scope your MCP server to a [specific project](https://github.com/supabase-community/supabase-mcp#project-scoped-mode), limiting access to only that project's resources. This prevents LLMs from accessing data from other projects in your Supabase account. - **Branching**: Use Supabase's [branching feature](/docs/guides/deployment/branching) to create a development branch for your database. This allows you to test changes in a safe environment before merging them to production. - **Feature groups**: The server allows you to enable or disable specific [tool groups](https://github.com/supabase-community/supabase-mcp#feature-groups), so you can control which tools are available to the LLM. This helps reduce the attack surface and limits the actions that LLMs can perform to only those that you need. - -## MCP for local Supabase instances - -The Supabase MCP server connects directly to the cloud platform to access your database. If you are running a local instance of Supabase, you can instead use the [Postgres MCP server](https://github.com/modelcontextprotocol/servers-archived/tree/main/src/postgres) to connect to your local database. This MCP server runs all queries as read-only transactions. - -### Step 1: Find your database connection string - -To connect to your local Supabase instance, you need to get the connection string for your local database. You can find your connection string by running: - -```shell -supabase status -``` - -or if you are using `npx`: - -```shell -npx supabase status -``` - -This will output a list of details about your local Supabase instance. Copy the `DB URL` field in the output. - -### Step 2: Configure the MCP server - -Configure your client with the following: - -<$Partial path="mcp_postgres_config.mdx" variables={{ "app": "your MCP client" }} /> - -### Next steps - -Your AI tool is now connected to your local Supabase instance using MCP. Try asking the AI tool to query your database using natural language commands. diff --git a/apps/docs/features/ui/McpConfigPanel.tsx b/apps/docs/features/ui/McpConfigPanel.tsx index 96411cd929994..be86b5261d51d 100644 --- a/apps/docs/features/ui/McpConfigPanel.tsx +++ b/apps/docs/features/ui/McpConfigPanel.tsx @@ -18,6 +18,7 @@ import { PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, } from 'ui' +import { Admonition } from 'ui-patterns' import { McpConfigPanel as McpConfigPanelBase } from 'ui-patterns/McpUrlBuilder' import { useProjectsQuery } from '~/lib/fetch/projects' @@ -193,28 +194,44 @@ export function McpConfigPanel() { const project = isPlatform ? selectedProject : null return ( -
-
- +
+
+ + {isPlatform && ( + + )} +
+

+ {isPlatform + ? 'Scope the MCP server to a project. If no project is selected, all projects will be accessible.' + : 'Project selection is only available for the hosted platform.'} +

+ - {isPlatform && ( - - )}
-

- {isPlatform - ? 'Scope the MCP server to a project. If no project is selected, all projects will be accessible.' - : 'Project selection is only available for the hosted platform.'} -

- -
+ {isPlatform && ( + +

+ { + "Your MCP client will automatically prompt you to login to Supabase during setup. This will open a browser window where you can login to your Supabase account and grant access to the MCP client. Be sure to choose the organization that contains the project you wish to work with. In the future, we'll offer more fine grain control over these permissions." + } +

+

+ { + 'Previously Supabase MCP required you to generate a personal access token (PAT), but this is no longer required.' + } +

+
+ )} + ) } diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets.tsx new file mode 100644 index 0000000000000..2da6510625794 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets.tsx @@ -0,0 +1,6 @@ +import { EmptyBucketState } from './EmptyBucketState' + +export const AnalyticsBuckets = () => { + // Placeholder component - will be implemented in a later PR + return +} diff --git a/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx b/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx index f1f8bdf305329..2586ce15220f6 100644 --- a/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx @@ -1,7 +1,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' import { snakeCase } from 'lodash' -import { Edit } from 'lucide-react' +import { Plus } from 'lucide-react' import { useRouter } from 'next/router' import { useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' @@ -51,6 +51,7 @@ import { } from 'ui' import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { useIsNewStorageUIEnabled } from '../App/FeaturePreview/FeaturePreviewContext' import { inverseValidBucketNameRegex, validBucketNameRegex } from './CreateBucketModal.utils' import { convertFromBytes, convertToBytes } from './StorageSettings/StorageSettings.utils' @@ -95,10 +96,23 @@ const formId = 'create-storage-bucket-form' export type CreateBucketForm = z.infer -export const CreateBucketModal = () => { +interface CreateBucketModalProps { + buttonSize?: 'tiny' | 'small' + buttonType?: 'default' | 'primary' + buttonClassName?: string + label?: string +} + +export const CreateBucketModal = ({ + buttonSize = 'tiny', + buttonType = 'default', + buttonClassName, + label = 'New bucket', +}: CreateBucketModalProps) => { const router = useRouter() const { ref } = useParams() const { data: org } = useSelectedOrganizationQuery() + const isStorageV2 = useIsNewStorageUIEnabled() const [visible, setVisible] = useState(false) const [selectedUnit, setSelectedUnit] = useState(StorageSizeUnits.MB) @@ -189,7 +203,7 @@ export const CreateBucketModal = () => { setSelectedUnit(StorageSizeUnits.MB) setVisible(false) - router.push(`/project/${ref}/storage/buckets/${values.name}`) + if (!isStorageV2) router.push(`/project/${ref}/storage/buckets/${values.name}`) } catch (error: any) { // Handle specific error cases for inline display const errorMessage = error.message?.toLowerCase() || '' @@ -228,8 +242,10 @@ export const CreateBucketModal = () => { } + size={buttonSize} + type={buttonType} + className={buttonClassName} + icon={} disabled={!canCreateBuckets} style={{ justifyContent: 'start' }} onClick={() => setVisible(true)} @@ -242,7 +258,7 @@ export const CreateBucketModal = () => { }, }} > - New bucket + {label} @@ -281,59 +297,61 @@ export const CreateBucketModal = () => { )} /> - ( - - - field.onChange(v)} - > - - {IS_PLATFORM && ( + {!isStorageV2 && ( + ( + + + field.onChange(v)} + > - <> -

- Stores Iceberg files and is optimized for analytical workloads. -

- - {icebergCatalogEnabled ? null : ( -
- - - This feature is currently in alpha and not yet enabled for your - project. Sign up{' '} - - here - - . - -
- )} - -
- )} -
-
-
- )} - /> + /> + {IS_PLATFORM && ( + + <> +

+ Stores Iceberg files and is optimized for analytical workloads. +

+ + {icebergCatalogEnabled ? null : ( +
+ + + This feature is currently in alpha and not yet enabled for + your project. Sign up{' '} + + here + + . + +
+ )} + +
+ )} +
+
+
+ )} + /> + )} diff --git a/apps/studio/components/interfaces/Storage/EmptyBucketModal.tsx b/apps/studio/components/interfaces/Storage/EmptyBucketModal.tsx index 3b3416834a9a4..597ae9ea7c56d 100644 --- a/apps/studio/components/interfaces/Storage/EmptyBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/EmptyBucketModal.tsx @@ -35,7 +35,7 @@ export const EmptyBucketModal = ({ visible, bucket, onClose }: EmptyBucketModalP folderName: bucket.name, index: -1, }) - toast.success(`Successfully deleted bucket ${bucket!.name}`) + toast.success(`Successfully emptied bucket ${bucket!.name}`) onClose() }, }) diff --git a/apps/studio/components/interfaces/Storage/EmptyBucketState.tsx b/apps/studio/components/interfaces/Storage/EmptyBucketState.tsx new file mode 100644 index 0000000000000..3b4e1a61a65e7 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/EmptyBucketState.tsx @@ -0,0 +1,25 @@ +import { CreateBucketModal } from './CreateBucketModal' +import { BUCKET_TYPES } from './Storage.constants' + +interface EmptyBucketStateProps { + bucketType: keyof typeof BUCKET_TYPES +} + +export const EmptyBucketState = ({ bucketType }: EmptyBucketStateProps) => { + const config = BUCKET_TYPES[bucketType] + + return ( + + ) +} diff --git a/apps/studio/components/interfaces/Storage/FilesBuckets.tsx b/apps/studio/components/interfaces/Storage/FilesBuckets.tsx new file mode 100644 index 0000000000000..b9bd99680197a --- /dev/null +++ b/apps/studio/components/interfaces/Storage/FilesBuckets.tsx @@ -0,0 +1,173 @@ +import { Edit, FolderOpen, MoreVertical, Search, Trash2 } from 'lucide-react' +import { useState } from 'react' + +import { useParams } from 'common' +import { ScaffoldSection } from 'components/layouts/Scaffold' +import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' +import { Bucket, useBucketsQuery } from 'data/storage/buckets-query' +import { + Button, + Card, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from 'ui' +import { Input } from 'ui-patterns/DataInputs/Input' +import { CreateBucketModal } from './CreateBucketModal' +import { DeleteBucketModal } from './DeleteBucketModal' +import { EditBucketModal } from './EditBucketModal' +import { EmptyBucketModal } from './EmptyBucketModal' +import { EmptyBucketState } from './EmptyBucketState' + +export const FilesBuckets = () => { + const { ref } = useParams() + + const [modal, setModal] = useState<'edit' | 'empty' | 'delete' | null>(null) + const [selectedBucket, setSelectedBucket] = useState() + const [filterString, setFilterString] = useState('') + + const { data: buckets = [], isLoading } = useBucketsQuery({ projectRef: ref }) + const filesBuckets = buckets + .filter((bucket) => !('type' in bucket) || bucket.type === 'STANDARD') + .filter((bucket) => + filterString.length === 0 + ? true + : bucket.name.toLowerCase().includes(filterString.toLowerCase()) + ) + return ( + <> + {!isLoading && + buckets.filter((bucket) => !('type' in bucket) || bucket.type === 'STANDARD').length === 0 ? ( + + ) : ( + +
+ setFilterString(e.target.value)} + icon={} + /> + +
+ + {isLoading ? ( + + ) : ( + + + + + Name + Visibility + + + + + {filesBuckets.length === 0 && filterString.length > 0 && ( + + +

No results found

+

+ Your search for "{filterString}" did not return any results +

+
+
+ )} + {filesBuckets.map((bucket) => ( + + +

{bucket.name}

+
+ +

+ {bucket.public ? 'Public' : 'Private'} +

+
+ +
+ + + +
+
+
+ ))} +
+
+
+ )} +
+ )} + + {selectedBucket && ( + <> + setModal(null)} + /> + setModal(null)} + /> + setModal(null)} + /> + + )} + + ) +} diff --git a/apps/studio/components/interfaces/Storage/Storage.constants.ts b/apps/studio/components/interfaces/Storage/Storage.constants.ts index 9dc6cae7b37b2..b2c75e211ac0a 100644 --- a/apps/studio/components/interfaces/Storage/Storage.constants.ts +++ b/apps/studio/components/interfaces/Storage/Storage.constants.ts @@ -65,20 +65,25 @@ export const CONTEXT_MENU_KEYS = { export const BUCKET_TYPES = { files: { displayName: 'Files', + label: 'a file bucket', description: 'General file storage for most types of digital content.', + valueProp: 'Store images, videos, documents, and any other file type.', docsUrl: `${DOCS_URL}/guides/storage/buckets/fundamentals`, }, analytics: { displayName: 'Analytics', + label: 'an analytics bucket', description: 'Purpose-built storage for analytical workloads.', + valueProp: 'Store large datasets for analytics and reporting.', docsUrl: `${DOCS_URL}/guides/storage/analytics/introduction`, }, vectors: { displayName: 'Vectors', + label: 'a vector bucket', description: 'Purpose-built storage for vector data.', + valueProp: 'Store, index, and query your vector embeddings at scale.', docsUrl: `${DOCS_URL}/guides/storage/vectors`, }, } - export const BUCKET_TYPE_KEYS = Object.keys(BUCKET_TYPES) as Array export const DEFAULT_BUCKET_TYPE: keyof typeof BUCKET_TYPES = 'files' diff --git a/apps/studio/components/interfaces/Storage/Storage.utils.ts b/apps/studio/components/interfaces/Storage/Storage.utils.ts index 36f64ef075fd3..5125475065232 100644 --- a/apps/studio/components/interfaces/Storage/Storage.utils.ts +++ b/apps/studio/components/interfaces/Storage/Storage.utils.ts @@ -1,4 +1,6 @@ -import { groupBy, difference } from 'lodash' +import { difference, groupBy } from 'lodash' +import { useRouter } from 'next/router' + import { STORAGE_CLIENT_LIBRARY_MAPPINGS } from './Storage.constants' import type { StoragePolicyFormField } from './Storage.types' @@ -188,3 +190,8 @@ export const createSQLPolicies = ( export const applyBucketIdToTemplateDefinition = (definition: string, bucketId: any) => { return definition.replace('{bucket_id}', `'${bucketId}'`) } + +export const useStorageV2Page = () => { + const router = useRouter() + return router.pathname.split('/')[4] as undefined | 'files' | 'analytics' | 'vectors' | 's3' +} diff --git a/apps/studio/components/interfaces/Storage/StorageMenuV2.tsx b/apps/studio/components/interfaces/Storage/StorageMenuV2.tsx index e5b8e8592efb3..65a82846ce074 100644 --- a/apps/studio/components/interfaces/Storage/StorageMenuV2.tsx +++ b/apps/studio/components/interfaces/Storage/StorageMenuV2.tsx @@ -1,29 +1,48 @@ -import { useParams } from 'common' import Link from 'next/link' + +import { IS_PLATFORM, useParams } from 'common' import { Menu } from 'ui' -import { BUCKET_TYPES, BUCKET_TYPE_KEYS, DEFAULT_BUCKET_TYPE } from './Storage.constants' +import { BUCKET_TYPES, BUCKET_TYPE_KEYS } from './Storage.constants' +import { useStorageV2Page } from './Storage.utils' export const StorageMenuV2 = () => { - const { ref, bucketType } = useParams() - const selectedBucketType = bucketType || DEFAULT_BUCKET_TYPE + const { ref } = useParams() + const page = useStorageV2Page() return ( - -
- Bucket Types} /> + +
+
+ Bucket Types} /> + + {BUCKET_TYPE_KEYS.map((bucketTypeKey) => { + const isSelected = page === bucketTypeKey + const config = BUCKET_TYPES[bucketTypeKey] + + return ( + + +

{config.displayName}

+
+ + ) + })} +
- {BUCKET_TYPE_KEYS.map((bucketTypeKey) => { - const isSelected = selectedBucketType === bucketTypeKey - const config = BUCKET_TYPES[bucketTypeKey] + {IS_PLATFORM && ( + <> +
+
+ Configuration} /> - return ( - - -

{config.displayName}

-
- - ) - })} + + +

S3

+
+ +
+ + )}
) diff --git a/apps/studio/components/interfaces/Storage/StorageSettings/S3Connection.tsx b/apps/studio/components/interfaces/Storage/StorageSettings/S3Connection.tsx index 1c3d302beea37..4be54f62c57a0 100644 --- a/apps/studio/components/interfaces/Storage/StorageSettings/S3Connection.tsx +++ b/apps/studio/components/interfaces/Storage/StorageSettings/S3Connection.tsx @@ -118,13 +118,22 @@ export const S3Connection = () => {
- S3 Connection + Connection - Connect to your bucket using any S3-compatible service via the S3 protocol + Connect to your bucket using any S3-compatible service via the S3 protocol.
+ + {isErrorStorageConfig && ( + + )} +
{projectIsLoading ? ( @@ -153,15 +162,6 @@ export const S3Connection = () => { )} /> - - {isErrorStorageConfig && ( -
- -
- )} @@ -227,7 +227,7 @@ export const S3Connection = () => {
- S3 Access Keys + Access keys Manage your access keys for this project. diff --git a/apps/studio/components/interfaces/Storage/VectorsBuckets.tsx b/apps/studio/components/interfaces/Storage/VectorsBuckets.tsx new file mode 100644 index 0000000000000..87e2dbbab12b1 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/VectorsBuckets.tsx @@ -0,0 +1,6 @@ +import { EmptyBucketState } from './EmptyBucketState' + +export const VectorsBuckets = () => { + // Placeholder component - will be implemented in a later PR + return +} diff --git a/apps/studio/components/layouts/StorageLayout/BucketLayout.tsx b/apps/studio/components/layouts/StorageLayout/BucketLayout.tsx deleted file mode 100644 index 53d2a252e6f2c..0000000000000 --- a/apps/studio/components/layouts/StorageLayout/BucketLayout.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { PropsWithChildren } from 'react' - -import { useParams } from 'common' -import { BUCKET_TYPES, DEFAULT_BUCKET_TYPE } from 'components/interfaces/Storage/Storage.constants' -import { DocsButton } from 'components/ui/DocsButton' -import DefaultLayout from '../DefaultLayout' -import { PageLayout } from '../PageLayout/PageLayout' -import { ScaffoldContainer } from '../Scaffold' -import StorageLayout from './StorageLayout' - -export const BucketTypeLayout = ({ children }: PropsWithChildren) => { - const { bucketType } = useParams() - const bucketTypeKey = bucketType || DEFAULT_BUCKET_TYPE - const config = BUCKET_TYPES[bucketTypeKey as keyof typeof BUCKET_TYPES] - const secondaryActions = [] - - return ( - - - - {children} - - - - ) -} diff --git a/apps/studio/components/layouts/StorageLayout/StorageBucketsLayout.tsx b/apps/studio/components/layouts/StorageLayout/StorageBucketsLayout.tsx new file mode 100644 index 0000000000000..1c9c46bcef535 --- /dev/null +++ b/apps/studio/components/layouts/StorageLayout/StorageBucketsLayout.tsx @@ -0,0 +1,58 @@ +import { useRouter } from 'next/router' +import { PropsWithChildren, useEffect } from 'react' + +import { useParams } from 'common' +import { useIsNewStorageUIEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { BUCKET_TYPES } from 'components/interfaces/Storage/Storage.constants' +import { useStorageV2Page } from 'components/interfaces/Storage/Storage.utils' +import { DocsButton } from 'components/ui/DocsButton' +import { PageLayout } from '../PageLayout/PageLayout' +import { ScaffoldContainer } from '../Scaffold' + +export const StorageBucketsLayout = ({ + title, + hideSubtitle = false, + children, +}: PropsWithChildren<{ title?: string; hideSubtitle?: boolean }>) => { + const { ref } = useParams() + const router = useRouter() + const page = useStorageV2Page() + const isStorageV2 = useIsNewStorageUIEnabled() + + const config = !!page && page !== 's3' ? BUCKET_TYPES[page] : undefined + + const navigationItems = + page === 'files' + ? [ + { + label: 'Buckets', + href: `/project/${ref}/storage/files`, + }, + { + label: 'Settings', + href: `/project/${ref}/storage/files/settings`, + }, + { + label: 'Policies', + href: `/project/${ref}/storage/files/policies`, + }, + ] + : [] + + useEffect(() => { + if (!isStorageV2) router.replace(`/project/${ref}/storage/buckets`) + }, [isStorageV2, ref, router]) + + return ( + ] : []} + > + {children} + + ) +} diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx index 757ee95ff9cf0..a723b64bf16f9 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx @@ -10,6 +10,7 @@ import { LOCAL_STORAGE_KEYS, useFlag } from 'common' import { useParams, useSearchParamsShallow } from 'common/hooks' import { Markdown } from 'components/interfaces/Markdown' import { useCheckOpenAIKeyQuery } from 'data/ai/check-api-key-query' +import { useRateMessageMutation } from 'data/ai/rate-message-mutation' import { constructHeaders } from 'data/fetchers' import { useTablesQuery } from 'data/tables/tables-query' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' @@ -83,10 +84,13 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { const [value, setValue] = useState(snap.initialInput || '') const [editingMessageId, setEditingMessageId] = useState(null) const [isResubmitting, setIsResubmitting] = useState(false) + const [messageRatings, setMessageRatings] = useState>({}) const { data: check, isSuccess } = useCheckOpenAIKeyQuery() const isApiKeySet = IS_PLATFORM || !!check?.hasKey + const { mutateAsync: rateMessage } = useRateMessageMutation() + const isInSQLEditor = router.pathname.includes('/sql/[id]') const snippet = snippets[entityId ?? ''] const snippetContent = snippet?.snippet?.content?.sql @@ -260,6 +264,47 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { setValue('') }, [setValue]) + const handleRateMessage = useCallback( + async (messageId: string, rating: 'positive' | 'negative', reason?: string) => { + if (!project?.ref || !selectedOrganization?.slug) return + + // Optimistically update UI + setMessageRatings((prev) => ({ ...prev, [messageId]: rating })) + + try { + const result = await rateMessage({ + rating, + messages: chatMessages, + messageId, + projectRef: project.ref, + orgSlug: selectedOrganization.slug, + reason, + }) + + sendEvent({ + action: 'assistant_message_rating_submitted', + properties: { + rating, + category: result.category, + ...(reason && { reason }), + }, + groups: { + project: project.ref, + organization: selectedOrganization.slug, + }, + }) + } catch (error) { + console.error('Failed to rate message:', error) + // Rollback on error + setMessageRatings((prev) => { + const { [messageId]: _, ...rest } = prev + return rest + }) + } + }, + [chatMessages, project?.ref, selectedOrganization?.slug, rateMessage, sendEvent] + ) + const renderedMessages = useMemo( () => chatMessages.map((message, index) => { @@ -283,6 +328,8 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { isBeingEdited={isBeingEdited} onCancelEdit={cancelEdit} isLastMessage={isLastMessage} + onRate={handleRateMessage} + rating={messageRatings[message.id] ?? null} /> ) }), @@ -294,6 +341,8 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { editingMessageId, chatStatus, addToolResult, + handleRateMessage, + messageRatings, ] ) diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.Actions.tsx b/apps/studio/components/ui/AIAssistantPanel/Message.Actions.tsx index 140f8c08804ae..2170e2afee825 100644 --- a/apps/studio/components/ui/AIAssistantPanel/Message.Actions.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/Message.Actions.tsx @@ -1,13 +1,33 @@ -import { Pencil, Trash2 } from 'lucide-react' -import { type PropsWithChildren } from 'react' +import { Pencil, ThumbsDown, ThumbsUp, Trash2 } from 'lucide-react' +import { type PropsWithChildren, useState, useEffect } from 'react' +import { zodResolver } from '@hookform/resolvers/zod' +import { useForm } from 'react-hook-form' +import * as z from 'zod' import { ButtonTooltip } from '../ButtonTooltip' +import { + cn, + Button, + Popover_Shadcn_, + PopoverTrigger_Shadcn_, + PopoverContent_Shadcn_, + Form_Shadcn_, + FormField_Shadcn_, + FormControl_Shadcn_, + TextArea_Shadcn_, +} from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' -export function MessageActions({ children }: PropsWithChildren<{}>) { +export function MessageActions({ + children, + alwaysShow = false, +}: PropsWithChildren<{ alwaysShow?: boolean }>) { return (
-
{children}
+
+ {children} +
) } @@ -44,3 +64,147 @@ function MessageActionsDelete({ onClick }: { onClick: () => void }) { ) } MessageActions.Delete = MessageActionsDelete + +function MessageActionsThumbsUp({ + onClick, + isActive, + disabled, +}: { + onClick: () => void + isActive?: boolean + disabled?: boolean +}) { + return ( + + + + {form.formState.isSubmitSuccessful ? ( +

We appreciate your feedback!

+ ) : ( + + + ( + + + + + + )} + /> +
+ +
+ +
+ )} +
+ + ) +} +MessageActions.ThumbsDown = MessageActionsThumbsDown diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.Context.tsx b/apps/studio/components/ui/AIAssistantPanel/Message.Context.tsx index 70a2ff87c8ef1..e9905ce824f45 100644 --- a/apps/studio/components/ui/AIAssistantPanel/Message.Context.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/Message.Context.tsx @@ -18,6 +18,7 @@ export interface MessageInfo { isLastMessage?: boolean state: 'idle' | 'editing' | 'predecessor-editing' + rating?: 'positive' | 'negative' | null } export interface MessageActions { @@ -26,6 +27,7 @@ export interface MessageActions { onDelete: (id: string) => void onEdit: (id: string) => void onCancelEdit: () => void + onRate?: (id: string, rating: 'positive' | 'negative', reason?: string) => void } const MessageInfoContext = createContext(null) diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.tsx b/apps/studio/components/ui/AIAssistantPanel/Message.tsx index 61c3f6f2fc702..7796683852525 100644 --- a/apps/studio/components/ui/AIAssistantPanel/Message.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/Message.tsx @@ -10,8 +10,12 @@ import { MessageDisplay } from './Message.Display' import { MessageProvider, useMessageActionsContext, useMessageInfoContext } from './Message.Context' function AssistantMessage({ message }: { message: VercelMessage }) { - const { variant, state } = useMessageInfoContext() - const { onCancelEdit } = useMessageActionsContext() + const { id, variant, state, isLastMessage, readOnly, rating, isLoading } = useMessageInfoContext() + const { onCancelEdit, onRate } = useMessageActionsContext() + + const handleRate = (newRating: 'positive' | 'negative', reason?: string) => { + onRate?.(id, newRating, reason) + } return ( + {!readOnly && isLastMessage && onRate && !isLoading && ( + + handleRate('positive')} + isActive={rating === 'positive'} + disabled={!!rating} + /> + handleRate('negative', reason)} + isActive={rating === 'negative'} + disabled={!!rating} + /> + + )} ) } @@ -81,6 +99,8 @@ interface MessageProps { isBeingEdited: boolean onCancelEdit: () => void isLastMessage?: boolean + onRate?: (id: string, rating: 'positive' | 'negative', reason?: string) => void + rating?: 'positive' | 'negative' | null } export function Message(props: MessageProps) { @@ -99,6 +119,7 @@ export function Message(props: MessageProps) { ? 'predecessor-editing' : 'idle', isLastMessage: props.isLastMessage, + rating: props.rating, } satisfies MessageInfo const messageActions = { @@ -106,6 +127,7 @@ export function Message(props: MessageProps) { onDelete: props.onDelete, onEdit: props.onEdit, onCancelEdit: props.onCancelEdit, + onRate: props.onRate, } return ( diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.utils.ts b/apps/studio/components/ui/AIAssistantPanel/Message.utils.ts index cf80eb9572ca1..16f6125896e45 100644 --- a/apps/studio/components/ui/AIAssistantPanel/Message.utils.ts +++ b/apps/studio/components/ui/AIAssistantPanel/Message.utils.ts @@ -96,3 +96,18 @@ export const deployEdgeFunctionInputSchema = z export const deployEdgeFunctionOutputSchema = z .object({ success: z.boolean().optional() }) .passthrough() + +export const rateMessageResponseSchema = z.object({ + category: z.enum([ + 'sql_generation', + 'schema_design', + 'rls_policies', + 'edge_functions', + 'database_optimization', + 'debugging', + 'general_help', + 'other', + ]), +}) + +export type RateMessageResponse = z.infer diff --git a/apps/studio/data/ai/rate-message-mutation.ts b/apps/studio/data/ai/rate-message-mutation.ts new file mode 100644 index 0000000000000..923943e6cfe99 --- /dev/null +++ b/apps/studio/data/ai/rate-message-mutation.ts @@ -0,0 +1,74 @@ +import { useMutation, UseMutationOptions } from '@tanstack/react-query' +import { UIMessage } from '@ai-sdk/react' + +import { constructHeaders, fetchHandler } from 'data/fetchers' +import { BASE_PATH } from 'lib/constants' +import { ResponseError } from 'types' +import type { RateMessageResponse } from 'components/ui/AIAssistantPanel/Message.utils' + +export type RateMessageVariables = { + rating: 'positive' | 'negative' + messages: UIMessage[] + messageId: string + projectRef: string + orgSlug?: string + reason?: string +} + +export async function rateMessage({ + rating, + messages, + messageId, + projectRef, + orgSlug, + reason, +}: RateMessageVariables) { + const url = `${BASE_PATH}/api/ai/feedback/rate` + + const headers = await constructHeaders({ 'Content-Type': 'application/json' }) + const response = await fetchHandler(url, { + headers, + method: 'POST', + body: JSON.stringify({ rating, messages, messageId, projectRef, orgSlug, reason }), + }) + + let body: any + + try { + body = await response.json() + } catch {} + + if (!response.ok) { + throw new ResponseError(body?.message, response.status) + } + + return body as RateMessageResponse +} + +type RateMessageData = Awaited> + +export const useRateMessageMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + return useMutation( + (vars) => rateMessage(vars), + { + async onSuccess(data, variables, context) { + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + console.error(`Failed to rate message: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/data/config/project-storage-config-query.ts b/apps/studio/data/config/project-storage-config-query.ts index 058fa95e9a93b..be158636207ae 100644 --- a/apps/studio/data/config/project-storage-config-query.ts +++ b/apps/studio/data/config/project-storage-config-query.ts @@ -2,9 +2,9 @@ import { useQuery, UseQueryOptions } from '@tanstack/react-query' import { components } from 'data/api' import { get, handleError } from 'data/fetchers' +import { IS_PLATFORM } from 'lib/constants' import type { ResponseError } from 'types' import { configKeys } from './keys' -import { IS_PLATFORM } from 'lib/constants' export type ProjectStorageConfigVariables = { projectRef?: string @@ -23,7 +23,15 @@ export async function getProjectStorageConfig( signal, }) - if (error) handleError(error) + if (error) { + // [Joshen] This is due to API not returning an error message on this endpoint if a 404 is returned + // Should only be a temporary patch, needs to be addressed on the API end + if ((error as any).code === 404) { + handleError({ ...(error as any), message: 'Storage configuration not found.' }) + } else { + handleError(error) + } + } return data } diff --git a/apps/studio/lib/api/rate.test.ts b/apps/studio/lib/api/rate.test.ts new file mode 100644 index 0000000000000..499cfd5d0c315 --- /dev/null +++ b/apps/studio/lib/api/rate.test.ts @@ -0,0 +1,74 @@ +import { expect, test, vi } from 'vitest' +// End of third-party imports + +import rate from '../../pages/api/ai/feedback/rate' +import { sanitizeMessagePart } from '../ai/tools/tool-sanitizer' + +vi.mock('../ai/tools/tool-sanitizer', () => ({ + sanitizeMessagePart: vi.fn((part) => part), +})) + +test('rate calls the tool sanitizer', async () => { + const mockReq = { + method: 'POST', + headers: { + authorization: 'Bearer test-token', + }, + body: { + rating: 'negative', + messages: [ + { + role: 'assistant', + parts: [ + { + type: 'tool-execute_sql', + state: 'output-available', + output: 'test output', + }, + ], + }, + ], + messageId: 'test-message-id', + projectRef: 'test-project', + orgSlug: 'test-org', + reason: 'The response was not helpful', + }, + on: vi.fn(), + } + + const mockRes = { + status: vi.fn(() => mockRes), + json: vi.fn(() => mockRes), + setHeader: vi.fn(() => mockRes), + } + + vi.mock('lib/ai/org-ai-details', () => ({ + getOrgAIDetails: vi.fn().mockResolvedValue({ + aiOptInLevel: 'schema_and_log_and_data', + isLimited: false, + }), + })) + + vi.mock('lib/ai/model', () => ({ + getModel: vi.fn().mockResolvedValue({ + model: {}, + error: null, + }), + })) + + vi.mock('ai', () => ({ + generateObject: vi.fn().mockResolvedValue({ + object: { + category: 'sql_generation', + }, + }), + })) + + vi.mock('components/ui/AIAssistantPanel/Message.utils', () => ({ + rateMessageResponseSchema: {}, + })) + + await rate(mockReq as any, mockRes as any) + + expect(sanitizeMessagePart).toHaveBeenCalled() +}) diff --git a/apps/studio/middleware.ts b/apps/studio/middleware.ts index 5327cb589e3a9..f531e68729188 100644 --- a/apps/studio/middleware.ts +++ b/apps/studio/middleware.ts @@ -8,6 +8,7 @@ export const config = { // [Joshen] Return 404 for all next.js API endpoints EXCEPT the ones we use in hosted: const HOSTED_SUPPORTED_API_URLS = [ '/ai/sql/generate-v4', + '/ai/feedback/rate', '/ai/code/complete', '/ai/sql/cron-v2', '/ai/sql/title-v2', diff --git a/apps/studio/pages/api/ai/feedback/rate.ts b/apps/studio/pages/api/ai/feedback/rate.ts new file mode 100644 index 0000000000000..2892cabb6d5ce --- /dev/null +++ b/apps/studio/pages/api/ai/feedback/rate.ts @@ -0,0 +1,155 @@ +import { generateObject } from 'ai' +import { NextApiRequest, NextApiResponse } from 'next' +import { z } from 'zod' + +import { IS_PLATFORM } from 'common' +import type { AiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi' +import { getModel } from 'lib/ai/model' +import { getOrgAIDetails } from 'lib/ai/org-ai-details' +import { sanitizeMessagePart } from 'lib/ai/tools/tool-sanitizer' +import apiWrapper from 'lib/api/apiWrapper' +import { rateMessageResponseSchema } from 'components/ui/AIAssistantPanel/Message.utils' + +export const maxDuration = 30 + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const { method } = req + + switch (method) { + case 'POST': + return handlePost(req, res) + default: + res.setHeader('Allow', ['POST']) + res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } }) + } +} + +const requestBodySchema = z.object({ + rating: z.enum(['positive', 'negative']), + messages: z.array(z.any()), + messageId: z.string(), + projectRef: z.string(), + orgSlug: z.string().optional(), + reason: z.string().optional(), +}) + +export async function handlePost(req: NextApiRequest, res: NextApiResponse) { + const authorization = req.headers.authorization + const accessToken = authorization?.replace('Bearer ', '') + + if (IS_PLATFORM && !accessToken) { + return res.status(401).json({ error: 'Authorization token is required' }) + } + + const body = typeof req.body === 'string' ? JSON.parse(req.body) : req.body + const { data, error: parseError } = requestBodySchema.safeParse(body) + + if (parseError) { + return res.status(400).json({ error: 'Invalid request body', issues: parseError.issues }) + } + + const { rating, messages: rawMessages, projectRef, orgSlug, reason } = data + + let aiOptInLevel: AiOptInLevel = 'disabled' + + if (!IS_PLATFORM) { + aiOptInLevel = 'schema' + } + + if (IS_PLATFORM && orgSlug && authorization && projectRef) { + try { + // Get organizations and compute opt in level server-side + const { aiOptInLevel: orgAIOptInLevel, isLimited: orgAILimited } = await getOrgAIDetails({ + orgSlug, + authorization, + projectRef, + }) + + aiOptInLevel = orgAIOptInLevel + } catch (error) { + return res.status(400).json({ + error: 'There was an error fetching your organization details', + }) + } + } + + // Only returns last 7 messages + // Filters out tool outputs based on opt-in level using sanitizeMessagePart + const messages = (rawMessages || []).slice(-7).map((msg: any) => { + if (msg && msg.role === 'assistant' && 'results' in msg) { + const cleanedMsg = { ...msg } + delete cleanedMsg.results + return cleanedMsg + } + if (msg && msg.role === 'assistant' && msg.parts) { + const cleanedParts = msg.parts.map((part: any) => { + return sanitizeMessagePart(part, aiOptInLevel) + }) + return { ...msg, parts: cleanedParts } + } + return msg + }) + + try { + const { model, error: modelError } = await getModel({ + provider: 'openai', + isLimited: true, + routingKey: 'feedback', + }) + + if (modelError) { + return res.status(500).json({ error: modelError.message }) + } + + const { object } = await generateObject({ + model, + schema: rateMessageResponseSchema, + prompt: ` +Your job is to look at a Supabase Assistant conversation, which the user has given feedback on, and classify it. + +The user gave this feedback: ${rating === 'positive' ? 'THUMBS UP (positive)' : 'THUMBS DOWN (negative)'} +${reason ? `\nUser's reason: ${reason}` : ''} + +Raw conversation: +${JSON.stringify(messages)} + +Instructions: +1. Classify the conversation into ONE of these categories: + - sql_generation: Generating SQL queries, DML statements + - schema_design: Creating tables, columns, relationships + - rls_policies: Row Level Security policies + - edge_functions: Edge Functions or serverless functions + - database_optimization: Performance, indexes, optimization + - debugging: Helping debug errors or issues + - general_help: General questions about Supabase features + - other: Anything else +`, + }) + + return res.json({ + category: object.category, + }) + } catch (error) { + if (error instanceof Error) { + console.error(`Classifying feedback failed:`, error) + + // Check for context length error + if (error.message.includes('context_length') || error.message.includes('too long')) { + return res.status(400).json({ + error: 'The conversation is too large to analyze', + }) + } + } else { + console.error(`Unknown error: ${error}`) + } + + return res.status(500).json({ + error: 'There was an unknown error analyzing the feedback.', + }) + } +} + +const wrapper = (req: NextApiRequest, res: NextApiResponse) => + apiWrapper(req, res, handler, { withAuth: true }) + +export default wrapper diff --git a/apps/studio/pages/project/[ref]/storage/[bucketType]/index.tsx b/apps/studio/pages/project/[ref]/storage/[bucketType]/index.tsx deleted file mode 100644 index c762752bc478d..0000000000000 --- a/apps/studio/pages/project/[ref]/storage/[bucketType]/index.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { useRouter } from 'next/router' -import { useEffect } from 'react' - -import { useParams } from 'common' -import { useIsNewStorageUIEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' -import { BUCKET_TYPES, DEFAULT_BUCKET_TYPE } from 'components/interfaces/Storage/Storage.constants' -import { BucketTypeLayout } from 'components/layouts/StorageLayout/BucketLayout' -import type { NextPageWithLayout } from 'types' - -const BucketTypePage: NextPageWithLayout = () => { - const router = useRouter() - const { bucketType, ref } = useParams() - const isStorageV2 = useIsNewStorageUIEnabled() - - const bucketTypeKey = bucketType || DEFAULT_BUCKET_TYPE - const config = BUCKET_TYPES[bucketTypeKey as keyof typeof BUCKET_TYPES] - - useEffect(() => { - if (!isStorageV2) router.replace(`/project/${ref}/storage`) - }, [isStorageV2, ref]) - - useEffect(() => { - if (!config) { - router.replace(`/project/${ref}/storage`) - } - }, [config, ref, router]) - - return ( -
- {/* [Danny] Purposefully duplicated directly below StorageLayout's config.description for now. Will be placed in a conditional empty state in next PR. TODO: consider reusing FormHeader for non-empty state.*/} - {/*

{config.description}

*/} -
- ) -} - -BucketTypePage.getLayout = (page) => { - return {page} -} - -export default BucketTypePage diff --git a/apps/studio/pages/project/[ref]/storage/analytics/index.tsx b/apps/studio/pages/project/[ref]/storage/analytics/index.tsx new file mode 100644 index 0000000000000..180782e9a42c6 --- /dev/null +++ b/apps/studio/pages/project/[ref]/storage/analytics/index.tsx @@ -0,0 +1,19 @@ +import { AnalyticsBuckets } from 'components/interfaces/Storage/AnalyticsBuckets' +import DefaultLayout from 'components/layouts/DefaultLayout' +import { StorageBucketsLayout } from 'components/layouts/StorageLayout/StorageBucketsLayout' +import StorageLayout from 'components/layouts/StorageLayout/StorageLayout' +import type { NextPageWithLayout } from 'types' + +const StorageAnalyticsPage: NextPageWithLayout = () => { + return +} + +StorageAnalyticsPage.getLayout = (page) => ( + + + {page} + + +) + +export default StorageAnalyticsPage diff --git a/apps/studio/pages/project/[ref]/storage/files/index.tsx b/apps/studio/pages/project/[ref]/storage/files/index.tsx new file mode 100644 index 0000000000000..3d92fda273cc1 --- /dev/null +++ b/apps/studio/pages/project/[ref]/storage/files/index.tsx @@ -0,0 +1,19 @@ +import { FilesBuckets } from 'components/interfaces/Storage/FilesBuckets' +import DefaultLayout from 'components/layouts/DefaultLayout' +import { StorageBucketsLayout } from 'components/layouts/StorageLayout/StorageBucketsLayout' +import StorageLayout from 'components/layouts/StorageLayout/StorageLayout' +import type { NextPageWithLayout } from 'types' + +const StorageFilesPage: NextPageWithLayout = () => { + return +} + +StorageFilesPage.getLayout = (page) => ( + + + {page} + + +) + +export default StorageFilesPage diff --git a/apps/studio/pages/project/[ref]/storage/files/policies.tsx b/apps/studio/pages/project/[ref]/storage/files/policies.tsx new file mode 100644 index 0000000000000..114bac7ebcdf8 --- /dev/null +++ b/apps/studio/pages/project/[ref]/storage/files/policies.tsx @@ -0,0 +1,19 @@ +import { StoragePolicies } from 'components/interfaces/Storage/StoragePolicies/StoragePolicies' +import DefaultLayout from 'components/layouts/DefaultLayout' +import { StorageBucketsLayout } from 'components/layouts/StorageLayout/StorageBucketsLayout' +import StorageLayout from 'components/layouts/StorageLayout/StorageLayout' +import type { NextPageWithLayout } from 'types' + +const FilesPoliciesPage: NextPageWithLayout = () => { + return +} + +FilesPoliciesPage.getLayout = (page) => ( + + + {page} + + +) + +export default FilesPoliciesPage diff --git a/apps/studio/pages/project/[ref]/storage/files/settings.tsx b/apps/studio/pages/project/[ref]/storage/files/settings.tsx new file mode 100644 index 0000000000000..187c39ce2ac6f --- /dev/null +++ b/apps/studio/pages/project/[ref]/storage/files/settings.tsx @@ -0,0 +1,32 @@ +import { StorageSettings } from 'components/interfaces/Storage/StorageSettings/StorageSettings' +import DefaultLayout from 'components/layouts/DefaultLayout' +import { + ScaffoldSection, + ScaffoldSectionDescription, + ScaffoldSectionTitle, +} from 'components/layouts/Scaffold' +import { StorageBucketsLayout } from 'components/layouts/StorageLayout/StorageBucketsLayout' +import StorageLayout from 'components/layouts/StorageLayout/StorageLayout' +import type { NextPageWithLayout } from 'types' + +const FilesSettingsPage: NextPageWithLayout = () => { + return ( + + Global settings + + Set limits or transformations across all file buckets. + + + + ) +} + +FilesSettingsPage.getLayout = (page) => ( + + + {page} + + +) + +export default FilesSettingsPage diff --git a/apps/studio/pages/project/[ref]/storage/s3.tsx b/apps/studio/pages/project/[ref]/storage/s3.tsx new file mode 100644 index 0000000000000..88e30cd3ab06e --- /dev/null +++ b/apps/studio/pages/project/[ref]/storage/s3.tsx @@ -0,0 +1,21 @@ +import { S3Connection } from 'components/interfaces/Storage/StorageSettings/S3Connection' +import DefaultLayout from 'components/layouts/DefaultLayout' +import { StorageBucketsLayout } from 'components/layouts/StorageLayout/StorageBucketsLayout' +import StorageLayout from 'components/layouts/StorageLayout/StorageLayout' +import type { NextPageWithLayout } from 'types' + +const S3SettingsPage: NextPageWithLayout = () => { + return +} + +S3SettingsPage.getLayout = (page) => ( + + + + {page} + + + +) + +export default S3SettingsPage diff --git a/apps/studio/pages/project/[ref]/storage/vectors/index.tsx b/apps/studio/pages/project/[ref]/storage/vectors/index.tsx new file mode 100644 index 0000000000000..edc9ae1886bdd --- /dev/null +++ b/apps/studio/pages/project/[ref]/storage/vectors/index.tsx @@ -0,0 +1,19 @@ +import { VectorsBuckets } from 'components/interfaces/Storage/VectorsBuckets' +import DefaultLayout from 'components/layouts/DefaultLayout' +import { StorageBucketsLayout } from 'components/layouts/StorageLayout/StorageBucketsLayout' +import StorageLayout from 'components/layouts/StorageLayout/StorageLayout' +import type { NextPageWithLayout } from 'types' + +const StorageVectorsPage: NextPageWithLayout = () => { + return +} + +StorageVectorsPage.getLayout = (page) => ( + + + {page} + + +) + +export default StorageVectorsPage diff --git a/apps/www/_blog/2025-10-03-remote-mcp-server.mdx b/apps/www/_blog/2025-10-03-remote-mcp-server.mdx index fdcb0bc33cf2a..737c7b0c175b5 100644 --- a/apps/www/_blog/2025-10-03-remote-mcp-server.mdx +++ b/apps/www/_blog/2025-10-03-remote-mcp-server.mdx @@ -13,7 +13,7 @@ date: '2025-10-03:10:00:00' toc_depth: 2 --- -Today we are launching our remote MCP server, allowing you to connect your Supabase projects with _many_ more AI agents than before, including ChatGPT, Claude, and Builder.io. We also added support for MCP auth (OAuth2), a faster and more secure way to connect agents with your Supabase account (via browser-based authentication). Last but not least, we’re adding official MCP support for local Supabase instances created through the [CLI](https://supabase.com/docs/guides/local-development/cli/getting-started). +Today we are launching our remote MCP server, allowing you to connect your Supabase projects with _many_ more AI agents than before, including ChatGPT, Claude, and Builder.io. We also added support for MCP auth (OAuth2), a faster and more secure way to connect agents with your Supabase account (via browser-based authentication). Last but not least, we're adding official MCP support for local Supabase instances created through the [CLI](https://supabase.com/docs/guides/local-development/cli/getting-started). Now all you need is a single URL to connect your favorite AI agent to Supabase: @@ -21,7 +21,7 @@ Now all you need is a single URL to connect your favorite AI agent to Supabase: https://mcp.supabase.com/mcp ``` -Or if you’re running Supabase locally: +Or if you're running Supabase locally: ```bash http://localhost:54321/mcp @@ -67,16 +67,14 @@ https://mcp.supabase.com/mcp # or http://localhost:54321/mcp for local ``` -We built this interactive widget to help you connect popular MCP clients to Supabase and customize the URL to your preferences (like project-scoped mode and read-only mode): - -{/* __ */} +We also built an [interactive widget](https://supabase.com/mcp) to help you connect popular MCP clients to Supabase and customize the URL to your preferences (like project-scoped mode and read-only mode). ## New features Our philosophy on Supabase MCP comes down to two ideas: -1. Supabase MCP should be used for development. It was designed from the beginning to assist with app development and shouldn’t be connected to production databases. See our post on [Defense in Depth for MCP Servers](https://supabase.com/blog/defense-in-depth-mcp). -2. MCP is just another way to access the same platform features that you already use in the web dashboard and CLI. But - since it’s being used by an LLM, it should also lean into the strengths of an AI-first UX. +1. Supabase MCP should be used for development. It was designed from the beginning to assist with app development and shouldn't be connected to production databases. See our post on [Defense in Depth for MCP Servers](https://supabase.com/blog/defense-in-depth-mcp). +2. MCP is just another way to access the same platform features that you already use in the web dashboard and CLI. But - since it's being used by an LLM, it should also lean into the strengths of an AI-first UX. With these in mind, we added the following new features to assist AI agents while they help build your app: @@ -121,16 +119,20 @@ Our solution is a feature that already exists on our platform - advisors. [Advis ### Storage -We also added initial support for [Supabase Storage](https://supabase.com/docs/guides/storage) on our MCP server. This first version allows your agent to see which buckets exist on your project and update their configuration, but in the future we’ll look into more abilities like listing files and their details. +We also added initial support for [Supabase Storage](https://supabase.com/docs/guides/storage) on our MCP server. This first version allows your agent to see which buckets exist on your project and update their configuration, but in the future we'll look into more abilities like listing files and their details. -This feature was actually a community contribution (thanks [Nico](https://github.com/Ngineer101)!). If there are ever missing features that you’d like to see, [PR’s](https://github.com/supabase-community/supabase-mcp/issues/new/choose) are always welcome! +This feature was actually a community contribution (thanks [Nico](https://github.com/Ngineer101)!). If there are ever missing features that you'd like to see, [PR's](https://github.com/supabase-community/supabase-mcp/issues/new/choose) are always welcome! -## What’s next? +## What's next? We have more exciting plans for MCP at Supabase: -1. **Security:** Today our OAuth2 implementation requires you to make a binary decision on permissions: either grant _all_ permissions to your MCP client, or _none_. This isn’t ideal if you know that you never want to, say, allow your client to access to your Edge Functions. +1. **Security:** Today our OAuth2 implementation requires you to make a binary decision on permissions: either grant _all_ permissions to your MCP client, or _none_. This isn't ideal if you know that you never want to, say, allow your client to access to your Edge Functions. + + To improve this, we're working to support fine-grain permissions that can be toggled during authorization. It's a big task to re-work our permission infrastructure to support this, but we believe it's worth it. + +2. **Double down on local:** We're very excited to support local Supabase instances in this release, but we also believe there is a lot more that can be done. Supabase MCP is designed to be used for development, so we want the local experience to be first-class. -To improve this, we’re working to support fine-grain permissions that can be toggled during authorization. It’s a big task to re-work our permission infrastructure to support this, but we believe it’s worth it. 2. **Double down on local:** We’re very excited to support local Supabase instances in this release, but we also believe there is a lot more that can be done. Supabase MCP is designed to be used for development, so we want the local experience to be first-class. 3. **Build your own MCP:** You might have thought about building _your own_ MCP server on top of Supabase. We’re using the playbook and lessons learned from our own MCP server to provide the tools you need to do the same - including remote MCP and auth. Stay tuned! +3. **Build your own MCP:** You might have thought about building _your own_ MCP server on top of Supabase. We're using the playbook and lessons learned from our own MCP server to provide the tools you need to do the same - including remote MCP and auth. Stay tuned! -We’re keen to continue investing in MCP and excited to see how you use these new features! +We're keen to continue investing in MCP and excited to see how you use these new features! diff --git a/apps/www/pages/terms.mdx b/apps/www/pages/terms.mdx index f9ea22db845c1..17f2839f1d72b 100644 --- a/apps/www/pages/terms.mdx +++ b/apps/www/pages/terms.mdx @@ -12,7 +12,7 @@ export const meta = { _Last Modified: 11 July 2025_ -These Terms of Service (this "**Agreement**") are a binding contract between you ("**Customer**," "**you**," or "**your**") and Supabase, Inc., a Delaware corporation with offices located at 970 Toa Payoh North #07-04, Singapore 318992 ("**Supabase**," "**we**," or "**us**"). This Agreement governs your access to and use of the Cloud Services. Supabase and Customer may be referred to herein collectively as the "**Parties**" or individually as a "**Party**." +These Terms of Service (this "**Agreement**") are a binding contract between you ("**Customer**," "**you**," or "**your**") and Supabase, Inc., a Delaware corporation with offices located at 65 Chulia Street #38-02/03, OCBC Centre, Singapore 049513 ("**Supabase**," "**we**," or "**us**"). This Agreement governs your access to and use of the Cloud Services. Supabase and Customer may be referred to herein collectively as the "**Parties**" or individually as a "**Party**." ## Agreement Acceptance diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index f12911ecde72d..facb0c6d65821 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -1189,6 +1189,35 @@ export interface AiAssistantInSupportFormClickedEvent { groups: Partial } +/** + * User rated an AI assistant message with thumbs up or thumbs down. + * + * @group Events + * @source studio + */ +export interface AssistantMessageRatingSubmittedEvent { + action: 'assistant_message_rating_submitted' + properties: { + /** + * The rating given by the user: positive (thumbs up) or negative (thumbs down) + */ + rating: 'positive' | 'negative' + /** + * The category of the conversation + */ + category: + | 'sql_generation' + | 'schema_design' + | 'rls_policies' + | 'edge_functions' + | 'database_optimization' + | 'debugging' + | 'general_help' + | 'other' + } + groups: TelemetryGroups +} + /** * User copied the command for a Supabase UI component. * @@ -1871,6 +1900,7 @@ export type TelemetryEvent = | AssistantSuggestionRunQueryClickedEvent | AssistantSqlDiffHandlerEvaluatedEvent | AssistantEditInSqlEditorClickedEvent + | AssistantMessageRatingSubmittedEvent | DocsFeedbackClickedEvent | HomepageFrameworkQuickstartClickedEvent | HomepageProductCardClickedEvent diff --git a/packages/ui-patterns/src/McpUrlBuilder/McpConfigPanel.tsx b/packages/ui-patterns/src/McpUrlBuilder/McpConfigPanel.tsx index 709130225f38a..c6463094a940e 100644 --- a/packages/ui-patterns/src/McpUrlBuilder/McpConfigPanel.tsx +++ b/packages/ui-patterns/src/McpUrlBuilder/McpConfigPanel.tsx @@ -1,14 +1,15 @@ 'use client' import React, { useMemo, useState } from 'react' -import { cn, Separator } from 'ui' +import { cn, Separator, CodeBlock } from 'ui' import { ClientSelectDropdown } from './components/ClientSelectDropdown' import { McpConfigurationDisplay } from './components/McpConfigurationDisplay' import { McpConfigurationOptions } from './components/McpConfigurationOptions' -import { FEATURE_GROUPS_PLATFORM, FEATURE_GROUPS_NON_PLATFORM, MCP_CLIENTS } from './constants' +import { FEATURE_GROUPS_NON_PLATFORM, FEATURE_GROUPS_PLATFORM, MCP_CLIENTS } from './constants' import type { McpClient } from './types' import { getMcpUrl } from './utils/getMcpUrl' +import { InfoTooltip } from '../info-tooltip' export interface McpConfigPanelProps { basePath: string @@ -43,7 +44,7 @@ export function McpConfigPanel({ ) }, [selectedFeatures, supportedFeatures]) - const { clientConfig } = getMcpUrl({ + const { mcpUrl, clientConfig } = getMcpUrl({ projectRef, isPlatform, apiUrl, @@ -78,6 +79,24 @@ export function McpConfigPanel({ onFeaturesChange={setSelectedFeatures} featureGroups={isPlatform ? FEATURE_GROUPS_PLATFORM : FEATURE_GROUPS_NON_PLATFORM} /> +
+ + Server URL + + {`MCP clients should support the Streamable HTTP transport${isPlatform ? ' and OAuth 2.1 with dynamic client registration' : ''}`} + +
+ } + hideLineNumbers + language="http" + className="max-h-64 overflow-y-auto" + > + {mcpUrl} + +
- + diff --git a/packages/ui-patterns/src/McpUrlBuilder/components/McpConfigurationDisplay.tsx b/packages/ui-patterns/src/McpUrlBuilder/components/McpConfigurationDisplay.tsx index 5ea38d01fe644..6d4ba5414379c 100644 --- a/packages/ui-patterns/src/McpUrlBuilder/components/McpConfigurationDisplay.tsx +++ b/packages/ui-patterns/src/McpUrlBuilder/components/McpConfigurationDisplay.tsx @@ -65,6 +65,7 @@ export function McpConfigurationDisplay({ value={JSON.stringify(clientConfig, null, 2)} language="json" className="max-h-64 overflow-y-auto" + focusable={false} /> {selectedClient.alternateInstructions && selectedClient.alternateInstructions(clientConfig)} diff --git a/packages/ui-patterns/src/McpUrlBuilder/constants.tsx b/packages/ui-patterns/src/McpUrlBuilder/constants.tsx index 67f6e7c8875a6..dd32ba408a7f4 100644 --- a/packages/ui-patterns/src/McpUrlBuilder/constants.tsx +++ b/packages/ui-patterns/src/McpUrlBuilder/constants.tsx @@ -3,7 +3,6 @@ import type { ClaudeCodeMcpConfig, McpClient, McpFeatureGroup, - OtherMcpConfig, VSCodeMcpConfig, WindsurfMcpConfig, } from './types' @@ -12,7 +11,7 @@ export const FEATURE_GROUPS_PLATFORM: McpFeatureGroup[] = [ { id: 'docs', name: 'Documentation', - description: 'Access project documentation and guides', + description: 'Access Supabase documentation and guides', }, { id: 'account', @@ -151,29 +150,6 @@ export const MCP_CLIENTS: McpClient[] = [ ) }, }, - { - key: 'other', - label: 'Other', - transformConfig: (config): OtherMcpConfig => { - return { - mcpServers: { - supabase: { - type: 'http', - url: config.mcpServers.supabase.url, - }, - }, - } - }, - alternateInstructions: (_config) => { - return ( -

- These generic MCP settings may work with other MCP clients, but there are no guarantees, - due to differences between clients. Refer to your specific client docs for where to input - the configuration. -

- ) - }, - }, ] export const DEFAULT_MCP_URL_PLATFORM = 'http://localhost:8080/mcp' diff --git a/packages/ui/src/components/Input/Input.tsx b/packages/ui/src/components/Input/Input.tsx index 35f21c2aa8aa3..d895037d2e095 100644 --- a/packages/ui/src/components/Input/Input.tsx +++ b/packages/ui/src/components/Input/Input.tsx @@ -38,7 +38,7 @@ export interface Props } /** - * @deprecated Use `import { Input_shadcn_ } from "ui"` instead or ./ui-patterns/data-inputs/input + * @deprecated Use `import { Input_Shadcn_ } from 'ui'` instead or `import { Input } from 'ui-patterns/DataInputs/Input'` */ function Input({ autoComplete,