diff --git a/apps/docs/content/guides/integrations/partner-catalog.mdx b/apps/docs/content/guides/integrations/partner-catalog.mdx
index e6e59da9cfb6d..aafe1bf5b84f5 100644
--- a/apps/docs/content/guides/integrations/partner-catalog.mdx
+++ b/apps/docs/content/guides/integrations/partner-catalog.mdx
@@ -6,7 +6,7 @@ description: 'Integrations and Partners'
The [Partner Catalog](/partners/catalog) is Supabase's public directory of third-party integrations. It lists tools that extend your Supabase project. These tools cover Auth, Caching, Hosting, and Low-code categories.
-The Partner Catalog is different from [Dashboard Integrations](/guides/integrations#dashboard-integrations). You install Dashboard Integrations directly from a project in the Supabase Dashboard.
+The Partner Catalog is different from [Dashboard Integrations](/docs/guides/integrations#dashboard-integrations). You install Dashboard Integrations directly from a project in the Supabase Dashboard.
## Build an integration
diff --git a/apps/docs/features/ui/AgentPluginsPanel.tsx b/apps/docs/features/ui/AgentPluginsPanel.tsx
index 33518664e6daf..c8b992a9967da 100644
--- a/apps/docs/features/ui/AgentPluginsPanel.tsx
+++ b/apps/docs/features/ui/AgentPluginsPanel.tsx
@@ -50,6 +50,21 @@ const PLUGIN_CLIENTS: PluginClient[] = [
docsUrl:
'https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/plugins-finding-installing',
},
+ {
+ key: 'kimi',
+ label: 'Kimi Code',
+ icon: 'kimi',
+ hasDistinctDarkIcon: true,
+ repoUrl: 'https://github.com/supabase-community/supabase-plugin',
+ docsUrl: 'https://www.kimi.com/code/docs/en/kimi-code-cli/customization/plugins.html',
+ },
+ {
+ key: 'vscode',
+ label: 'VS Code',
+ icon: 'vscode',
+ repoUrl: 'https://github.com/supabase-community/supabase-plugin',
+ docsUrl: 'https://code.visualstudio.com/docs/agent-customization/agent-plugins',
+ },
]
function PluginInstructions({ client }: { client: PluginClient }) {
@@ -172,6 +187,58 @@ function PluginInstructions({ client }: { client: PluginClient }) {
)
}
+ if (client.key === 'kimi') {
+ return (
+
+
Open Kimi Code by running
+
+
+ Then install the Supabase plugin from within the session:
+
+
+
+ Confirm the trust prompt to install. Kimi adds the plugin to its native plugin store. Run{' '}
+ /plugins at any time to view or reload installed plugins.
+
+
+ )
+ }
+
+ if (client.key === 'vscode') {
+ return (
+
+
+ Open the Command Palette (Cmd/Ctrl+Shift+P) and run{' '}
+ Chat: Install Plugin From Source , then paste the Supabase plugin
+ repository URL:
+
+
+
+ VS Code auto-detects the vendor-neutral{' '}
+
+ Open Plugin
+ {' '}
+ manifest in the repo. Review the trust prompt to finish installing.
+
+
+ )
+ }
+
if (client.key === 'github-copilot') {
return (
diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.types.ts b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.types.ts
index a831766cb30b8..d305bd8ccab19 100644
--- a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.types.ts
+++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.types.ts
@@ -1,6 +1,6 @@
export type ResponseData = {
status: number
- headers: Record
+ headers: Record
body: string
}
diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.utils.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.utils.tsx
index 25fe2a6cc0a2f..21b02412ecb1a 100644
--- a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.utils.tsx
+++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.utils.tsx
@@ -1,4 +1,7 @@
+import { getAnchor } from '@ui/components/CustomHTMLElements/CustomHTMLElements.utils'
+
import { EdgeFunction } from '@/data/edge-functions/edge-function-query'
+import { DOCS_URL } from '@/lib/constants'
export const generateCLICommands = ({
selectedFunction,
@@ -99,3 +102,18 @@ export const generateCLICommands = ({
return { managementCommands, secretCommands, invokeCommands }
}
+
+export const getEdgeFunctionErrorDocs = (headers: Record) => {
+ const header = Object.entries(headers).find(
+ ([name]) => name.toLowerCase() === 'sb-error-code'
+ )?.[1]
+ const code = (Array.isArray(header) ? header[0] : header)?.trim()
+ const anchor = code ? getAnchor(code) : undefined
+
+ if (!code || !anchor) return undefined
+
+ return {
+ code,
+ href: `${DOCS_URL}/guides/functions/error-codes#${anchor}`,
+ }
+}
diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx
index 250f5096474c9..1a719dc33da15 100644
--- a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx
+++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx
@@ -1,7 +1,7 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
-import { Loader2, Plus, Send, X } from 'lucide-react'
+import { BookOpen, Loader2, Plus, Send, X } from 'lucide-react'
import { useState } from 'react'
import { useFieldArray, useForm } from 'react-hook-form'
import {
@@ -37,6 +37,7 @@ import * as z from 'zod'
import { HTTP_METHODS } from './EdgeFunctionDetails.constants'
import { ErrorWithStatus, ResponseData } from './EdgeFunctionDetails.types'
+import { getEdgeFunctionErrorDocs } from './EdgeFunctionDetails.utils'
import { RoleImpersonationPopover } from '@/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover'
import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip'
import { useAPIKeys } from '@/data/api-keys/api-keys-query'
@@ -100,6 +101,7 @@ const EdgeFunctionTesterSheetContent = ({ visible, onClose }: EdgeFunctionTester
const [response, setResponse] = useState(null)
const [error, setError] = useState(null)
+ const errorDocs = response ? getEdgeFunctionErrorDocs(response.headers) : undefined
const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*')
const { data: apiKeysData } = useAPIKeys({ projectRef }, { enabled: canReadAPIKeys })
@@ -423,12 +425,28 @@ const EdgeFunctionTesterSheetContent = ({ visible, onClose }: EdgeFunctionTester
Headers
- = 400 ? 'destructive' : 'success'}
- className="-translate-y-1"
- >
- {response.status}
-
+
+ {errorDocs !== undefined && (
+
}
+ >
+
+ Error docs
+
+
+ )}
+
= 400 ? 'destructive' : 'success'}>
+ {response.status}
+
+
editor === 'table' && t.metadata?.schema !== currentSchema)
}, [openTabs, currentSchema, editor])
- // Create a motion version of TabsTrigger while preserving all functionality
- // const MotionTabsTrigger = motion(TabsTrigger)
+ const isActive = tabs.activeTab === tab.id
+
+ const closeTabFromKeyboard = (event: KeyboardEvent) => {
+ if (event.key !== 'Delete' && event.key !== 'Backspace') return
+ event.preventDefault()
+ event.stopPropagation()
+ onClose(tab.id)
+ }
return (
- {
- // Middle click closes tab
- if (e.button === 1) {
- e.preventDefault()
- onClose(tab.id)
- }
- }}
- onDoubleClick={() => tabs.makeTabPermanent(tab.id)}
- className={cn(
- 'flex items-center gap-2 pl-3 pr-2.5 text-xs',
- 'bg-dash-sidebar/50 dark:bg-surface-100/50',
- 'data-[state=active]:bg-dash-sidebar dark:data-[state=active]:bg-surface-100',
- 'border-b border-default',
- 'data-[state=active]:border-b-background-dash-sidebar dark:data-[state=active]:border-b-background-surface-100',
- 'relative group h-full',
- 'hover:bg-surface-300 dark:hover:bg-surface-100',
- tab.isPreview && 'italic font-light' // Optional: style preview tabs differently
- )}
- {...listeners}
- >
-
-
-
- {shouldShowSchema && (
-
- {tab?.metadata?.schema}.
-
- )}
-
-
{tab.label || 'Untitled'}
-
- {/* VS Code-style slot: the type's status indicator (e.g. an unsaved dot)
- shows at rest and swaps to the close button on hover. */}
-
- {StatusIndicator && (
-
-
-
- )}
-
{
- e.preventDefault()
- e.stopPropagation()
- }}
- className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 hover:bg-200 rounded-xs cursor-pointer"
- onMouseDown={(e) => {
- e.preventDefault()
- e.stopPropagation()
- }}
- onPointerDown={(e) => {
+
+
{
+ // Middle click closes tab
+ if (e.button === 1) {
e.preventDefault()
- e.stopPropagation()
onClose(tab.id)
- }}
+ }
+ }}
+ onDoubleClick={() => tabs.makeTabPermanent(tab.id)}
+ onKeyDown={closeTabFromKeyboard}
+ className={cn(
+ 'flex items-center gap-2 pl-3 pr-2.5 text-xs',
+ 'bg-dash-sidebar/50 dark:bg-surface-100/50',
+ 'data-[state=active]:bg-dash-sidebar dark:data-[state=active]:bg-surface-100',
+ 'border-b border-default',
+ 'data-[state=active]:border-b-background-dash-sidebar dark:data-[state=active]:border-b-background-surface-100',
+ 'relative group h-full',
+ 'hover:bg-surface-300 dark:hover:bg-surface-100',
+ tab.isPreview && 'italic font-light' // Optional: style preview tabs differently
+ )}
+ {...listeners}
+ >
+
+
+
+ {shouldShowSchema && (
+
+ {tab?.metadata?.schema}.
+
+ )}
+
+
{tab.label || 'Untitled'}
+
+ {/* Reserve status/close slot width; close is a sibling overlay, not nested. */}
+
-
-
-
-
-
+ {StatusIndicator && (
+
+
+
+ )}
+
+
+
+ {/* Sibling of TabsTrigger — not nested inside the tab button.
+ Only the active tab's close is in the tab order (roving tabs). Delete/Backspace
+ on the focused tab also closes. */}
+ {
+ e.preventDefault()
+ e.stopPropagation()
+ onClose(tab.id)
+ }}
+ onMouseDown={(e) => {
+ e.preventDefault()
+ e.stopPropagation()
+ }}
+ onPointerDown={(e) => {
+ e.preventDefault()
+ e.stopPropagation()
+ }}
+ className={cn(
+ 'absolute top-1/2 right-2.5 z-10 -translate-y-1/2',
+ 'flex size-5 items-center justify-center rounded-xs',
+ 'opacity-0 group-hover/tab:opacity-100 group-focus-within/tab:opacity-100 focus-visible:opacity-100',
+ 'hover:bg-200 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
+ 'cursor-pointer'
+ )}
+ >
+
+
+
{index < openTabs.length && (
)}
diff --git a/apps/studio/components/layouts/Tabs/Tabs.tsx b/apps/studio/components/layouts/Tabs/Tabs.tsx
index c1959e98717aa..72b06e913ac31 100644
--- a/apps/studio/components/layouts/Tabs/Tabs.tsx
+++ b/apps/studio/components/layouts/Tabs/Tabs.tsx
@@ -214,27 +214,40 @@ export const EditorTabs = () => {
{/* Non-draggable new tab */}
{hasNewTab && (
-
-
-
- New
-
-
+ {
+ if (e.key !== 'Delete' && e.key !== 'Backspace') return
+ e.preventDefault()
+ e.stopPropagation()
+ handleClose('new')
+ }}
+ className={cn(
+ 'flex items-center gap-2 px-3 text-xs',
+ 'bg-dash-sidebar/50 dark:bg-surface-100/50',
+ 'data-[state=active]:bg-dash-sidebar dark:data-[state=active]:bg-surface-100',
+ 'relative group h-full border-t-2 border-b-0!',
+ 'hover:bg-surface-300 dark:hover:bg-surface-100'
+ )}
+ >
+
+
+ New
+
+ {/* Reserve close-icon width; close is a sibling overlay. */}
+
+
+
+ {
e.preventDefault()
e.stopPropagation()
+ handleClose('new')
}}
- className="ml-1 opacity-0 group-hover:opacity-100 hover:bg-200 rounded-xs cursor-pointer"
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
@@ -242,13 +255,17 @@ export const EditorTabs = () => {
onPointerDown={(e) => {
e.preventDefault()
e.stopPropagation()
- handleClose('new')
}}
+ className={cn(
+ 'absolute top-1/2 right-3 z-10 flex -translate-y-1/2 items-center justify-center rounded-xs',
+ 'opacity-0 group-hover/new-tab:opacity-100 group-focus-within/new-tab:opacity-100 focus-visible:opacity-100',
+ 'hover:bg-200 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
+ 'cursor-pointer'
+ )}
>
- {' '}
-
-
+
+
)}
diff --git a/apps/studio/pages/api/edge-functions/test.ts b/apps/studio/pages/api/edge-functions/test.ts
index bc356e79126cc..096d66fa205e3 100644
--- a/apps/studio/pages/api/edge-functions/test.ts
+++ b/apps/studio/pages/api/edge-functions/test.ts
@@ -77,42 +77,21 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse) {
redirect: 'manual', // don't follow the redirect and return response as is
})
- // Handle non-JSON responses
- let responseBody: string
- const contentType = response.headers.get('content-type')
- if (contentType?.includes('application/json')) {
- // If JSON, parse and stringify to ensure it's valid JSON
- const jsonBody = await response.json()
- responseBody = JSON.stringify(jsonBody)
- } else {
- // For non-JSON responses, get raw text
- responseBody = await response.text()
- }
-
- if (!response.ok) {
- // Try to parse error response if it's JSON
- try {
- const errorBody = JSON.parse(responseBody)
+ const responseBody = await response.text()
- return res.status(response.status).json({
- status: response.status,
- error: { message: errorBody?.error || 'Edge function returned an error' },
- })
- } catch (parseError) {
- // If not JSON, return the raw error
- return res.status(response.status).json({
- status: response.status,
- error: { message: responseBody || 'Edge function returned an error' },
- })
- }
- }
-
- const responseHeaders: Record = {}
+ const responseHeaders: Record = {}
response.headers.forEach((value, key) => {
- responseHeaders[key] = value
+ const existing = responseHeaders[key]
+ if (existing === undefined) {
+ responseHeaders[key] = value
+ } else if (Array.isArray(existing)) {
+ existing.push(value)
+ } else {
+ responseHeaders[key] = [existing, value]
+ }
})
- return res.status(response.status).json({
+ return res.status(200).json({
status: response.status,
headers: responseHeaders,
body: responseBody,
diff --git a/apps/studio/state/ai-assistant-state.test.ts b/apps/studio/state/ai-assistant-state.test.ts
new file mode 100644
index 0000000000000..4fb412ffe831f
--- /dev/null
+++ b/apps/studio/state/ai-assistant-state.test.ts
@@ -0,0 +1,46 @@
+import { proxy, ref } from 'valtio/vanilla'
+import { describe, expect, it } from 'vitest'
+
+import { sanitizeForCloning } from './ai-assistant-state'
+
+describe('AI assistant chat message sync', () => {
+ // FE-3954: syncing the live array into valtio corrupted it with Proxies, breaking structuredClone in addToolApprovalResponse
+ it('does not corrupt the live SDK message array when synced into valtio state', () => {
+ const state = proxy<{ chats: Record; chatInstances: Record }>({
+ chats: {},
+ chatInstances: {},
+ })
+ state.chats['chat-1'] = { id: 'chat-1', messages: [] }
+
+ const liveSdkMessages = [
+ {
+ id: 'message-1',
+ role: 'assistant',
+ parts: [
+ { type: 'text', text: "Sure, here's a query" },
+ {
+ type: 'tool-execute_sql',
+ toolCallId: 'tool-1',
+ state: 'approval-requested',
+ approval: { id: 'approval-1' },
+ },
+ ],
+ },
+ ]
+ state.chatInstances['chat-1'] = ref({ messages: liveSdkMessages })
+
+ const chat = state.chats['chat-1']
+ chat.messages = liveSdkMessages.map((message) => sanitizeForCloning(message))
+
+ // mirrors addToolApprovalResponse's own update logic
+ const lastMessage = liveSdkMessages[liveSdkMessages.length - 1]
+ const updatedParts = lastMessage.parts.map((part: any) =>
+ part.state === 'approval-requested' && part.approval?.id === 'approval-1'
+ ? { ...part, state: 'approval-responded', approval: { id: 'approval-1', approved: true } }
+ : part
+ )
+ const replacedMessage = { ...lastMessage, parts: updatedParts }
+
+ expect(() => structuredClone(replacedMessage)).not.toThrow()
+ })
+})
diff --git a/apps/studio/state/ai-assistant-state.tsx b/apps/studio/state/ai-assistant-state.tsx
index c083fa10e52c7..9f9599b32f9bc 100644
--- a/apps/studio/state/ai-assistant-state.tsx
+++ b/apps/studio/state/ai-assistant-state.tsx
@@ -152,7 +152,7 @@ async function clearStorage(): Promise {
// Helper function to sanitize objects to ensure they're cloneable
// Issue due to addToolResult
-function sanitizeForCloning(obj: any): any {
+export function sanitizeForCloning(obj: any): any {
if (obj === null || obj === undefined) return obj
if (typeof obj !== 'object') return obj
return JSON.parse(JSON.stringify(obj))
@@ -329,7 +329,8 @@ function createChatInstance(
const messages = chatInstance.messages
const chat = state.chats[options.id]
if (chat) {
- chat.messages = messages
+ // Clone first — valtio's proxy() mutates nested properties in place and would corrupt the SDK's live array
+ chat.messages = messages.map((message) => sanitizeForCloning(message))
chat.updatedAt = new Date()
}
diff --git a/apps/studio/tests/components/Functions/EdgeFunctionDetails.utils.test.ts b/apps/studio/tests/components/Functions/EdgeFunctionDetails.utils.test.ts
new file mode 100644
index 0000000000000..af31ed7d99cd5
--- /dev/null
+++ b/apps/studio/tests/components/Functions/EdgeFunctionDetails.utils.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from 'vitest'
+
+import { getEdgeFunctionErrorDocs } from '@/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.utils'
+import { DOCS_URL } from '@/lib/constants'
+
+describe('getEdgeFunctionErrorDocs', () => {
+ it('builds a documentation link from the error code header', () => {
+ expect(
+ getEdgeFunctionErrorDocs({ 'sb-error-code': 'UNAUTHORIZED_INVALID_JWT_FORMAT' })
+ ).toEqual({
+ code: 'UNAUTHORIZED_INVALID_JWT_FORMAT',
+ href: `${DOCS_URL}/guides/functions/error-codes#unauthorizedinvalidjwtformat`,
+ })
+ })
+
+ it('uses the same anchor format as documentation headings', () => {
+ expect(getEdgeFunctionErrorDocs({ 'sb-error-code': 'WORKER_RESOURCE_LIMIT' })).toEqual({
+ code: 'WORKER_RESOURCE_LIMIT',
+ href: `${DOCS_URL}/guides/functions/error-codes#workerresourcelimit`,
+ })
+ })
+
+ it('matches the error code header case-insensitively', () => {
+ expect(getEdgeFunctionErrorDocs({ 'Sb-Error-Code': 'BOOT_ERROR' })).toEqual({
+ code: 'BOOT_ERROR',
+ href: `${DOCS_URL}/guides/functions/error-codes#booterror`,
+ })
+ })
+
+ it('uses the first value when the header contains multiple values', () => {
+ expect(getEdgeFunctionErrorDocs({ 'sb-error-code': ['WORKER_ERROR', 'BOOT_ERROR'] })).toEqual({
+ code: 'WORKER_ERROR',
+ href: `${DOCS_URL}/guides/functions/error-codes#workererror`,
+ })
+ })
+
+ it('omits the link when the error code header is missing', () => {
+ expect(getEdgeFunctionErrorDocs({})).toBeUndefined()
+ })
+
+ it.each(['', '___'])('omits the link for an unusable error code', (code) => {
+ expect(getEdgeFunctionErrorDocs({ 'sb-error-code': code })).toBeUndefined()
+ })
+})
diff --git a/apps/studio/tests/pages/api/edge-functions/test.test.ts b/apps/studio/tests/pages/api/edge-functions/test.test.ts
new file mode 100644
index 0000000000000..a3fdf4b656d4a
--- /dev/null
+++ b/apps/studio/tests/pages/api/edge-functions/test.test.ts
@@ -0,0 +1,141 @@
+import { createMocks } from 'node-mocks-http'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import handler from '../../../../pages/api/edge-functions/test'
+
+vi.mock('common', () => ({ IS_PLATFORM: true }))
+
+const createRequest = (url = 'https://abcdefghijklmnopqrst.supabase.co/functions/v1/test') =>
+ createMocks({
+ method: 'POST',
+ body: {
+ url,
+ method: 'POST',
+ body: '{}',
+ headers: {},
+ },
+ })
+
+describe('/api/edge-functions/test', () => {
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('preserves unsuccessful edge function responses', async () => {
+ const body = JSON.stringify({
+ code: 'UNAUTHORIZED_NO_AUTH_HEADER',
+ message: 'Missing authorization header',
+ })
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue(
+ new Response(body, {
+ status: 401,
+ headers: {
+ 'content-type': 'application/json',
+ 'sb-error-code': 'UNAUTHORIZED_NO_AUTH_HEADER',
+ },
+ })
+ )
+ )
+ const { req, res } = createRequest()
+
+ await handler(req, res)
+
+ expect(res._getStatusCode()).toBe(200)
+ expect(JSON.parse(res._getData())).toEqual({
+ status: 401,
+ headers: {
+ 'content-type': 'application/json',
+ 'sb-error-code': 'UNAUTHORIZED_NO_AUTH_HEADER',
+ },
+ body,
+ })
+ })
+
+ it('preserves multiple Set-Cookie headers', async () => {
+ const headers = new Headers()
+ headers.append('set-cookie', 'session=one; Path=/; HttpOnly')
+ headers.append('set-cookie', 'csrf=two; Path=/; SameSite=Lax')
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { headers })))
+ const { req, res } = createRequest()
+
+ await handler(req, res)
+
+ expect(JSON.parse(res._getData())).toEqual({
+ status: 200,
+ headers: {
+ 'set-cookie': ['session=one; Path=/; HttpOnly', 'csrf=two; Path=/; SameSite=Lax'],
+ },
+ body: '',
+ })
+ })
+
+ it.each([
+ ['successful JSON', JSON.stringify({ ok: true }), 'application/json', 200],
+ [
+ 'gateway JSON',
+ JSON.stringify({ message: 'Name resolution failed' }),
+ 'application/json',
+ 503,
+ ],
+ ['legacy JSON', JSON.stringify({ msg: 'Invalid JWT' }), 'application/json', 503],
+ [
+ 'nested JSON',
+ JSON.stringify({ error: { message: 'Function failed' } }),
+ 'application/json',
+ 503,
+ ],
+ ['arbitrary JSON', JSON.stringify({ details: ['Function failed'] }), 'application/json', 503],
+ ['plain text', 'Bad Gateway', 'text/plain', 503],
+ ['malformed JSON', '{"message": invalid json', 'application/json', 503],
+ ])('preserves %s bodies without parsing them', async (_name, body, contentType, status) => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue(
+ new Response(body, {
+ status,
+ headers: { 'content-type': contentType },
+ })
+ )
+ )
+ const { req, res } = createRequest()
+
+ await handler(req, res)
+
+ expect(res._getStatusCode()).toBe(200)
+ expect(JSON.parse(res._getData())).toEqual({
+ status,
+ headers: { 'content-type': contentType },
+ body,
+ })
+ })
+
+ it('rejects invalid URLs without making an upstream request', async () => {
+ const fetchMock = vi.fn()
+ vi.stubGlobal('fetch', fetchMock)
+ const { req, res } = createRequest('https://example.com/functions/v1/test')
+
+ await handler(req, res)
+
+ expect(fetchMock).not.toHaveBeenCalled()
+ expect(res._getStatusCode()).toBe(400)
+ expect(JSON.parse(res._getData())).toEqual({
+ status: 400,
+ error: { message: 'Provided URL is not a valid Supabase edge function URL' },
+ })
+ })
+
+ it('returns fetch failures as proxy errors', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Connection refused')))
+ const { req, res } = createRequest()
+
+ await handler(req, res)
+
+ expect(res._getStatusCode()).toBe(500)
+ expect(JSON.parse(res._getData())).toEqual({
+ status: 500,
+ error: { message: 'Connection refused' },
+ })
+ })
+})
diff --git a/e2e/studio/features/edge-functions.spec.ts b/e2e/studio/features/edge-functions.spec.ts
new file mode 100644
index 0000000000000..8861185791eeb
--- /dev/null
+++ b/e2e/studio/features/edge-functions.spec.ts
@@ -0,0 +1,61 @@
+import { expect } from '@playwright/test'
+
+import { test } from '../utils/test.js'
+import { toUrl } from '../utils/to-url.js'
+
+const FUNCTION_SLUG = 'error-code-docs'
+
+test.describe('Edge Functions', () => {
+ test('links an sb-error-code response to its documentation', async ({ page, ref }) => {
+ await page.route(`**/api/v1/projects/${ref}/functions/${FUNCTION_SLUG}`, async (route) => {
+ const timestamp = Date.now()
+ await route.fulfill({
+ contentType: 'application/json',
+ body: JSON.stringify({
+ id: '00000000-0000-0000-0000-000000000000',
+ slug: FUNCTION_SLUG,
+ name: FUNCTION_SLUG,
+ version: 1,
+ status: 'ACTIVE',
+ entrypoint_path: `supabase/functions/${FUNCTION_SLUG}/index.ts`,
+ created_at: timestamp,
+ updated_at: timestamp,
+ }),
+ })
+ })
+ await page.route('**/api/edge-functions/test', async (route) => {
+ await route.fulfill({
+ contentType: 'application/json',
+ body: JSON.stringify({
+ status: 401,
+ headers: { 'sb-error-code': 'UNAUTHORIZED_INVALID_JWT_FORMAT' },
+ body: '',
+ }),
+ })
+ })
+
+ await page.goto(toUrl(`/project/${ref}/functions/${FUNCTION_SLUG}`))
+ await page.getByRole('button', { name: 'Test', exact: true }).click()
+
+ const testResponse = page.waitForResponse(
+ (response) =>
+ response.url().includes('/api/edge-functions/test') &&
+ response.request().method() === 'POST'
+ )
+ await page.getByRole('button', { name: 'Send Request' }).click()
+ expect(
+ (await testResponse).ok(),
+ 'Studio proxy should return a successful response envelope'
+ ).toBeTruthy()
+
+ await expect(
+ page.getByRole('link', {
+ name: 'View documentation for UNAUTHORIZED_INVALID_JWT_FORMAT (opens in new tab)',
+ }),
+ 'Error response should link to its documentation section'
+ ).toHaveAttribute(
+ 'href',
+ 'https://supabase.com/docs/guides/functions/error-codes#unauthorizedinvalidjwtformat'
+ )
+ })
+})
diff --git a/packages/ui-patterns/src/McpUrlBuilder/assets/kimi-icon-dark.svg b/packages/ui-patterns/src/McpUrlBuilder/assets/kimi-icon-dark.svg
new file mode 100644
index 0000000000000..949cf16d79207
--- /dev/null
+++ b/packages/ui-patterns/src/McpUrlBuilder/assets/kimi-icon-dark.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/packages/ui-patterns/src/McpUrlBuilder/assets/kimi-icon.svg b/packages/ui-patterns/src/McpUrlBuilder/assets/kimi-icon.svg
new file mode 100644
index 0000000000000..209edf3b9dac6
--- /dev/null
+++ b/packages/ui-patterns/src/McpUrlBuilder/assets/kimi-icon.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/packages/ui-patterns/src/McpUrlBuilder/clients.data.ts b/packages/ui-patterns/src/McpUrlBuilder/clients.data.ts
index 06e5dd64f0b27..f1bfecd348ada 100644
--- a/packages/ui-patterns/src/McpUrlBuilder/clients.data.ts
+++ b/packages/ui-patterns/src/McpUrlBuilder/clients.data.ts
@@ -6,6 +6,7 @@ import type {
FactoryMcpConfig,
GeminiMcpConfig,
GooseMcpConfig,
+ KimiMcpConfig,
McpClientBaseConfig,
McpClientConfig,
McpClientDeepLinkOptions,
@@ -152,6 +153,24 @@ export const MCP_CLIENT_DATA: McpClientData[] = [
}
},
},
+ {
+ key: 'kimi',
+ label: 'Kimi Code',
+ icon: 'kimi',
+ hasDistinctDarkIcon: true,
+ configFile: '.kimi-code/mcp.json',
+ externalDocsUrl: 'https://www.kimi.com/code/docs/en/kimi-code-cli/customization/mcp.html',
+ transformConfig: (config): KimiMcpConfig => {
+ return {
+ mcpServers: {
+ supabase: {
+ transport: 'http',
+ url: config.mcpServers.supabase.url,
+ },
+ },
+ }
+ },
+ },
{
key: 'gemini-cli',
label: 'Gemini CLI',
@@ -375,7 +394,7 @@ export const MCP_CLIENT_GROUPS = [
},
{
heading: 'IDE',
- keys: ['cursor', 'vscode', 'antigravity', 'kiro', 'windsurf'],
+ keys: ['cursor', 'vscode', 'antigravity', 'kiro', 'windsurf', 'kimi'],
},
] as const
diff --git a/packages/ui-patterns/src/McpUrlBuilder/clients.instructions.md.tsx b/packages/ui-patterns/src/McpUrlBuilder/clients.instructions.md.tsx
index be200091d4e10..670b09ceadede 100644
--- a/packages/ui-patterns/src/McpUrlBuilder/clients.instructions.md.tsx
+++ b/packages/ui-patterns/src/McpUrlBuilder/clients.instructions.md.tsx
@@ -88,6 +88,25 @@ export const MCP_CLIENT_INSTRUCTIONS: Record = {
>
),
},
+ kimi: {
+ alternate: () => (
+ <>
+
+ Kimi Code reads from your current working
+ directory. To make the server available in every project, place the file under{' '}
+ instead, which defaults to{' '}
+ .
+
+
+ Restart Kimi Code or start a new session to load the server, then check its status by
+ running:
+
+
+ To configure MCP servers and complete the Supabase OAuth login, run:
+
+ >
+ ),
+ },
'gemini-cli': {
primary: ({ isPlatform, url }) => (
<>
diff --git a/packages/ui-patterns/src/McpUrlBuilder/types.ts b/packages/ui-patterns/src/McpUrlBuilder/types.ts
index 3b19577127071..97a03ae103b4d 100644
--- a/packages/ui-patterns/src/McpUrlBuilder/types.ts
+++ b/packages/ui-patterns/src/McpUrlBuilder/types.ts
@@ -175,6 +175,19 @@ export interface CopilotMcpConfig extends McpClientBaseConfig {
}
}
+/**
+ * Configuration format for Kimi Code CLI MCP client.
+ * Kimi keys the server transport with `transport` (not `type`).
+ */
+export interface KimiMcpConfig extends McpClientBaseConfig {
+ mcpServers: {
+ supabase: {
+ transport: 'http'
+ url: string
+ }
+ }
+}
+
// Union of all possible config types
export type McpClientConfig =
| AntigravityMcpConfig
@@ -186,6 +199,7 @@ export type McpClientConfig =
| FactoryMcpConfig
| GeminiMcpConfig
| GooseMcpConfig
+ | KimiMcpConfig
| McpClientBaseConfig
| OpenCodeMcpConfig
| OtherMcpConfig
diff --git a/packages/ui-patterns/src/McpUrlBuilder/utils/mcpIconAssets.ts b/packages/ui-patterns/src/McpUrlBuilder/utils/mcpIconAssets.ts
index ebebe51289ba1..cb27dd3fd299f 100644
--- a/packages/ui-patterns/src/McpUrlBuilder/utils/mcpIconAssets.ts
+++ b/packages/ui-patterns/src/McpUrlBuilder/utils/mcpIconAssets.ts
@@ -11,6 +11,8 @@ import factoryIcon from '../assets/factory-icon.svg'
import geminiCliIcon from '../assets/gemini-cli-icon.svg'
import gooseDarkIcon from '../assets/goose-icon-dark.svg'
import gooseIcon from '../assets/goose-icon.svg'
+import kimiDarkIcon from '../assets/kimi-icon-dark.svg'
+import kimiIcon from '../assets/kimi-icon.svg'
import kiroIcon from '../assets/kiro-icon.svg'
import openaiDarkIcon from '../assets/openai-icon-dark.svg'
import openaiIcon from '../assets/openai-icon.svg'
@@ -37,6 +39,7 @@ const MCP_CLIENT_ICON_ASSETS = {
factory: { light: factoryIcon, dark: factoryDarkIcon },
'gemini-cli': { light: geminiCliIcon, dark: geminiCliIcon },
goose: { light: gooseIcon, dark: gooseDarkIcon },
+ kimi: { light: kimiIcon, dark: kimiDarkIcon },
kiro: { light: kiroIcon, dark: kiroIcon },
openai: { light: openaiIcon, dark: openaiDarkIcon },
opencode: { light: opencodeIcon, dark: opencodeDarkIcon },