diff --git a/packages/mcp-server-supabase/src/pricing.ts b/packages/mcp-server-supabase/src/pricing.ts index 960bbae3..8ffb1e65 100644 --- a/packages/mcp-server-supabase/src/pricing.ts +++ b/packages/mcp-server-supabase/src/pricing.ts @@ -48,6 +48,6 @@ export async function getNextProjectCost( /** * Gets the cost for a database branch. */ -export function getBranchCost(): Cost { +export function getBranchCost(): BranchCost { return { type: 'branch', recurrence: 'hourly', amount: BRANCH_COST_HOURLY }; } diff --git a/packages/mcp-server-supabase/src/server.test.ts b/packages/mcp-server-supabase/src/server.test.ts index 92b8d224..69a3b8a9 100644 --- a/packages/mcp-server-supabase/src/server.test.ts +++ b/packages/mcp-server-supabase/src/server.test.ts @@ -25,13 +25,19 @@ import { createProject, MCP_CLIENT_NAME, MCP_CLIENT_VERSION, + mockBranches, mockContentApiSchemaLoadCount, mockProjects, setupMockApis, } from '../test/mocks.js'; import { createSupabaseApiPlatform } from './platform/api-platform.js'; import type { SupabasePlatform } from './platform/types.js'; -import { BRANCH_COST_HOURLY, PROJECT_COST_MONTHLY } from './pricing.js'; +import * as pricing from './pricing.js'; +import { + BRANCH_COST_HOURLY, + getBranchCost, + PROJECT_COST_MONTHLY, +} from './pricing.js'; import { createSupabaseMcpServer, instructions, @@ -42,6 +48,7 @@ import { supabaseMcpToolSchemas, } from './tools/tool-schemas.js'; import { createSupabaseMcpHandler } from './transports/http.js'; +import { hashObject } from './util.js'; let mockServer: SetupServer | undefined; @@ -143,6 +150,7 @@ async function setup(options: SetupOptions = {}) { type FormCapableSetupOptions = { readOnly?: boolean; + projectId?: string; /** * Registers an auto-fulfilling `elicitation/create` handler that always * answers with this action, driven via `client.callTool`. Omit for manual @@ -156,7 +164,7 @@ const COST_CONFIRMATION: NonNullable< > = { requestStateKey: 'a'.repeat(32), principal: 'test-user', - enabledTools: ['create_project'], + enabledTools: ['create_project', 'create_branch'], }; // https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/ @@ -165,14 +173,15 @@ const MCP_ENDPOINT = new URL('https://mcp.test'); /** * Sets up an MCP client against the hosted HTTP handler (in-process, via a - * custom `fetch`) for the `create_project` cost-confirmation elicitation - * lane: a client pinned to the 2026-07-28 protocol, declaring per-request - * form capability. Raw `StreamTransport` only speaks the 2025 era, so the - * form-capable lane - which depends on the per-request `_meta` envelope - - * needs the same in-process HTTP transport the hosted runtime uses. + * custom `fetch`) for the `create_project`/`create_branch` cost-confirmation + * elicitation lanes: a client pinned to the 2026-07-28 protocol, declaring + * per-request form capability. Raw `StreamTransport` only speaks the 2025 + * era, so the form-capable lane - which depends on the per-request `_meta` + * envelope - needs the same in-process HTTP transport the hosted runtime + * uses. */ async function setupFormCapable(options: FormCapableSetupOptions = {}) { - const { readOnly, elicitationAction } = options; + const { readOnly, projectId, elicitationAction } = options; const platform = createSupabaseApiPlatform({ accessToken: ACCESS_TOKEN, @@ -190,6 +199,7 @@ async function setupFormCapable(options: FormCapableSetupOptions = {}) { const handler = createSupabaseMcpHandler({ platform, + projectId, readOnly, costConfirmation: COST_CONFIRMATION, }); @@ -3732,6 +3742,596 @@ describe('tools', () => { ); }); + describe('create_branch cost confirmation via elicitation', () => { + test('create_branch requires confirm_cost_id when cost confirmation is not configured', async () => { + const { client } = await setup({ features: ['branching'] }); + + const { tools } = await client.listTools(); + const createBranchTool = tools.find( + (tool) => tool.name === 'create_branch' + ); + + expect(createBranchTool?.inputSchema.required).toContain( + 'confirm_cost_id' + ); + }); + + test('create_branch advertises confirm_cost_id as optional when cost confirmation is configured', async () => { + const { client } = await setup({ + features: ['branching'], + costConfirmation: COST_CONFIRMATION, + }); + + const { tools } = await client.listTools(); + const createBranchTool = tools.find( + (tool) => tool.name === 'create_branch' + ); + + expect(createBranchTool?.inputSchema.required).not.toContain( + 'confirm_cost_id' + ); + }); + + test('capability-free client still succeeds via get_cost -> confirm_cost -> create_branch', async () => { + const { callTool } = await setup({ + features: ['account', 'branching'], + costConfirmation: COST_CONFIRMATION, + }); + + const org = await createOrganization({ + name: 'My Org', + plan: 'free', + allowed_release_channels: ['ga'], + }); + + const project = await createProject({ + name: 'Project 1', + region: 'us-east-1', + organization_id: org.id, + }); + project.status = 'ACTIVE_HEALTHY'; + + const confirm_cost_id_result = await callTool({ + name: 'confirm_cost', + arguments: { + type: 'branch', + recurrence: 'hourly', + amount: BRANCH_COST_HOURLY, + }, + }); + + const branchName = 'test-branch'; + const result = await callTool({ + name: 'create_branch', + arguments: { + project_id: project.id, + name: branchName, + confirm_cost_id: confirm_cost_id_result.confirmation_id, + }, + }); + + expect(result).toMatchObject({ + name: branchName, + parent_project_ref: project.id, + }); + // Creating a project's first branch also mints a same-named + // `is_default` mock branch representing the parent project itself - + // filter it out to count only the branch this call created. + expect( + Array.from(mockBranches.values()).filter( + (branch) => branch.name === branchName && !branch.is_default + ) + ).toHaveLength(1); + }); + + test('form-capable client: accept creates the branch exactly once', async () => { + const { client } = await setupFormCapable({ + elicitationAction: 'accept', + }); + + const org = await createOrganization({ + name: 'My Org', + plan: 'free', + allowed_release_channels: ['ga'], + }); + + const project = await createProject({ + name: 'Project 1', + region: 'us-east-1', + organization_id: org.id, + }); + project.status = 'ACTIVE_HEALTHY'; + + const result = await client.callTool({ + name: 'create_branch', + arguments: { project_id: project.id, name: 'test-branch' }, + }); + + expect(result.isError).toBeFalsy(); + const [content] = result.content; + if (content?.type !== 'text') { + throw new Error('expected text content'); + } + const branch = JSON.parse(content.text); + expect(branch).toMatchObject({ + name: 'test-branch', + parent_project_ref: project.id, + }); + expect( + Array.from(mockBranches.values()).filter( + (branch) => branch.name === 'test-branch' && !branch.is_default + ) + ).toHaveLength(1); + }); + + test('form-capable client: decline does not create a branch', async () => { + const { client } = await setupFormCapable({ + elicitationAction: 'decline', + }); + + const org = await createOrganization({ + name: 'My Org', + plan: 'free', + allowed_release_channels: ['ga'], + }); + + const project = await createProject({ + name: 'Project 1', + region: 'us-east-1', + organization_id: org.id, + }); + project.status = 'ACTIVE_HEALTHY'; + + const result = await client.callTool({ + name: 'create_branch', + arguments: { project_id: project.id, name: 'test-branch' }, + }); + + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toEqual({ status: 'declined' }); + expect(mockBranches.size).toBe(0); + }); + + test('form-capable client: cancel does not create a branch', async () => { + const { client } = await setupFormCapable({ + elicitationAction: 'cancel', + }); + + const org = await createOrganization({ + name: 'My Org', + plan: 'free', + allowed_release_channels: ['ga'], + }); + + const project = await createProject({ + name: 'Project 1', + region: 'us-east-1', + organization_id: org.id, + }); + project.status = 'ACTIVE_HEALTHY'; + + const result = await client.callTool({ + name: 'create_branch', + arguments: { project_id: project.id, name: 'test-branch' }, + }); + + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toEqual({ status: 'cancelled' }); + expect(mockBranches.size).toBe(0); + }); + + test('form-capable client: a non-elicitation response re-prompts without creating a branch', async () => { + const { client } = await setupFormCapable(); + + const org = await createOrganization({ + name: 'My Org', + plan: 'free', + allowed_release_channels: ['ga'], + }); + const project = await createProject({ + name: 'Project 1', + region: 'us-east-1', + organization_id: org.id, + }); + project.status = 'ACTIVE_HEALTHY'; + + const args = { project_id: project.id, name: 'test-branch' }; + const first = (await client.request( + { + method: 'tools/call', + params: { name: 'create_branch', arguments: args }, + }, + { allowInputRequired: true } + )) as CallToolResult | InputRequiredResult; + + if (!isInputRequiredResult(first)) { + throw new Error('expected an input_required result'); + } + + const second = (await client.request( + { + method: 'tools/call', + params: { + name: 'create_branch', + arguments: args, + inputResponses: { + confirm_cost: { roots: [] }, + }, + requestState: first.requestState, + }, + }, + { allowInputRequired: true } + )) as CallToolResult | InputRequiredResult; + + expect(isInputRequiredResult(second)).toBe(true); + expect(mockBranches.size).toBe(0); + }); + + test('a decline is honored even when the quoted branch cost changed since the state was minted', async () => { + const getBranchCostSpy = vi + .spyOn(pricing, 'getBranchCost') + .mockReturnValueOnce({ + type: 'branch', + recurrence: 'hourly', + amount: BRANCH_COST_HOURLY, + }) + .mockReturnValueOnce({ + type: 'branch', + recurrence: 'hourly', + amount: BRANCH_COST_HOURLY + 1, + }); + try { + const { client } = await setupFormCapable(); + + const org = await createOrganization({ + name: 'My Org', + plan: 'free', + allowed_release_channels: ['ga'], + }); + const project = await createProject({ + name: 'Project 1', + region: 'us-east-1', + organization_id: org.id, + }); + project.status = 'ACTIVE_HEALTHY'; + + const args = { project_id: project.id, name: 'test-branch' }; + const first = (await client.request( + { + method: 'tools/call', + params: { name: 'create_branch', arguments: args }, + }, + { allowInputRequired: true } + )) as CallToolResult | InputRequiredResult; + + if (!isInputRequiredResult(first)) { + throw new Error('expected an input_required result'); + } + expect(first.inputRequests?.confirm_cost).toMatchObject({ + method: 'elicitation/create', + params: { + mode: 'form', + message: expect.stringContaining(`$${BRANCH_COST_HOURLY}/hr`), + }, + }); + expect(first.inputRequests?.confirm_cost).toMatchObject({ + method: 'elicitation/create', + params: { + mode: 'form', + message: expect.stringContaining( + 'Standard rate, before plan allowances or exemptions.' + ), + }, + }); + expect(first.inputRequests?.confirm_cost).toMatchObject({ + method: 'elicitation/create', + params: { + mode: 'form', + message: expect.stringContaining( + 'until deleted (~$9.68 per 30 days).' + ), + }, + }); + + const second = (await client.request( + { + method: 'tools/call', + params: { + name: 'create_branch', + arguments: args, + inputResponses: { + confirm_cost: { action: 'decline' }, + }, + requestState: first.requestState, + }, + }, + { allowInputRequired: true } + )) as CallToolResult | InputRequiredResult; + + if (isInputRequiredResult(second)) { + throw new Error('expected a CallToolResult'); + } + expect(second.structuredContent).toEqual({ status: 'declined' }); + expect(mockBranches.size).toBe(0); + } finally { + getBranchCostSpy.mockRestore(); + } + }); + + test('form-capable client: a supplied confirm_cost_id cannot bypass the form', async () => { + const { client } = await setupFormCapable(); + + const org = await createOrganization({ + name: 'My Org', + plan: 'free', + allowed_release_channels: ['ga'], + }); + + const project = await createProject({ + name: 'Project 1', + region: 'us-east-1', + organization_id: org.id, + }); + project.status = 'ACTIVE_HEALTHY'; + + // The correct legacy hash - even a valid confirmation ID must not + // let a form-capable client skip straight to creation. + const legacyConfirmCostId = await hashObject(getBranchCost()); + + const result = (await client.request( + { + method: 'tools/call', + params: { + name: 'create_branch', + arguments: { + project_id: project.id, + name: 'test-branch', + confirm_cost_id: legacyConfirmCostId, + }, + }, + }, + { allowInputRequired: true } + )) as CallToolResult | InputRequiredResult; + + if (!isInputRequiredResult(result)) { + throw new Error('expected an input_required result'); + } + expect(mockBranches.size).toBe(0); + }); + + test('rejects a retry whose arguments changed since the state was minted', async () => { + const { client } = await setupFormCapable(); + + const org = await createOrganization({ + name: 'My Org', + plan: 'free', + allowed_release_channels: ['ga'], + }); + + const project = await createProject({ + name: 'Project 1', + region: 'us-east-1', + organization_id: org.id, + }); + project.status = 'ACTIVE_HEALTHY'; + + const first = (await client.request( + { + method: 'tools/call', + params: { + name: 'create_branch', + arguments: { project_id: project.id, name: 'test-branch' }, + }, + }, + { allowInputRequired: true } + )) as CallToolResult | InputRequiredResult; + + if (!isInputRequiredResult(first)) { + throw new Error('expected an input_required result'); + } + + const second = (await client.request( + { + method: 'tools/call', + params: { + name: 'create_branch', + arguments: { project_id: project.id, name: 'renamed-branch' }, + inputResponses: { + confirm_cost: { action: 'accept', content: {} }, + }, + requestState: first.requestState, + }, + }, + { allowInputRequired: true } + )) as CallToolResult | InputRequiredResult; + + if (isInputRequiredResult(second)) { + throw new Error('expected a CallToolResult'); + } + + expect(second.isError).toBe(true); + expect(second.structuredContent).toEqual({ status: 'error' }); + expect(mockBranches.size).toBe(0); + }); + + test('project-scoped server signs and uses the configured project', async () => { + const org = await createOrganization({ + name: 'My Org', + plan: 'free', + allowed_release_channels: ['ga'], + }); + + const project = await createProject({ + name: 'Project 1', + region: 'us-east-1', + organization_id: org.id, + }); + project.status = 'ACTIVE_HEALTHY'; + + const { client } = await setupFormCapable({ + projectId: project.id, + elicitationAction: 'accept', + }); + + const { tools } = await client.listTools(); + const createBranchTool = tools.find( + (tool) => tool.name === 'create_branch' + ); + expect( + Object.keys(createBranchTool?.inputSchema.properties ?? {}) + ).not.toContain('project_id'); + + const result = await client.callTool({ + name: 'create_branch', + arguments: { name: 'test-branch' }, + }); + + expect(result.isError).toBeFalsy(); + const [content] = result.content; + if (content?.type !== 'text') { + throw new Error('expected text content'); + } + const branch = JSON.parse(content.text); + expect(branch).toMatchObject({ + name: 'test-branch', + parent_project_ref: project.id, + }); + expect( + Array.from(mockBranches.values()).filter( + (branch) => branch.name === 'test-branch' && !branch.is_default + ) + ).toHaveLength(1); + }); + + test('rejects a requestState minted by create_project', async () => { + const { client } = await setupFormCapable(); + + // create_project only triggers cost confirmation for a paid org's + // additional projects, so set up a pro org with one existing project. + const org = await createOrganization({ + name: 'Paid Org', + plan: 'pro', + allowed_release_channels: ['ga'], + }); + const existingProject = await createProject({ + name: 'Existing Project', + region: 'us-east-1', + organization_id: org.id, + }); + existingProject.status = 'ACTIVE_HEALTHY'; + + const projectFirst = (await client.request( + { + method: 'tools/call', + params: { + name: 'create_project', + arguments: { + organization_id: org.id, + name: 'My Project', + region: 'us-east-1', + }, + }, + }, + { allowInputRequired: true } + )) as CallToolResult | InputRequiredResult; + + if (!isInputRequiredResult(projectFirst)) { + throw new Error( + 'expected an input_required result from create_project' + ); + } + + const result = (await client.request( + { + method: 'tools/call', + params: { + name: 'create_branch', + arguments: { project_id: existingProject.id, name: 'test-branch' }, + inputResponses: { + confirm_cost: { action: 'accept', content: {} }, + }, + requestState: projectFirst.requestState, + }, + }, + { allowInputRequired: true } + )) as CallToolResult | InputRequiredResult; + + if (isInputRequiredResult(result)) { + throw new Error('expected a CallToolResult'); + } + + expect(result.isError).toBe(true); + expect(result.structuredContent).toEqual({ status: 'error' }); + expect( + result.content.some( + (c) => + c.type === 'text' && + c.text === 'Request state was not issued for create_branch.' + ) + ).toBe(true); + expect(mockBranches.size).toBe(0); + }); + + test('rejects a tampered requestState before the handler runs', async () => { + const { client } = await setupFormCapable(); + + const org = await createOrganization({ + name: 'My Org', + plan: 'free', + allowed_release_channels: ['ga'], + }); + const project = await createProject({ + name: 'Project 1', + region: 'us-east-1', + organization_id: org.id, + }); + project.status = 'ACTIVE_HEALTHY'; + + const first = (await client.request( + { + method: 'tools/call', + params: { + name: 'create_branch', + arguments: { project_id: project.id, name: 'test-branch' }, + }, + }, + { allowInputRequired: true } + )) as CallToolResult | InputRequiredResult; + + if (!isInputRequiredResult(first)) { + throw new Error('expected an input_required result'); + } + + // Flip the last character to tamper the HMAC signature; the framework + // rejects it before the handler runs (ProtocolError -32602). + const originalState = first.requestState as string; + const lastChar = originalState.slice(-1); + const tamperedState = + originalState.slice(0, -1) + (lastChar === 'a' ? 'b' : 'a'); + + await expect( + client.request( + { + method: 'tools/call', + params: { + name: 'create_branch', + arguments: { project_id: project.id, name: 'test-branch' }, + inputResponses: { + confirm_cost: { action: 'accept', content: {} }, + }, + requestState: tamperedState, + }, + }, + { allowInputRequired: true } + ) + ).rejects.toMatchObject({ + code: -32602, + message: 'Invalid or expired requestState', + }); + expect(mockBranches.size).toBe(0); + }); + }); + test('delete branch', async () => { const { callTool } = await setup({ features: ['account', 'branching'], diff --git a/packages/mcp-server-supabase/src/server.ts b/packages/mcp-server-supabase/src/server.ts index aa05cd97..117f418d 100644 --- a/packages/mcp-server-supabase/src/server.ts +++ b/packages/mcp-server-supabase/src/server.ts @@ -60,8 +60,9 @@ export type SupabaseMcpServerOptions = { /** * Enables cost confirmation via elicitation for clients that declare * per-request form-elicitation capability. Clients without that - * capability keep using `get_cost` -> `confirm_cost` -> - * `create_project(confirm_cost_id)`. + * capability keep using `get_cost` -> `confirm_cost` -> the relevant + * project or branch `confirm_cost_id` flow (`create_project` or + * `create_branch`). */ costConfirmation?: { /** HMAC key for the `requestState` codec. MUST be at least 32 bytes. */ @@ -71,7 +72,7 @@ export type SupabaseMcpServerOptions = { /** How long a minted `requestState` stays valid, in seconds. */ ttlSeconds?: number; /** Tools that accept a cost-confirmation elicitation. */ - enabledTools: readonly 'create_project'[]; + enabledTools: readonly ('create_project' | 'create_branch')[]; }; }; @@ -135,9 +136,7 @@ export function createSupabaseMcpServer(options: SupabaseMcpServerOptions) { features ?? availableDefaultFeatures ); - const costConfirmationCodec = costConfirmation?.enabledTools.includes( - 'create_project' - ) + const costConfirmationCodec = costConfirmation?.enabledTools.length ? createRequestStateCodec({ key: costConfirmation.requestStateKey, ttlSeconds: costConfirmation.ttlSeconds, @@ -191,9 +190,11 @@ export function createSupabaseMcpServer(options: SupabaseMcpServerOptions) { getAccountTools({ account, readOnly, - costConfirmation: costConfirmationCodec && { - codec: costConfirmationCodec, - }, + costConfirmation: + costConfirmationCodec && + costConfirmation?.enabledTools.includes('create_project') + ? { codec: costConfirmationCodec } + : undefined, }) ); } @@ -227,7 +228,16 @@ export function createSupabaseMcpServer(options: SupabaseMcpServerOptions) { if (branching && enabledFeatures.has('branching')) { Object.assign( tools, - getBranchingTools({ branching, projectId, readOnly }) + getBranchingTools({ + branching, + projectId, + readOnly, + costConfirmation: + costConfirmationCodec && + costConfirmation?.enabledTools.includes('create_branch') + ? { codec: costConfirmationCodec } + : undefined, + }) ); } diff --git a/packages/mcp-server-supabase/src/tools/branching-tools.ts b/packages/mcp-server-supabase/src/tools/branching-tools.ts index bbad5976..567072e2 100644 --- a/packages/mcp-server-supabase/src/tools/branching-tools.ts +++ b/packages/mcp-server-supabase/src/tools/branching-tools.ts @@ -1,15 +1,35 @@ +import { + inputRequired, + inputResponse, + 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 { branchSchema } from '../platform/types.js'; import { getBranchCost } from '../pricing.js'; import { hashObject } from '../util.js'; +import { + actionOnlyElicitationSchema, + isFormCapable, + type CostConfirmationState, +} from './cost-confirmation.js'; import { injectableTool, type ToolDefs } from './util.js'; type BranchingToolsOptions = { branching: BranchingOperations; projectId?: string; readOnly?: boolean; + /** + * Enables cost confirmation via elicitation inside `create_branch` for + * clients that declare per-request form capability (see + * `isFormCapable`). Absent, `create_branch` keeps requiring + * `confirm_cost_id` from `confirm_cost` unchanged. + */ + costConfirmation?: { + codec: RequestStateCodec; + }; }; const createBranchInputSchema = z.object({ @@ -25,6 +45,15 @@ const createBranchInputSchema = z.object({ .describe('The cost confirmation ID. Call `confirm_cost` first.'), }); +const createBranchInputSchemaWithElicitation = createBranchInputSchema.extend({ + confirm_cost_id: z + .string() + .optional() + .describe( + 'The cost confirmation ID. Only required for clients without per-request form-elicitation capability; those clients must call `confirm_cost` first. Form-capable clients are asked to confirm the cost inline when creating the branch.' + ), +}); + const createBranchOutputSchema = branchSchema; const listBranchesInputSchema = z.object({ @@ -155,18 +184,132 @@ export function getBranchingTools({ branching, projectId, readOnly, + costConfirmation, }: BranchingToolsOptions) { const project_id = projectId; return { create_branch: injectableTool({ ...branchingToolDefs.create_branch, + parameters: costConfirmation + ? createBranchInputSchemaWithElicitation + : createBranchInputSchema, inject: { project_id }, - execute: async ({ project_id, name, confirm_cost_id }) => { + execute: async ( + { + project_id, + name, + confirm_cost_id, + }: z.infer, + ctx: ServerContext + ) => { if (readOnly) { throw new Error('Cannot create a branch in read-only mode.'); } + if (costConfirmation && isFormCapable(ctx)) { + const { codec } = costConfirmation; + const cost = getBranchCost(); + const costSuffix = { hourly: '/hr' }[cost.recurrence]; + + const askForConfirmation = async () => + inputRequired({ + inputRequests: { + confirm_cost: inputRequired.elicit({ + mode: 'form', + message: [ + `Preview branch: $${cost.amount}${costSuffix} until deleted (~$${(cost.amount * 24 * 30).toFixed(2)} per 30 days).`, + 'Auto-pauses on inactivity.', + 'Standard rate, before plan allowances or exemptions.', + ].join('\n'), + requestedSchema: actionOnlyElicitationSchema, + }), + }, + requestState: await codec.mint( + { tool: 'create_branch', project_id, name, cost }, + ctx + ), + }); + + const state = ctx.mcpReq.requestState(); + if (!state) { + return askForConfirmation(); + } + + if (state.tool !== 'create_branch') { + return { + content: [ + { + type: 'text' as const, + text: 'Request state was not issued for create_branch.', + }, + ], + structuredContent: { status: 'error' }, + isError: true, + }; + } + + if (state.project_id !== project_id || state.name !== name) { + return { + content: [ + { + type: 'text' as const, + text: 'Request state arguments do not match the current arguments.', + }, + ], + structuredContent: { status: 'error' }, + isError: true, + }; + } + + const response = inputResponse( + ctx.mcpReq.inputResponses, + 'confirm_cost' + ); + if (response.kind !== 'elicit') { + return askForConfirmation(); + } + + if (response.action === 'decline') { + return { + content: [ + { + type: 'text' as const, + text: 'Branch creation was declined.', + }, + ], + structuredContent: { status: 'declined' }, + }; + } + + if (response.action !== 'accept') { + return { + content: [ + { + type: 'text' as const, + text: 'Branch creation was cancelled.', + }, + ], + structuredContent: { status: 'cancelled' }, + }; + } + + if ( + state.cost.type !== cost.type || + state.cost.recurrence !== cost.recurrence || + state.cost.amount !== cost.amount + ) { + // Pricing changed since the state was minted - reissue a + // fresh prompt bound to the recomputed cost rather than + // honoring a stale quote. + return askForConfirmation(); + } + + return await branching.createBranch(state.project_id, { + name: state.name, + }); + } + const cost = getBranchCost(); const costHash = await hashObject(cost); if (costHash !== confirm_cost_id) { diff --git a/packages/mcp-server-supabase/src/tools/cost-confirmation.test.ts b/packages/mcp-server-supabase/src/tools/cost-confirmation.test.ts new file mode 100644 index 00000000..b2140d34 --- /dev/null +++ b/packages/mcp-server-supabase/src/tools/cost-confirmation.test.ts @@ -0,0 +1,70 @@ +import { + CLIENT_CAPABILITIES_META_KEY, + PROTOCOL_VERSION_META_KEY, + type ServerContext, +} from '@modelcontextprotocol/server'; +import { describe, expect, test } from 'vitest'; + +import { isFormCapable } from './cost-confirmation.js'; + +// Minimal ServerContext stub: only the envelope slice isFormCapable reads. +function makeCtx(envelope: Record): ServerContext { + return { + mcpReq: { envelope }, + } as unknown as ServerContext; +} + +// A valid envelope that satisfies both meta-key checks. +function validBase(): Record { + return { + [PROTOCOL_VERSION_META_KEY]: '2026-07-28', + [CLIENT_CAPABILITIES_META_KEY]: { elicitation: { form: {} } }, + }; +} + +describe('isFormCapable', () => { + test.each([ + { + label: 'no elicitation key in capabilities', + envelope: { + ...validBase(), + [CLIENT_CAPABILITIES_META_KEY]: {}, + }, + expected: false, + }, + { + label: 'elicitation is an empty object (any mode accepted)', + envelope: { + ...validBase(), + [CLIENT_CAPABILITIES_META_KEY]: { elicitation: {} }, + }, + expected: true, + }, + { + label: 'elicitation has url mode only', + envelope: { + ...validBase(), + [CLIENT_CAPABILITIES_META_KEY]: { elicitation: { url: {} } }, + }, + expected: false, + }, + { + label: 'elicitation has form mode', + envelope: { + ...validBase(), + [CLIENT_CAPABILITIES_META_KEY]: { elicitation: { form: {} } }, + }, + expected: true, + }, + { + label: 'form capability present but protocol version meta key absent', + envelope: { + [CLIENT_CAPABILITIES_META_KEY]: { elicitation: { form: {} } }, + // PROTOCOL_VERSION_META_KEY intentionally omitted + }, + expected: false, + }, + ])('$label -> $expected', ({ envelope, expected }) => { + expect(isFormCapable(makeCtx(envelope))).toBe(expected); + }); +}); diff --git a/packages/mcp-server-supabase/src/tools/cost-confirmation.ts b/packages/mcp-server-supabase/src/tools/cost-confirmation.ts index c066bbd2..ad0f5977 100644 --- a/packages/mcp-server-supabase/src/tools/cost-confirmation.ts +++ b/packages/mcp-server-supabase/src/tools/cost-confirmation.ts @@ -3,7 +3,7 @@ import { PROTOCOL_VERSION_META_KEY, type ServerContext, } from '@modelcontextprotocol/server'; -import type { Cost } from '../pricing.js'; +import type { BranchCost, Cost } from '../pricing.js'; import type { AWS_REGION_CODES } from '../regions.js'; /** @@ -19,11 +19,23 @@ export type ProjectCostState = { cost: Cost; }; +/** + * Signed `requestState` payload for the `create_branch` cost-confirmation + * elicitation, bound to the branch arguments and the cost quoted to the + * user. + */ +export type BranchCostState = { + tool: 'create_branch'; + project_id: string; + name: string; + cost: BranchCost; +}; + /** * Signed `requestState` payload for any cost-confirmation elicitation this * server issues, discriminated by `tool`. */ -export type CostConfirmationState = ProjectCostState; +export type CostConfirmationState = ProjectCostState | BranchCostState; /** * An action-only elicitation: no properties, so the client renders the