From 8902bc4aea1d685f3e28670925d1659ccb2977cd Mon Sep 17 00:00:00 2001 From: Pedro Rodrigues Date: Thu, 8 Oct 2026 12:16:30 +0100 Subject: [PATCH 1/2] feat(branching): return a non-error result when the project can't branch BranchingOperations gains an optional getAvailability(projectId). create_branch asks it before any cost step and, when the platform says the project's organization can't branch, returns the platform's message as a normal tool result instead of failing with the 402. get_cost gains an optional project_id and does the same for branch costs. Project-scoped create_branch accepts a missing confirm_cost_id so it can reach the check, and still rejects it with the existing message when branching is available. Hosts without getAvailability, and failed checks, keep today's behavior. Refs AI-1292. --- .../mcp-server-supabase/src/platform/types.ts | 19 ++ .../src/server.branching.test.ts | 257 ++++++++++++++++++ packages/mcp-server-supabase/src/server.ts | 39 ++- .../src/tools/branching-tools.ts | 86 +++++- 4 files changed, 394 insertions(+), 7 deletions(-) diff --git a/packages/mcp-server-supabase/src/platform/types.ts b/packages/mcp-server-supabase/src/platform/types.ts index 062908d1..cacf82f3 100644 --- a/packages/mcp-server-supabase/src/platform/types.ts +++ b/packages/mcp-server-supabase/src/platform/types.ts @@ -348,7 +348,26 @@ export type StorageOperations = { listAllBuckets(projectId: string): Promise; }; +/** The host resolves a branch to its parent project. */ +export type BranchingAvailabilityScope = + | { projectId: string } + | { branchId: string }; + +export type BranchingAvailability = + | { available: true } + /** `message` is relayed to the agent; without it the server uses a generic one. */ + | { available: false; message?: string }; + export type BranchingOperations = { + /** + * Whether the project's organization can use branching, as the platform + * decides it. Every branching tool, and branch `get_cost`, asks before + * acting. A rejected promise lets the call proceed, so the other operations + * must keep enforcing the entitlement. + */ + getAvailability?( + scope: BranchingAvailabilityScope + ): Promise; listBranches(projectId: string): Promise; createBranch( projectId: string, diff --git a/packages/mcp-server-supabase/src/server.branching.test.ts b/packages/mcp-server-supabase/src/server.branching.test.ts index 83498abe..93e80705 100644 --- a/packages/mcp-server-supabase/src/server.branching.test.ts +++ b/packages/mcp-server-supabase/src/server.branching.test.ts @@ -6,6 +6,8 @@ import type { } from '@modelcontextprotocol/client'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { + ACCESS_TOKEN, + API_URL, createBranch, createOrganization, createProject, @@ -13,6 +15,8 @@ import { mockBranches, } from '../test/mocks.js'; import { callModernTool, createServerHarness } from '../test/server-harness.js'; +import { createSupabaseApiPlatform } from './platform/api-platform.js'; +import type { BranchingOperations } from './platform/types.js'; import { BRANCH_COST_HOURLY, getBranchCost } from './pricing.js'; import * as pricing from './pricing.js'; import type { SupabaseMcpServerOptions } from './server.js'; @@ -1212,3 +1216,256 @@ describe('tools', () => { }); }); }); + +describe('branching availability', () => { + const UNAVAILABLE = { + available: false, + message: 'Branching is not available. Upgrade at https://example.test', + } as const; + + const UNAVAILABLE_RESULT = { + content: [{ type: 'text', text: UNAVAILABLE.message }], + structuredContent: { status: 'unavailable', message: UNAVAILABLE.message }, + }; + + function createPlatform( + getAvailability: NonNullable + ) { + const platform = createSupabaseApiPlatform({ + accessToken: ACCESS_TOKEN, + apiUrl: API_URL, + }); + platform.branching = { ...platform.branching!, getAvailability }; + return platform; + } + + test('create_branch returns the unavailable result instead of a tool error', async () => { + const getAvailability = vi.fn(async () => UNAVAILABLE); + const { client } = await setup({ + platform: createPlatform(getAvailability), + features: ['account', 'branching'], + }); + const { project } = await createProjectFixture(); + + const result = await client.callTool({ + name: 'create_branch', + arguments: { + project_id: project.id, + name: 'test-branch', + confirm_cost_id: await hashObject(getBranchCost()), + }, + }); + + expect(result).toEqual(UNAVAILABLE_RESULT); + expect(getAvailability).toHaveBeenCalledWith({ projectId: project.id }); + expect(mockBranches.size).toBe(0); + }); + + test('create_branch answers before the form cost prompt', async () => { + const { client, platform } = await setupModern({ + clientCapabilities: FORM_CAPABLE, + }); + platform.branching = { + ...platform.branching!, + getAvailability: async () => UNAVAILABLE, + }; + const { project } = await createProjectFixture(); + + const result = await callModernTool(client, { + name: 'create_branch', + arguments: { project_id: project.id, name: 'test-branch' }, + }); + + expect(result).toMatchObject(UNAVAILABLE_RESULT); + expect(result).not.toHaveProperty('isError'); + expect(mockBranches.size).toBe(0); + }); + + test('project-scoped create_branch answers without confirm_cost_id', async () => { + const { project } = await createProjectFixture(); + const { client } = await setup({ + platform: createPlatform(async () => UNAVAILABLE), + projectId: project.id, + features: ['branching'], + }); + + const result = await client.callTool({ + name: 'create_branch', + arguments: { name: 'test-branch' }, + }); + + expect(result).toEqual(UNAVAILABLE_RESULT); + }); + + test('project-scoped create_branch still requires confirm_cost_id when available', async () => { + const { project } = await createProjectFixture(); + const { callTool } = await setup({ + platform: createPlatform(async () => ({ available: true })), + projectId: project.id, + features: ['branching'], + }); + + await expect( + callTool({ name: 'create_branch', arguments: { name: 'test-branch' } }) + ).rejects.toThrow( + 'User must confirm understanding of costs before creating a branch.' + ); + expect(mockBranches.size).toBe(0); + }); + + test('get_cost returns the unavailable result for a branch with project_id', async () => { + const getAvailability = vi.fn(async () => UNAVAILABLE); + const { client } = await setup({ + platform: createPlatform(getAvailability), + features: ['account', 'branching'], + }); + const { project, organization } = await createProjectFixture(); + + const result = await client.callTool({ + name: 'get_cost', + arguments: { + type: 'branch', + organization_id: organization.id, + project_id: project.id, + }, + }); + + expect(result).toEqual(UNAVAILABLE_RESULT); + expect(getAvailability).toHaveBeenCalledWith({ projectId: project.id }); + }); + + test('get_cost quotes as before without project_id or for projects', async () => { + const getAvailability = vi.fn(async () => UNAVAILABLE); + const { callTool } = await setup({ + platform: createPlatform(getAvailability), + features: ['account', 'branching'], + }); + const { project, organization } = await createProjectFixture(); + + await expect( + callTool({ + name: 'get_cost', + arguments: { type: 'branch', organization_id: organization.id }, + }) + ).resolves.toEqual(getBranchCost()); + await expect( + callTool({ + name: 'get_cost', + arguments: { + type: 'project', + organization_id: organization.id, + project_id: project.id, + }, + }) + ).resolves.toMatchObject({ type: 'project' }); + expect(getAvailability).not.toHaveBeenCalled(); + }); + + test('create_branch uses a generic answer when the host gives no message', async () => { + const { client } = await setup({ + platform: createPlatform(async () => ({ available: false })), + features: ['account', 'branching'], + }); + const { project } = await createProjectFixture(); + + const result = await client.callTool({ + name: 'create_branch', + arguments: { + project_id: project.id, + name: 'test-branch', + confirm_cost_id: await hashObject(getBranchCost()), + }, + }); + + expect(result).not.toHaveProperty('isError'); + expect(result).toMatchObject({ + structuredContent: { + status: 'unavailable', + message: expect.stringContaining("Branching isn't available"), + }, + }); + expect(mockBranches.size).toBe(0); + }); + + test('list_branches returns the unavailable result', async () => { + const getAvailability = vi.fn(async () => UNAVAILABLE); + const platform = createPlatform(getAvailability); + const listBranches = vi.spyOn(platform.branching!, 'listBranches'); + const { client } = await setup({ platform, features: ['branching'] }); + const { project } = await createProjectFixture(); + + const result = await client.callTool({ + name: 'list_branches', + arguments: { project_id: project.id }, + }); + + expect(result).toEqual(UNAVAILABLE_RESULT); + expect(getAvailability).toHaveBeenCalledWith({ projectId: project.id }); + expect(listBranches).not.toHaveBeenCalled(); + }); + + test.each([ + ['delete_branch', 'deleteBranch', {}], + ['merge_branch', 'mergeBranch', {}], + ['reset_branch', 'resetBranch', {}], + ['rebase_branch', 'rebaseBranch', {}], + ] as const)( + '%s returns the unavailable result', + async (name, operation, extraArguments) => { + const getAvailability = vi.fn(async () => UNAVAILABLE); + const platform = createPlatform(getAvailability); + const branchOperation = vi.spyOn(platform.branching!, operation); + const { client } = await setup({ platform, features: ['branching'] }); + const { project } = await createProjectFixture(); + const branch = await createBranch({ + name: 'test-branch', + parent_project_ref: project.id, + }); + + const result = await client.callTool({ + name, + arguments: { branch_id: branch.id, ...extraArguments }, + }); + + expect(result).toEqual(UNAVAILABLE_RESULT); + expect(getAvailability).toHaveBeenCalledWith({ branchId: branch.id }); + expect(branchOperation).not.toHaveBeenCalled(); + } + ); + + test('branch-id tools work as before when branching is available', async () => { + const platform = createPlatform(async () => ({ available: true })); + const { callTool } = await setup({ platform, features: ['branching'] }); + const { project } = await createProjectFixture(); + const branch = await createBranch({ + name: 'test-branch', + parent_project_ref: project.id, + }); + + await expect( + callTool({ name: 'delete_branch', arguments: { branch_id: branch.id } }) + ).resolves.toEqual({ success: true }); + expect(mockBranches.has(branch.id)).toBe(false); + }); + + test('a failing availability check lets create_branch proceed', async () => { + const { callTool } = await setup({ + platform: createPlatform(async () => { + throw new Error('availability unavailable'); + }), + features: ['account', 'branching'], + }); + const { project } = await createProjectFixture(); + + await expect( + callTool({ + name: 'create_branch', + arguments: { + project_id: project.id, + name: 'test-branch', + confirm_cost_id: await hashObject(getBranchCost()), + }, + }) + ).resolves.toMatchObject({ name: 'test-branch' }); + }); +}); diff --git a/packages/mcp-server-supabase/src/server.ts b/packages/mcp-server-supabase/src/server.ts index 83933055..49cc9dc4 100644 --- a/packages/mcp-server-supabase/src/server.ts +++ b/packages/mcp-server-supabase/src/server.ts @@ -8,7 +8,10 @@ import packageJson from '../package.json' with { type: 'json' }; import { createContentApiClient } from './content-api/index.js'; import type { SupabasePlatform } from './platform/types.js'; import { getAccountTools } from './tools/account-tools.js'; -import { getBranchingTools } from './tools/branching-tools.js'; +import { + getBranchingTools, + getBranchingUnavailableResult, +} from './tools/branching-tools.js'; import { type ElicitationState, isFormCapable, @@ -119,6 +122,16 @@ const DEFAULT_FEATURES: FeatureGroup[] = [ export const PLATFORM_INDEPENDENT_FEATURES: FeatureGroup[] = ['docs']; +// Module scope: describe() registers in the global zod registry, so building it per request would grow it. +const branchCostProjectIdParameter = z.object({ + project_id: z + .string() + .optional() + .describe( + 'The project ID to create the branch on. Pass it when getting the cost of a branch.' + ), +}); + export const instructions = ` Here are guidelines for using Supabase tools effectively: @@ -330,6 +343,30 @@ export function createSupabaseMcpServer(options: SupabaseMcpServerOptions) { } } + // Branch quotes ask the platform first, so an organization without + // branching gets that answer before any cost step. + const getCost = tools.get_cost; + if (getCost && branching?.getAvailability) { + tools.get_cost = { + ...getCost, + parameters: getCost.parameters.extend( + branchCostProjectIdParameter.shape + ), + execute: async (params, ctx) => { + const { project_id } = branchCostProjectIdParameter.parse(params); + const args = getCost.parameters.parse(params); + return ( + (args.type === 'branch' && + project_id && + (await getBranchingUnavailableResult(branching, { + projectId: project_id, + }))) || + (await getCost.execute(args, ctx)) + ); + }, + }; + } + // Form-capable clients confirm cost inside the create tools, so those // drop `confirm_cost_id` and the legacy cost tools only offer the types // that still need them. With nothing left to quote they are hidden diff --git a/packages/mcp-server-supabase/src/tools/branching-tools.ts b/packages/mcp-server-supabase/src/tools/branching-tools.ts index b7de9afd..3cc0b94b 100644 --- a/packages/mcp-server-supabase/src/tools/branching-tools.ts +++ b/packages/mcp-server-supabase/src/tools/branching-tools.ts @@ -1,11 +1,15 @@ import { + type CallToolResult, inputRequired, type RequestStateCodec, type ServerContext, } from '@modelcontextprotocol/server'; import { tool } from '@supabase/mcp-utils'; import { z } from 'zod/v4'; -import type { BranchingOperations } from '../platform/types.js'; +import type { + BranchingAvailabilityScope, + BranchingOperations, +} from '../platform/types.js'; import { branchSchema } from '../platform/types.js'; import { getBranchCost } from '../pricing.js'; import { hashObject } from '../util.js'; @@ -33,15 +37,16 @@ type BranchingToolsOptions = { }; }; +const missingCostConfirmationMessage = + 'User must confirm understanding of costs before creating a branch.'; + const createBranchInputSchema = z.object({ project_id: z.string(), name: z.string().default('develop').describe('Name of the branch to create'), confirm_cost_id: z .string({ error: (issue) => - issue.input === undefined - ? 'User must confirm understanding of costs before creating a branch.' - : undefined, + issue.input === undefined ? missingCostConfirmationMessage : undefined, }) .describe('The cost confirmation ID. Call `confirm_cost` first.'), }); @@ -55,6 +60,10 @@ const createBranchInputSchemaWithElicitation = createBranchInputSchema.extend({ ), }); +// Project scope has no confirm_cost tool, so the availability check must be reachable without an ID. +const createBranchInputSchemaOptionalConfirmation = + createBranchInputSchema.partial({ confirm_cost_id: true }); + const createBranchOutputSchema = branchSchema; const listBranchesInputSchema = z.object({ @@ -194,7 +203,9 @@ export function getBranchingTools({ ...branchingToolDefs.create_branch, parameters: confirmation ? createBranchInputSchemaWithElicitation - : createBranchInputSchema, + : projectId + ? createBranchInputSchemaOptionalConfirmation + : createBranchInputSchema, inject: { project_id }, execute: async ( { @@ -208,6 +219,17 @@ export function getBranchingTools({ throw new Error('Cannot create a branch in read-only mode.'); } + const unavailable = await getBranchingUnavailableResult(branching, { + projectId: project_id, + }); + if (unavailable) { + return unavailable; + } + + if (!confirmation && confirm_cost_id === undefined) { + throw new Error(missingCostConfirmationMessage); + } + if (confirmation && isFormCapable(ctx)) { const { codec } = confirmation; const cost = getBranchCost(); @@ -274,7 +296,11 @@ export function getBranchingTools({ ...branchingToolDefs.list_branches, inject: { project_id }, execute: async ({ project_id }) => { - return { branches: await branching.listBranches(project_id) }; + return ( + (await getBranchingUnavailableResult(branching, { + projectId: project_id, + })) ?? { branches: await branching.listBranches(project_id) } + ); }, }), delete_branch: tool({ @@ -283,6 +309,12 @@ export function getBranchingTools({ if (readOnly) { throw new Error('Cannot delete a branch in read-only mode.'); } + const unavailable = await getBranchingUnavailableResult(branching, { + branchId: branch_id, + }); + if (unavailable) { + return unavailable; + } await branching.deleteBranch(branch_id); return { success: true }; @@ -294,6 +326,12 @@ export function getBranchingTools({ if (readOnly) { throw new Error('Cannot merge a branch in read-only mode.'); } + const unavailable = await getBranchingUnavailableResult(branching, { + branchId: branch_id, + }); + if (unavailable) { + return unavailable; + } await branching.mergeBranch(branch_id); return { success: true }; @@ -305,6 +343,12 @@ export function getBranchingTools({ if (readOnly) { throw new Error('Cannot reset a branch in read-only mode.'); } + const unavailable = await getBranchingUnavailableResult(branching, { + branchId: branch_id, + }); + if (unavailable) { + return unavailable; + } await branching.resetBranch(branch_id, { migration_version, @@ -318,6 +362,12 @@ export function getBranchingTools({ if (readOnly) { throw new Error('Cannot rebase a branch in read-only mode.'); } + const unavailable = await getBranchingUnavailableResult(branching, { + branchId: branch_id, + }); + if (unavailable) { + return unavailable; + } await branching.rebaseBranch(branch_id); return { success: true }; @@ -325,3 +375,27 @@ export function getBranchingTools({ }), }; } + +/** + * The platform's "not available" answer as a normal result: an entitlement + * block is not a tool error. A failed check lets the call proceed. + */ +export async function getBranchingUnavailableResult( + branching: BranchingOperations | undefined, + scope: BranchingAvailabilityScope +): Promise { + const availability = await branching + ?.getAvailability?.(scope) + .catch(() => undefined); + if (availability?.available !== false) { + return undefined; + } + const message = + availability.message ?? + "Branching isn't available for this project's organization. Tell the user and ask whether they want to upgrade their plan."; + return { + content: [{ type: 'text', text: message }], + // Some clients show the model structuredContent instead of the text, so it carries the message too. + structuredContent: { status: 'unavailable', message }, + }; +} From f34a9ba9656f766e145d89db08e4ff2324881169 Mon Sep 17 00:00:00 2001 From: Pedro Rodrigues Date: Thu, 8 Oct 2026 13:00:25 +0100 Subject: [PATCH 2/2] TEMP(AI-1292): call GET /v1/projects/{ref}/branching from the stdio platform Hand-written fetch until the endpoint is deployed and the generated Management API types include it; replace with the typed managementApiClient call before merge. --- .../src/platform/api-platform.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/packages/mcp-server-supabase/src/platform/api-platform.ts b/packages/mcp-server-supabase/src/platform/api-platform.ts index 72251a9a..6f2db5e0 100644 --- a/packages/mcp-server-supabase/src/platform/api-platform.ts +++ b/packages/mcp-server-supabase/src/platform/api-platform.ts @@ -4,6 +4,7 @@ import { } from '@mjackson/multipart-parser'; import type { InitData } from '@supabase/mcp-utils'; import { fileURLToPath } from 'node:url'; +import { z } from 'zod/v4'; import packageJson from '../../package.json' with { type: 'json' }; import { getDeploymentId, normalizeFilename } from '../edge-function.js'; import { getClickHouseLogQuery } from '../logs.js'; @@ -706,6 +707,40 @@ export function createSupabaseApiPlatform( }; const branching: BranchingOperations = { + // TEMP(AI-1292): replace with the typed managementApiClient call once + // GET /v1/projects/{ref}/branching is deployed and the generated types include it. + async getAvailability(scope) { + let projectId: string; + if ('branchId' in scope) { + // A branch is a project in its parent's organization. + const branchResponse = await managementApiClient.GET( + '/v1/branches/{branch_id_or_ref}', + { params: { path: { branch_id_or_ref: scope.branchId } } } + ); + assertSuccess(branchResponse, 'Failed to fetch branch'); + projectId = branchResponse.data.ref; + } else { + projectId = scope.projectId; + } + const response = await fetch( + `${managementApiUrl}/v1/projects/${encodeURIComponent(projectId)}/branching`, + { headers: { Authorization: `Bearer ${accessToken}` } } + ); + if (!response.ok) { + throw new Error(`Failed to check branching: ${response.status}`); + } + const { available } = z + .object({ available: z.boolean() }) + .parse(await response.json()); + if (available) { + return { available: true }; + } + const { organization_slug } = await account.getProject(projectId); + return { + available: false, + message: `Branching isn't available for this organization. Branching is supported only on the Pro plan or above. Ask the user whether they want to upgrade. They can upgrade on the organization's billing page: https://supabase.com/dashboard/org/${organization_slug}/billing`, + }; + }, async listBranches(projectId: string) { const response = await managementApiClient.GET( '/v1/projects/{ref}/branches',