Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions packages/mcp-server-supabase/src/platform/api-platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
Expand Down
19 changes: 19 additions & 0 deletions packages/mcp-server-supabase/src/platform/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,26 @@ export type StorageOperations = {
listAllBuckets(projectId: string): Promise<StorageBucket[]>;
};

/** 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<BranchingAvailability>;
listBranches(projectId: string): Promise<Branch[]>;
createBranch(
projectId: string,
Expand Down
257 changes: 257 additions & 0 deletions packages/mcp-server-supabase/src/server.branching.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,17 @@ import type {
} from '@modelcontextprotocol/client';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import {
ACCESS_TOKEN,
API_URL,
createBranch,
createOrganization,
createProject,
createProjectFixture,
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';
Expand Down Expand Up @@ -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<BranchingOperations['getAvailability']>
) {
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' });
});
});
Loading
Loading