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 && ( + + )} + = 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. */} + +
{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. */} + +
+ + +
)} 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 },