diff --git a/packages/mcp-server-supabase/src/server.test.ts b/packages/mcp-server-supabase/src/server.test.ts index cc208ae9..52f6eb35 100644 --- a/packages/mcp-server-supabase/src/server.test.ts +++ b/packages/mcp-server-supabase/src/server.test.ts @@ -4047,6 +4047,121 @@ describe('project scoped tools', () => { }), ]); }); + + test('destructive branch tools reject branches owned by another project', async () => { + const org = await createOrganization({ + name: 'My Org', + plan: 'free', + allowed_release_channels: ['ga'], + }); + + const projectA = await createProject({ + name: 'Project A', + region: 'us-east-1', + organization_id: org.id, + }); + projectA.status = 'ACTIVE_HEALTHY'; + + const projectB = await createProject({ + name: 'Project B', + region: 'us-east-1', + organization_id: org.id, + }); + projectB.status = 'ACTIVE_HEALTHY'; + + const foreignBranch = await createBranch({ + name: 'disposable-branch', + parent_project_ref: projectB.id, + }); + + // Also create a same-project branch so list_branches is non-empty for A + // and we can confirm same-project ops still work when needed. + await createBranch({ + name: 'owned-branch', + parent_project_ref: projectA.id, + }); + + const { callTool } = await setup({ + projectId: projectA.id, + features: ['branching'], + }); + + const listed = await callTool({ + name: 'list_branches', + arguments: {}, + }); + expect(listed.branches).not.toContainEqual( + expect.objectContaining({ id: foreignBranch.id }) + ); + expect(listed.branches).toContainEqual( + expect.objectContaining({ parent_project_ref: projectA.id }) + ); + + const scopeError = `Branch '${foreignBranch.id}' is not a development branch of the scoped project '${projectA.id}'.`; + + await expect( + callTool({ + name: 'delete_branch', + arguments: { branch_id: foreignBranch.id }, + }) + ).rejects.toThrow(scopeError); + + await expect( + callTool({ + name: 'merge_branch', + arguments: { branch_id: foreignBranch.id }, + }) + ).rejects.toThrow(scopeError); + + await expect( + callTool({ + name: 'reset_branch', + arguments: { branch_id: foreignBranch.id }, + }) + ).rejects.toThrow(scopeError); + + await expect( + callTool({ + name: 'rebase_branch', + arguments: { branch_id: foreignBranch.id }, + }) + ).rejects.toThrow(scopeError); + + // Foreign branch must still exist — delete must not have run + expect(mockBranches.has(foreignBranch.id)).toBe(true); + }); + + test('destructive branch tools still work for branches of the scoped project', async () => { + const org = await createOrganization({ + name: 'My Org', + plan: 'free', + allowed_release_channels: ['ga'], + }); + + const project = await createProject({ + name: 'Project A', + region: 'us-east-1', + organization_id: org.id, + }); + project.status = 'ACTIVE_HEALTHY'; + + const ownedBranch = await createBranch({ + name: 'owned-branch', + parent_project_ref: project.id, + }); + + const { callTool } = await setup({ + projectId: project.id, + features: ['branching'], + }); + + await callTool({ + name: 'delete_branch', + arguments: { branch_id: ownedBranch.id }, + }); + + expect(mockBranches.has(ownedBranch.id)).toBe(false); + }); }); describe('docs tools', () => { diff --git a/packages/mcp-server-supabase/src/tools/branching-tools.ts b/packages/mcp-server-supabase/src/tools/branching-tools.ts index bbad5976..94349535 100644 --- a/packages/mcp-server-supabase/src/tools/branching-tools.ts +++ b/packages/mcp-server-supabase/src/tools/branching-tools.ts @@ -151,6 +151,36 @@ export const branchingToolDefs = { }, } as const satisfies ToolDefs; +/** + * When the MCP server is scoped to a single project, destructive branch tools + * must not accept branch IDs that belong to another parent project. + * + * create_branch / list_branches already inject project_id; delete/merge/reset/ + * rebase only take branch_id and previously forwarded it unchecked. + * + * branch_id_or_ref may be either the branch UUID or the branch project_ref. + */ +async function assertBranchBelongsToScopedProject( + branching: BranchingOperations, + branchId: string, + projectId: string | undefined +) { + if (!projectId) { + return; + } + + const branches = await branching.listBranches(projectId); + const belongs = branches.some( + (branch) => branch.id === branchId || branch.project_ref === branchId + ); + + if (!belongs) { + throw new Error( + `Branch '${branchId}' is not a development branch of the scoped project '${projectId}'.` + ); + } +} + export function getBranchingTools({ branching, projectId, @@ -191,6 +221,11 @@ export function getBranchingTools({ throw new Error('Cannot delete a branch in read-only mode.'); } + await assertBranchBelongsToScopedProject( + branching, + branch_id, + project_id + ); await branching.deleteBranch(branch_id); return { success: true }; }, @@ -202,6 +237,11 @@ export function getBranchingTools({ throw new Error('Cannot merge a branch in read-only mode.'); } + await assertBranchBelongsToScopedProject( + branching, + branch_id, + project_id + ); await branching.mergeBranch(branch_id); return { success: true }; }, @@ -213,6 +253,11 @@ export function getBranchingTools({ throw new Error('Cannot reset a branch in read-only mode.'); } + await assertBranchBelongsToScopedProject( + branching, + branch_id, + project_id + ); await branching.resetBranch(branch_id, { migration_version, }); @@ -226,6 +271,11 @@ export function getBranchingTools({ throw new Error('Cannot rebase a branch in read-only mode.'); } + await assertBranchBelongsToScopedProject( + branching, + branch_id, + project_id + ); await branching.rebaseBranch(branch_id); return { success: true }; },