mirror of
https://github.com/supabase/supabase.git
synced 2026-10-09 11:25:06 +03:00
## I have read the [CONTRIBUTING.md](<https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md>) file. YES ## What kind of change does this PR introduce? Feature / refactor. ## What is the current behavior? The dashboard assistant runs `@supabase/mcp-server-supabase` in-process over an in-memory transport (`lib/ai/supabase-mcp.ts`). ## What is the new behavior? The assistant connects to the **remote MCP server** over HTTP (`@ai-sdk/mcp`), forwarding the dashboard session token as a bearer. URL comes from `NEXT_PUBLIC_MCP_URL` with a local-dev fallback; platform-only, and Nimbus works via the same env var. * **Tool model unchanged:** UI-controlled `execute_sql` (with `needsApproval`) and `deploy_edge_function` still come from Studio; the allowlist (`TOOL_CATEGORY_MAP`) remains the gate keeping the remote's write tools away from the assistant (`read_only` is defense-in-depth). * **Attribution:** sends `x-source-name: supabase-studio` (+ `x-source-version`) → logged as `source_name`/`client_name`. * **Connection lifecycle:** the HTTP client is closed via the request's `AbortSignal` (tools execute later during streaming); `signal` is required on `getTools`/`getMcpTools`. * **Resilience:** a remote-MCP failure degrades to the remaining tools instead of failing the assistant. * **Drift protection:** relied-upon tools are typed against `keyof typeof supabaseMcpToolSchemas`, so a package bump that renames/removes one fails `pnpm typecheck`; a runtime check also warns if the deployed server returns fewer tools. * Adds unit tests for the above. ## Additional context * Verified end-to-end against a local remote MCP server with a dashboard token: `initialize` 200, tools listed, a tool executed, client closed cleanly. * The remote MCP (mgmt-api) already accepts dashboard session tokens (GoTrue-JWT auth path) — no backend change needed. `NEXT_PUBLIC_MCP_URL` must point at each env's `/mcp`. * `@supabase/mcp-server-supabase` is kept — still used by the self-hosted `/api/mcp` routes. Closes [AI-137](https://linear.app/supabase/issue/AI-137/switch-dashboard-assistant-to-remote-mcp) ## Rollout * **Rollout:** merges with `USE_REMOTE_MCP` off (in-process); flip it to `true` per environment (staging → prod → Nimbus) once each one's prerequisites land. * **Rollback:** unset `USE_REMOTE_MCP` and redeploy to fall back to the in-process client — no revert needed. ## Summary by CodeRabbit * **Bug Fixes** * Improved AI request handling so tool loading and generation clean up properly when a request is cancelled or the browser connection closes. * Added safer fallback behavior when remote tool loading fails, so AI features can continue with available tools instead of stopping entirely. * Updated remote tool access to use the current project reference and preserve the correct access headers. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * AI tools now connect more reliably to remote services and stop cleanly when requests end or are canceled. * Tool loading is more resilient, continuing with available tools if remote access is unavailable. * **Bug Fixes** * Improved cleanup to prevent lingering connections during SQL generation and policy workflows. * Added safer handling for remote tool changes and invalid responses. * **Tests** * Expanded automated coverage for remote tool setup, cancellation, and fallback behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
173 lines
6.1 KiB
TypeScript
173 lines
6.1 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import { createInProcessSupabaseMCPClient, createSupabaseMCPClient } from '../supabase-mcp'
|
|
import { getMcpTools } from './mcp-tools'
|
|
|
|
vi.mock('../supabase-mcp', () => ({
|
|
createSupabaseMCPClient: vi.fn(),
|
|
createInProcessSupabaseMCPClient: vi.fn(),
|
|
}))
|
|
|
|
const BASE_PARAMS = {
|
|
accessToken: 'token',
|
|
projectRef: 'abcdefghijklmnopqrst',
|
|
aiOptInLevel: 'schema_and_log_and_data' as const,
|
|
// A fresh, non-aborted signal by default; lifecycle tests override it
|
|
signal: new AbortController().signal,
|
|
}
|
|
|
|
// A realistic remote tool set: all expected read tools plus the UI-executed ones
|
|
const FULL_REMOTE_TOOLS = {
|
|
search_docs: { description: 'docs' },
|
|
list_tables: { description: 'list tables' },
|
|
list_extensions: { description: 'extensions' },
|
|
list_edge_functions: { description: 'edge functions' },
|
|
list_branches: { description: 'branches' },
|
|
get_advisors: { description: 'advisors' },
|
|
get_logs: { description: 'get logs' },
|
|
execute_sql: { description: 'execute sql' },
|
|
deploy_edge_function: { description: 'deploy' },
|
|
}
|
|
|
|
describe('ai/tools/mcp-tools getMcpTools', () => {
|
|
let close: ReturnType<typeof vi.fn>
|
|
let tools: ReturnType<typeof vi.fn>
|
|
let consoleErrorSpy: ReturnType<typeof vi.spyOn>
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
// These tests exercise the remote transport; pin the migration gate to it.
|
|
process.env.USE_REMOTE_MCP = 'true'
|
|
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
close = vi.fn().mockResolvedValue(undefined)
|
|
tools = vi.fn().mockResolvedValue({ ...FULL_REMOTE_TOOLS })
|
|
vi.mocked(createSupabaseMCPClient).mockResolvedValue({ tools, close } as any)
|
|
})
|
|
|
|
afterEach(() => {
|
|
delete process.env.USE_REMOTE_MCP
|
|
consoleErrorSpy.mockRestore()
|
|
})
|
|
|
|
it('returns MCP tools and strips UI-executed tools handled locally', async () => {
|
|
const result = await getMcpTools(BASE_PARAMS)
|
|
|
|
expect(result).toHaveProperty('list_tables')
|
|
expect(result).toHaveProperty('get_logs')
|
|
expect(result).not.toHaveProperty('execute_sql')
|
|
expect(result).not.toHaveProperty('deploy_edge_function')
|
|
})
|
|
|
|
it('warns when the remote server is missing an expected tool (contract drift)', async () => {
|
|
const { list_branches, ...withoutBranches } = FULL_REMOTE_TOOLS
|
|
tools.mockResolvedValueOnce(withoutBranches)
|
|
|
|
await getMcpTools(BASE_PARAMS)
|
|
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('list_branches'))
|
|
})
|
|
|
|
it('does not warn about drift when all expected tools are present', async () => {
|
|
await getMcpTools(BASE_PARAMS)
|
|
|
|
const driftWarnings = consoleErrorSpy.mock.calls.filter(
|
|
([msg]: unknown[]) => typeof msg === 'string' && msg.includes('missing expected tools')
|
|
)
|
|
expect(driftWarnings).toHaveLength(0)
|
|
})
|
|
|
|
it('keeps the connection open until the request signal aborts', async () => {
|
|
const controller = new AbortController()
|
|
|
|
await getMcpTools({ ...BASE_PARAMS, signal: controller.signal })
|
|
// Tools execute later during streaming, so the client must stay open
|
|
expect(close).not.toHaveBeenCalled()
|
|
|
|
controller.abort()
|
|
await Promise.resolve()
|
|
expect(close).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('closes the client and skips fetching tools when the signal is already aborted', async () => {
|
|
const controller = new AbortController()
|
|
controller.abort()
|
|
|
|
const result = await getMcpTools({ ...BASE_PARAMS, signal: controller.signal })
|
|
|
|
expect(close).toHaveBeenCalledTimes(1)
|
|
expect(tools).not.toHaveBeenCalled()
|
|
expect(result).toEqual({})
|
|
})
|
|
|
|
it('closes the client and rethrows when fetching tools fails, without double-closing on later abort', async () => {
|
|
tools.mockRejectedValueOnce(new Error('network unreachable'))
|
|
const controller = new AbortController()
|
|
|
|
await expect(getMcpTools({ ...BASE_PARAMS, signal: controller.signal })).rejects.toThrow(
|
|
'network unreachable'
|
|
)
|
|
expect(close).toHaveBeenCalledTimes(1)
|
|
|
|
// A subsequent abort must not close the (already closed) client again
|
|
controller.abort()
|
|
await Promise.resolve()
|
|
expect(close).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('closes the client and rethrows when tool validation fails', async () => {
|
|
// A known tool name with a non-object value passes the opt-in filter but
|
|
// fails schema validation
|
|
tools.mockResolvedValueOnce({ ...FULL_REMOTE_TOOLS, list_tables: 'not-an-object' })
|
|
|
|
await expect(getMcpTools(BASE_PARAMS)).rejects.toThrow('MCP tools validation failed')
|
|
expect(close).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|
|
|
|
describe('ai/tools/mcp-tools getMcpTools transport selection', () => {
|
|
let close: ReturnType<typeof vi.fn>
|
|
let tools: ReturnType<typeof vi.fn>
|
|
let consoleErrorSpy: ReturnType<typeof vi.spyOn>
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
close = vi.fn().mockResolvedValue(undefined)
|
|
tools = vi.fn().mockResolvedValue({ ...FULL_REMOTE_TOOLS })
|
|
vi.mocked(createSupabaseMCPClient).mockResolvedValue({ tools, close } as any)
|
|
vi.mocked(createInProcessSupabaseMCPClient).mockResolvedValue({ tools, close } as any)
|
|
})
|
|
|
|
afterEach(() => {
|
|
delete process.env.USE_REMOTE_MCP
|
|
consoleErrorSpy.mockRestore()
|
|
})
|
|
|
|
it('uses the remote client when USE_REMOTE_MCP is "true"', async () => {
|
|
process.env.USE_REMOTE_MCP = 'true'
|
|
|
|
await getMcpTools(BASE_PARAMS)
|
|
|
|
expect(createSupabaseMCPClient).toHaveBeenCalledTimes(1)
|
|
expect(createInProcessSupabaseMCPClient).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('falls back to the in-process client when USE_REMOTE_MCP is unset (default)', async () => {
|
|
delete process.env.USE_REMOTE_MCP
|
|
|
|
await getMcpTools(BASE_PARAMS)
|
|
|
|
expect(createInProcessSupabaseMCPClient).toHaveBeenCalledTimes(1)
|
|
expect(createSupabaseMCPClient).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('uses the in-process client for any non-"true" value', async () => {
|
|
process.env.USE_REMOTE_MCP = 'false'
|
|
|
|
await getMcpTools(BASE_PARAMS)
|
|
|
|
expect(createInProcessSupabaseMCPClient).toHaveBeenCalledTimes(1)
|
|
expect(createSupabaseMCPClient).not.toHaveBeenCalled()
|
|
})
|
|
})
|