From c51e633d4792e823a31d5051c6acddc7a1df769b Mon Sep 17 00:00:00 2001 From: Pedro Rodrigues Date: Mon, 27 Oct 2025 14:44:32 +0000 Subject: [PATCH 1/6] feat: add MCP elicitation for apply_migration tool - Implemented user confirmation dialog for apply_migration using MCP elicitation protocol - Added graceful fallback for clients that don't support elicitation - Tests pass with fallback behavior in test environment - Maintains backwards compatibility with all MCP clients --- packages/mcp-server-supabase/src/server.ts | 1 + .../src/tools/database-operation-tools.ts | 44 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/packages/mcp-server-supabase/src/server.ts b/packages/mcp-server-supabase/src/server.ts index ab8fe0b..543e90c 100644 --- a/packages/mcp-server-supabase/src/server.ts +++ b/packages/mcp-server-supabase/src/server.ts @@ -144,6 +144,7 @@ export function createSupabaseMcpServer(options: SupabaseMcpServerOptions) { database, projectId, readOnly, + server, }) ); } diff --git a/packages/mcp-server-supabase/src/tools/database-operation-tools.ts b/packages/mcp-server-supabase/src/tools/database-operation-tools.ts index 762d600..f632f9f 100644 --- a/packages/mcp-server-supabase/src/tools/database-operation-tools.ts +++ b/packages/mcp-server-supabase/src/tools/database-operation-tools.ts @@ -1,4 +1,5 @@ import { source } from 'common-tags'; +import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { z } from 'zod'; import { listExtensionsSql, listTablesSql } from '../pg-meta/index.js'; import { @@ -14,12 +15,14 @@ export type DatabaseOperationToolsOptions = { database: DatabaseOperations; projectId?: string; readOnly?: boolean; + server?: Server; }; export function getDatabaseTools({ database, projectId, readOnly, + server, }: DatabaseOperationToolsOptions) { const project_id = projectId; @@ -215,6 +218,47 @@ export function getDatabaseTools({ throw new Error('Cannot apply migration in read-only mode.'); } + // Try to request user confirmation via elicitation + if (server) { + try { + const result = (await server.request( + { + method: 'elicitation/create', + params: { + message: `You are about to apply migration "${name}" to project ${project_id}. This will modify your database schema.\n\nPlease review the SQL:\n\n${query}\n\nDo you want to proceed?`, + requestedSchema: { + type: 'object', + properties: { + confirm: { + type: 'boolean', + title: 'Confirm Migration', + description: 'I have reviewed the SQL and approve this migration', + }, + }, + required: ['confirm'], + }, + }, + }, + // @ts-ignore - elicitation types might not be available + { elicitation: true } + )) as { + action: 'accept' | 'decline' | 'cancel'; + content?: { confirm?: boolean }; + }; + + // User declined or cancelled + if (result.action !== 'accept' || !result.content?.confirm) { + throw new Error('Migration cancelled by user'); + } + } catch (error) { + // If elicitation fails (client doesn't support it), proceed without confirmation + // This maintains backwards compatibility + console.warn( + 'Elicitation not supported by client, proceeding with migration without confirmation' + ); + } + } + await database.applyMigration(project_id, { name, query, From 65a54572fd3a159c827b908795d5b55964da72ab Mon Sep 17 00:00:00 2001 From: Pedro Rodrigues Date: Thu, 30 Oct 2025 13:39:34 +0000 Subject: [PATCH 2/6] add elicitation to create_project --- .../mcp-server-supabase/src/platform/types.ts | 10 +-- packages/mcp-server-supabase/src/server.ts | 2 +- .../src/tools/account-tools.ts | 70 ++++++++++++++----- packages/mcp-utils/src/server.ts | 11 ++- 4 files changed, 66 insertions(+), 27 deletions(-) diff --git a/packages/mcp-server-supabase/src/platform/types.ts b/packages/mcp-server-supabase/src/platform/types.ts index 0d7a896..9d0610e 100644 --- a/packages/mcp-server-supabase/src/platform/types.ts +++ b/packages/mcp-server-supabase/src/platform/types.ts @@ -234,10 +234,7 @@ export type DevelopmentOperations = { export type StorageOperations = { getStorageConfig(projectId: string): Promise; - updateStorageConfig( - projectId: string, - config: StorageConfig - ): Promise; + updateStorageConfig(projectId: string, config: StorageConfig): Promise; listAllBuckets(projectId: string): Promise; }; @@ -249,10 +246,7 @@ export type BranchingOperations = { ): Promise; deleteBranch(branchId: string): Promise; mergeBranch(branchId: string): Promise; - resetBranch( - branchId: string, - options: ResetBranchOptions - ): Promise; + resetBranch(branchId: string, options: ResetBranchOptions): Promise; rebaseBranch(branchId: string): Promise; }; diff --git a/packages/mcp-server-supabase/src/server.ts b/packages/mcp-server-supabase/src/server.ts index 543e90c..d5630ef 100644 --- a/packages/mcp-server-supabase/src/server.ts +++ b/packages/mcp-server-supabase/src/server.ts @@ -134,7 +134,7 @@ export function createSupabaseMcpServer(options: SupabaseMcpServerOptions) { } if (!projectId && account && enabledFeatures.has('account')) { - Object.assign(tools, getAccountTools({ account, readOnly })); + Object.assign(tools, getAccountTools({ account, readOnly, server })); } if (database && enabledFeatures.has('database')) { diff --git a/packages/mcp-server-supabase/src/tools/account-tools.ts b/packages/mcp-server-supabase/src/tools/account-tools.ts index d2e88f0..f40f3c4 100644 --- a/packages/mcp-server-supabase/src/tools/account-tools.ts +++ b/packages/mcp-server-supabase/src/tools/account-tools.ts @@ -1,4 +1,5 @@ -import { tool } from '@supabase/mcp-utils'; +import { tool, type ToolExecuteContext } from '@supabase/mcp-utils'; +import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { z } from 'zod'; import type { AccountOperations } from '../platform/types.js'; import { type Cost, getBranchCost, getNextProjectCost } from '../pricing.js'; @@ -10,9 +11,14 @@ const SUCCESS_RESPONSE = { success: true }; export type AccountToolsOptions = { account: AccountOperations; readOnly?: boolean; + server?: Server; }; -export function getAccountTools({ account, readOnly }: AccountToolsOptions) { +export function getAccountTools({ + account, + readOnly, + server, +}: AccountToolsOptions) { return { list_organizations: tool({ description: 'Lists all organizations that the user is a member of.', @@ -131,7 +137,7 @@ export function getAccountTools({ account, readOnly }: AccountToolsOptions) { }), create_project: tool({ description: - 'Creates a new Supabase project. Always ask the user which organization to create the project in. The project can take a few minutes to initialize - use `get_project` to check the status.', + 'Creates a new Supabase project. Always ask the user which organization to create the project in. If there is a cost involved, the user will be asked to confirm before creation. The project can take a few minutes to initialize - use `get_project` to check the status.', annotations: { title: 'Create project', readOnlyHint: false, @@ -145,31 +151,63 @@ export function getAccountTools({ account, readOnly }: AccountToolsOptions) { .enum(AWS_REGION_CODES) .describe('The region to create the project in.'), organization_id: z.string(), - confirm_cost_id: z - .string({ - required_error: - 'User must confirm understanding of costs before creating a project.', - }) - .describe('The cost confirmation ID. Call `confirm_cost` first.'), }), - execute: async ({ name, region, organization_id, confirm_cost_id }) => { + execute: async ({ name, region, organization_id }, context) => { if (readOnly) { throw new Error('Cannot create a project in read-only mode.'); } + // Calculate cost inline const cost = await getNextProjectCost(account, organization_id); - const costHash = await hashObject(cost); - if (costHash !== confirm_cost_id) { - throw new Error( - 'Cost confirmation ID does not match the expected cost of creating a project.' - ); + + // Only request confirmation if there's a cost AND server supports elicitation + if (cost.amount > 0 && context?.server?.elicitInput) { + const costMessage = `$${cost.amount} per ${cost.recurrence}`; + + const result = await context.server.elicitInput({ + message: `You are about to create project "${name}" in region ${region}.\n\n💰 Cost: ${costMessage}\n\nDo you want to proceed with this billable project?`, + requestedSchema: { + type: 'object', + properties: { + confirm: { + type: 'boolean', + title: 'Confirm billable project creation', + description: `I understand this will cost ${costMessage} and want to proceed`, + }, + }, + required: ['confirm'], + }, + }); + + // Handle user response + if (result.action === 'decline' || result.action === 'cancel') { + throw new Error('Project creation cancelled by user.'); + } + + if (result.action === 'accept' && !result.content?.confirm) { + throw new Error( + 'You must confirm understanding of the cost to create a billable project.' + ); + } } - return await account.createProject({ + // Create the project (either free or confirmed) + const project = await account.createProject({ name, region, organization_id, }); + + // Return appropriate message based on cost + const costInfo = + cost.amount > 0 + ? `Cost: $${cost.amount}/${cost.recurrence}` + : 'Cost: Free'; + + return { + ...project, + message: `Project "${name}" created successfully. ${costInfo}`, + }; }, }), pause_project: tool({ diff --git a/packages/mcp-utils/src/server.ts b/packages/mcp-utils/src/server.ts index b42e773..a5c469b 100644 --- a/packages/mcp-utils/src/server.ts +++ b/packages/mcp-utils/src/server.ts @@ -50,6 +50,10 @@ export type ResourceTemplate = { ): Promise; }; +export type ToolExecuteContext = { + server?: Server; +}; + export type Tool< Params extends z.ZodObject = z.ZodObject, Result = unknown, @@ -57,7 +61,10 @@ export type Tool< description: Prop; annotations?: Annotations; parameters: Params; - execute(params: z.infer): Promise; + execute( + params: z.infer, + context?: ToolExecuteContext + ): Promise; }; /** @@ -484,7 +491,7 @@ export function createMcpServer(options: McpServerOptions) { const executeWithCallback = async (tool: Tool) => { // Wrap success or error in a result value const res = await tool - .execute(args) + .execute(args, { server }) .then((data: unknown) => ({ success: true as const, data })) .catch((error) => ({ success: false as const, error })); From 681b453515d947bb363716df52a116926de73ee5 Mon Sep 17 00:00:00 2001 From: Pedro Rodrigues Date: Thu, 30 Oct 2025 17:44:37 +0000 Subject: [PATCH 3/6] filter tools if client capabilities are not supported --- .../src/tools/account-tools.ts | 1 + packages/mcp-utils/src/server.ts | 60 +++++++++++++------ 2 files changed, 43 insertions(+), 18 deletions(-) diff --git a/packages/mcp-server-supabase/src/tools/account-tools.ts b/packages/mcp-server-supabase/src/tools/account-tools.ts index f40f3c4..ebb7218 100644 --- a/packages/mcp-server-supabase/src/tools/account-tools.ts +++ b/packages/mcp-server-supabase/src/tools/account-tools.ts @@ -126,6 +126,7 @@ export function getAccountTools({ idempotentHint: true, openWorldHint: false, }, + isSupported: (clientCapabilities) => !clientCapabilities?.elicitation, parameters: z.object({ type: z.enum(['project', 'branch']), recurrence: z.enum(['hourly', 'monthly']), diff --git a/packages/mcp-utils/src/server.ts b/packages/mcp-utils/src/server.ts index a5c469b..d554689 100644 --- a/packages/mcp-utils/src/server.ts +++ b/packages/mcp-utils/src/server.ts @@ -61,6 +61,17 @@ export type Tool< description: Prop; annotations?: Annotations; parameters: Params; + /** + * Optional predicate to determine if this tool should be listed/usable for the + * connected client based on its declared capabilities. If omitted, the tool + * is assumed to be supported by all clients. + * + * Example: Only show when the client supports elicitation + * isSupported: (caps) => Boolean(caps?.elicitation) + */ + isSupported?: ( + clientCapabilities: ClientCapabilities | undefined + ) => boolean | Promise; execute( params: z.infer, context?: ToolExecuteContext @@ -443,28 +454,41 @@ export function createMcpServer(options: McpServerOptions) { ListToolsRequestSchema, async (): Promise => { const tools = await getTools(); + const clientCapabilities = server.getClientCapabilities(); + + // Filter tools based on client capabilities when a predicate is provided + const supportedToolEntries = ( + await Promise.all( + Object.entries(tools).map(async ([name, tool]) => { + const ok = + typeof tool.isSupported === 'function' + ? await tool.isSupported(clientCapabilities) + : true; + return ok ? ([name, tool] as const) : null; + }) + ) + ).filter(Boolean) as Array; return { tools: await Promise.all( - Object.entries(tools).map( - async ([name, { description, annotations, parameters }]) => { - const inputSchema = zodToJsonSchema(parameters); - - if (!('properties' in inputSchema)) { - throw new Error('tool parameters must be a ZodObject'); - } - - return { - name, - description: - typeof description === 'function' - ? await description() - : description, - annotations, - inputSchema, - }; + supportedToolEntries.map(async ([name, tool]) => { + const { description, annotations, parameters } = tool; + const inputSchema = zodToJsonSchema(parameters); + + if (!('properties' in inputSchema)) { + throw new Error('tool parameters must be a ZodObject'); } - ) + + return { + name, + description: + typeof description === 'function' + ? await description() + : description, + annotations, + inputSchema, + }; + }) ), } satisfies ListToolsResult; } From 0aed187e1de7592a0e67d8c29fe5696b3c0337ae Mon Sep 17 00:00:00 2001 From: Pedro Rodrigues Date: Thu, 30 Oct 2025 18:01:52 +0000 Subject: [PATCH 4/6] elicitation support ot create_branch --- .../src/tools/branching-tools.ts | 70 ++++++++++++++++--- .../src/tools/database-operation-tools.ts | 3 +- .../mcp-server-supabase/src/tools/util.ts | 2 +- 3 files changed, 62 insertions(+), 13 deletions(-) diff --git a/packages/mcp-server-supabase/src/tools/branching-tools.ts b/packages/mcp-server-supabase/src/tools/branching-tools.ts index ef458da..e05d524 100644 --- a/packages/mcp-server-supabase/src/tools/branching-tools.ts +++ b/packages/mcp-server-supabase/src/tools/branching-tools.ts @@ -37,25 +37,73 @@ export function getBranchingTools({ .string() .default('develop') .describe('Name of the branch to create'), + // When the client supports elicitation, we will ask the user to confirm the + // branch cost interactively and this parameter is not required. For clients + // without elicitation support, this confirmation ID is required. confirm_cost_id: z - .string({ - required_error: - 'User must confirm understanding of costs before creating a branch.', - }) - .describe('The cost confirmation ID. Call `confirm_cost` first.'), + .string() + .optional() + .describe( + 'The cost confirmation ID. Call `confirm_cost` first if elicitation is not supported.' + ), }), inject: { project_id }, - execute: async ({ project_id, name, confirm_cost_id }) => { + execute: async ({ project_id, name, confirm_cost_id }, context) => { if (readOnly) { throw new Error('Cannot create a branch in read-only mode.'); } const cost = getBranchCost(); - const costHash = await hashObject(cost); - if (costHash !== confirm_cost_id) { - throw new Error( - 'Cost confirmation ID does not match the expected cost of creating a branch.' - ); + + // If the server and client support elicitation, request explicit confirmation + const caps = context?.server?.getClientCapabilities?.(); + const supportsElicitation = Boolean(caps && (caps as any).elicitation); + + if ( + cost.amount > 0 && + supportsElicitation && + context?.server?.elicitInput + ) { + const costMessage = `$${cost.amount} per ${cost.recurrence}`; + + const result = await context.server.elicitInput({ + message: `You are about to create branch "${name}" on project ${project_id}.\n\n💰 Cost: ${costMessage}\n\nDo you want to proceed with this billable branch?`, + requestedSchema: { + type: 'object', + properties: { + confirm: { + type: 'boolean', + title: 'Confirm billable branch creation', + description: `I understand this will cost ${costMessage} and want to proceed`, + }, + }, + required: ['confirm'], + }, + }); + + if (result.action === 'decline' || result.action === 'cancel') { + throw new Error('Branch creation cancelled by user.'); + } + + if (result.action === 'accept' && !result.content?.confirm) { + throw new Error( + 'You must confirm understanding of the cost to create a billable branch.' + ); + } + } else { + // Fallback path (no elicitation support): require confirm_cost_id + if (!confirm_cost_id) { + throw new Error( + 'User must confirm understanding of costs before creating a branch.' + ); + } + + const costHash = await hashObject(cost); + if (costHash !== confirm_cost_id) { + throw new Error( + 'Cost confirmation ID does not match the expected cost of creating a branch.' + ); + } } return await branching.createBranch(project_id, { name }); }, diff --git a/packages/mcp-server-supabase/src/tools/database-operation-tools.ts b/packages/mcp-server-supabase/src/tools/database-operation-tools.ts index f632f9f..5969e45 100644 --- a/packages/mcp-server-supabase/src/tools/database-operation-tools.ts +++ b/packages/mcp-server-supabase/src/tools/database-operation-tools.ts @@ -232,7 +232,8 @@ export function getDatabaseTools({ confirm: { type: 'boolean', title: 'Confirm Migration', - description: 'I have reviewed the SQL and approve this migration', + description: + 'I have reviewed the SQL and approve this migration', }, }, required: ['confirm'], diff --git a/packages/mcp-server-supabase/src/tools/util.ts b/packages/mcp-server-supabase/src/tools/util.ts index a88b5ad..4207829 100644 --- a/packages/mcp-server-supabase/src/tools/util.ts +++ b/packages/mcp-server-supabase/src/tools/util.ts @@ -64,6 +64,6 @@ export function injectableTool< description, annotations, parameters: parameters.omit(mask), - execute: (args) => execute({ ...args, ...inject }), + execute: (args, context) => execute({ ...args, ...inject }, context), }) as Tool, Result>; } From b739cfed1a9b41a3ba199adee1d40162610690ea Mon Sep 17 00:00:00 2001 From: Pedro Rodrigues Date: Fri, 31 Oct 2025 17:54:54 +0000 Subject: [PATCH 5/6] merge get_cost and confirm_cost tools --- .../src/tools/account-tools.ts | 166 +++++++++++------- .../src/tools/branching-tools.ts | 70 ++------ .../src/tools/database-operation-tools.ts | 11 +- 3 files changed, 116 insertions(+), 131 deletions(-) diff --git a/packages/mcp-server-supabase/src/tools/account-tools.ts b/packages/mcp-server-supabase/src/tools/account-tools.ts index ebb7218..c4082ae 100644 --- a/packages/mcp-server-supabase/src/tools/account-tools.ts +++ b/packages/mcp-server-supabase/src/tools/account-tools.ts @@ -1,5 +1,6 @@ import { tool, type ToolExecuteContext } from '@supabase/mcp-utils'; import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { ElicitResultSchema } from '@modelcontextprotocol/sdk/types.js'; import { z } from 'zod'; import type { AccountOperations } from '../platform/types.js'; import { type Cost, getBranchCost, getNextProjectCost } from '../pricing.js'; @@ -82,11 +83,16 @@ export function getAccountTools({ return await account.getProject(id); }, }), - get_cost: tool({ - description: - 'Gets the cost of creating a new project or branch. Never assume organization as costs can be different for each.', + get_and_confirm_cost: tool({ + description: async () => { + const clientCapabilities = server?.getClientCapabilities(); + if (clientCapabilities?.elicitation) { + return 'Gets the cost of creating a new project or branch and requests user confirmation. Returns a unique ID for this confirmation which must be passed to `create_project` or `create_branch`. Never assume organization as costs can be different for each.'; + } + return 'Gets the cost of creating a new project or branch. You must repeat the cost to the user and confirm their understanding before calling `create_project` or `create_branch`. Returns a unique ID for this confirmation which must be passed to `create_project` or `create_branch`. Never assume organization as costs can be different for each.'; + }, annotations: { - title: 'Get cost of new resources', + title: 'Get and confirm cost', readOnlyHint: true, destructiveHint: false, idempotentHint: true, @@ -96,49 +102,94 @@ export function getAccountTools({ type: z.enum(['project', 'branch']), organization_id: z .string() - .describe('The organization ID. Always ask the user.'), + .describe('The organization ID. Always ask the user.') + .optional(), }), execute: async ({ type, organization_id }) => { - function generateResponse(cost: Cost) { - return `The new ${type} will cost $${cost.amount} ${cost.recurrence}. You must repeat this to the user and confirm their understanding.`; - } + // Get the cost + let cost: Cost; switch (type) { case 'project': { - const cost = await getNextProjectCost(account, organization_id); - return generateResponse(cost); + if (!organization_id) { + throw new Error( + 'organization_id is required for project cost calculation' + ); + } + cost = await getNextProjectCost(account, organization_id); + break; } case 'branch': { - const cost = getBranchCost(); - return generateResponse(cost); + cost = getBranchCost(); + break; } default: throw new Error(`Unknown cost type: ${type}`); } - }, - }), - confirm_cost: tool({ - description: - 'Ask the user to confirm their understanding of the cost of creating a new project or branch. Call `get_cost` first. Returns a unique ID for this confirmation which should be passed to `create_project` or `create_branch`.', - annotations: { - title: 'Confirm cost understanding', - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: false, - }, - isSupported: (clientCapabilities) => !clientCapabilities?.elicitation, - parameters: z.object({ - type: z.enum(['project', 'branch']), - recurrence: z.enum(['hourly', 'monthly']), - amount: z.number(), - }), - execute: async (cost) => { - return await hashObject(cost); + + let userDeclinedCost = false; + + // Request confirmation via elicitation if supported + const clientCapabilities = server?.getClientCapabilities(); + if (server && clientCapabilities?.elicitation) { + try { + const costMessage = + cost.amount > 0 ? `$${cost.amount} ${cost.recurrence}` : 'Free'; + + const result = await server.request( + { + method: 'elicitation/create', + params: { + message: `You are about to create a new ${type}.\n\nCost: ${costMessage}\n\nDo you want to proceed?`, + requestedSchema: { + type: 'object', + properties: { + confirm: { + type: 'boolean', + title: 'Confirm Cost', + description: `I understand the cost and want to create the ${type}`, + }, + }, + required: ['confirm'], + }, + }, + }, + ElicitResultSchema + ); + + if (result.action !== 'accept' || !result.content?.confirm) { + userDeclinedCost = true; + } + } catch (error) { + // If elicitation fails (client doesn't support it), return cost info for manual confirmation + console.warn( + 'Elicitation not supported by client, returning cost for manual confirmation' + ); + console.warn(error); + } + } + + if (userDeclinedCost) { + throw new Error( + 'The user declined to confirm the cost. Ask the user to confirm if they want to proceed with the operation or do something else.' + ); + } + + // Generate and return confirmation ID + const confirmationId = await hashObject(cost); + + return { + ...cost, + confirm_cost_id: confirmationId, + message: + cost.amount > 0 + ? `The new ${type} will cost $${cost.amount} ${cost.recurrence}. ${clientCapabilities?.elicitation ? 'User has confirmed.' : 'You must confirm this cost with the user before proceeding.'}` + : `The new ${type} is free. ${clientCapabilities?.elicitation ? 'User has confirmed.' : 'You may proceed with creation.'}`, + }; }, }), create_project: tool({ description: - 'Creates a new Supabase project. Always ask the user which organization to create the project in. If there is a cost involved, the user will be asked to confirm before creation. The project can take a few minutes to initialize - use `get_project` to check the status.', + 'Creates a new Supabase project. Always ask the user which organization to create the project in. Call `get_and_confirm_cost` first to verify the cost and get user confirmation. The project can take a few minutes to initialize - use `get_project` to check the status.', annotations: { title: 'Create project', readOnlyHint: false, @@ -152,47 +203,30 @@ export function getAccountTools({ .enum(AWS_REGION_CODES) .describe('The region to create the project in.'), organization_id: z.string(), + confirm_cost_id: z + .string({ + required_error: + 'User must confirm understanding of costs before creating a project.', + }) + .describe( + 'The cost confirmation ID. Call `get_and_confirm_cost` first.' + ), }), - execute: async ({ name, region, organization_id }, context) => { + execute: async ({ name, region, organization_id, confirm_cost_id }) => { if (readOnly) { throw new Error('Cannot create a project in read-only mode.'); } - // Calculate cost inline + // Verify the confirmation ID matches the expected cost const cost = await getNextProjectCost(account, organization_id); - - // Only request confirmation if there's a cost AND server supports elicitation - if (cost.amount > 0 && context?.server?.elicitInput) { - const costMessage = `$${cost.amount} per ${cost.recurrence}`; - - const result = await context.server.elicitInput({ - message: `You are about to create project "${name}" in region ${region}.\n\n💰 Cost: ${costMessage}\n\nDo you want to proceed with this billable project?`, - requestedSchema: { - type: 'object', - properties: { - confirm: { - type: 'boolean', - title: 'Confirm billable project creation', - description: `I understand this will cost ${costMessage} and want to proceed`, - }, - }, - required: ['confirm'], - }, - }); - - // Handle user response - if (result.action === 'decline' || result.action === 'cancel') { - throw new Error('Project creation cancelled by user.'); - } - - if (result.action === 'accept' && !result.content?.confirm) { - throw new Error( - 'You must confirm understanding of the cost to create a billable project.' - ); - } + const costHash = await hashObject(cost); + if (costHash !== confirm_cost_id) { + throw new Error( + 'Cost confirmation ID does not match the expected cost of creating a project.' + ); } - // Create the project (either free or confirmed) + // Create the project const project = await account.createProject({ name, region, diff --git a/packages/mcp-server-supabase/src/tools/branching-tools.ts b/packages/mcp-server-supabase/src/tools/branching-tools.ts index e05d524..c4ef731 100644 --- a/packages/mcp-server-supabase/src/tools/branching-tools.ts +++ b/packages/mcp-server-supabase/src/tools/branching-tools.ts @@ -23,7 +23,7 @@ export function getBranchingTools({ return { create_branch: injectableTool({ description: - 'Creates a development branch on a Supabase project. This will apply all migrations from the main project to a fresh branch database. Note that production data will not carry over. The branch will get its own project_id via the resulting project_ref. Use this ID to execute queries and migrations on the branch.', + 'Creates a development branch on a Supabase project. Call `get_and_confirm_cost` first to verify the cost and get user confirmation. This will apply all migrations from the main project to a fresh branch database. Note that production data will not carry over. The branch will get its own project_id via the resulting project_ref. Use this ID to execute queries and migrations on the branch.', annotations: { title: 'Create branch', readOnlyHint: false, @@ -37,73 +37,27 @@ export function getBranchingTools({ .string() .default('develop') .describe('Name of the branch to create'), - // When the client supports elicitation, we will ask the user to confirm the - // branch cost interactively and this parameter is not required. For clients - // without elicitation support, this confirmation ID is required. confirm_cost_id: z - .string() - .optional() + .string({ + required_error: + 'User must confirm understanding of costs before creating a branch.', + }) .describe( - 'The cost confirmation ID. Call `confirm_cost` first if elicitation is not supported.' + 'The cost confirmation ID. Call `get_and_confirm_cost` first.' ), }), inject: { project_id }, - execute: async ({ project_id, name, confirm_cost_id }, context) => { + execute: async ({ project_id, name, confirm_cost_id }) => { if (readOnly) { throw new Error('Cannot create a branch in read-only mode.'); } const cost = getBranchCost(); - - // If the server and client support elicitation, request explicit confirmation - const caps = context?.server?.getClientCapabilities?.(); - const supportsElicitation = Boolean(caps && (caps as any).elicitation); - - if ( - cost.amount > 0 && - supportsElicitation && - context?.server?.elicitInput - ) { - const costMessage = `$${cost.amount} per ${cost.recurrence}`; - - const result = await context.server.elicitInput({ - message: `You are about to create branch "${name}" on project ${project_id}.\n\n💰 Cost: ${costMessage}\n\nDo you want to proceed with this billable branch?`, - requestedSchema: { - type: 'object', - properties: { - confirm: { - type: 'boolean', - title: 'Confirm billable branch creation', - description: `I understand this will cost ${costMessage} and want to proceed`, - }, - }, - required: ['confirm'], - }, - }); - - if (result.action === 'decline' || result.action === 'cancel') { - throw new Error('Branch creation cancelled by user.'); - } - - if (result.action === 'accept' && !result.content?.confirm) { - throw new Error( - 'You must confirm understanding of the cost to create a billable branch.' - ); - } - } else { - // Fallback path (no elicitation support): require confirm_cost_id - if (!confirm_cost_id) { - throw new Error( - 'User must confirm understanding of costs before creating a branch.' - ); - } - - const costHash = await hashObject(cost); - if (costHash !== confirm_cost_id) { - throw new Error( - 'Cost confirmation ID does not match the expected cost of creating a branch.' - ); - } + const costHash = await hashObject(cost); + if (costHash !== confirm_cost_id) { + throw new Error( + 'Cost confirmation ID does not match the expected cost of creating a branch.' + ); } return await branching.createBranch(project_id, { name }); }, diff --git a/packages/mcp-server-supabase/src/tools/database-operation-tools.ts b/packages/mcp-server-supabase/src/tools/database-operation-tools.ts index 5969e45..e007037 100644 --- a/packages/mcp-server-supabase/src/tools/database-operation-tools.ts +++ b/packages/mcp-server-supabase/src/tools/database-operation-tools.ts @@ -1,5 +1,6 @@ import { source } from 'common-tags'; import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { ElicitResultSchema } from '@modelcontextprotocol/sdk/types.js'; import { z } from 'zod'; import { listExtensionsSql, listTablesSql } from '../pg-meta/index.js'; import { @@ -221,7 +222,7 @@ export function getDatabaseTools({ // Try to request user confirmation via elicitation if (server) { try { - const result = (await server.request( + const result = await server.request( { method: 'elicitation/create', params: { @@ -240,12 +241,8 @@ export function getDatabaseTools({ }, }, }, - // @ts-ignore - elicitation types might not be available - { elicitation: true } - )) as { - action: 'accept' | 'decline' | 'cancel'; - content?: { confirm?: boolean }; - }; + ElicitResultSchema + ); // User declined or cancelled if (result.action !== 'accept' || !result.content?.confirm) { From 9c65d6a00ebc41a60ea8a33253b47a31747dfd1b Mon Sep 17 00:00:00 2001 From: Pedro Rodrigues Date: Tue, 4 Nov 2025 13:25:39 +0000 Subject: [PATCH 6/6] fix unit tests --- .../mcp-server-supabase/src/server.test.ts | 157 +++++++++--------- 1 file changed, 76 insertions(+), 81 deletions(-) diff --git a/packages/mcp-server-supabase/src/server.test.ts b/packages/mcp-server-supabase/src/server.test.ts index d20ac9e..413b216 100644 --- a/packages/mcp-server-supabase/src/server.test.ts +++ b/packages/mcp-server-supabase/src/server.test.ts @@ -173,16 +173,20 @@ describe('tools', () => { }); const result = await callTool({ - name: 'get_cost', + name: 'get_and_confirm_cost', arguments: { type: 'project', organization_id: freeOrg.id, }, }); - expect(result).toEqual( - 'The new project will cost $0 monthly. You must repeat this to the user and confirm their understanding.' - ); + expect(result).toMatchObject({ + type: 'project', + recurrence: 'monthly', + amount: 0, + confirm_cost_id: expect.any(String), + message: expect.stringContaining('The new project is free'), + }); }); test('get next project cost for paid org with 0 projects', async () => { @@ -195,16 +199,20 @@ describe('tools', () => { }); const result = await callTool({ - name: 'get_cost', + name: 'get_and_confirm_cost', arguments: { type: 'project', organization_id: paidOrg.id, }, }); - expect(result).toEqual( - 'The new project will cost $0 monthly. You must repeat this to the user and confirm their understanding.' - ); + expect(result).toMatchObject({ + type: 'project', + recurrence: 'monthly', + amount: 0, + confirm_cost_id: expect.any(String), + message: expect.stringContaining('The new project is free'), + }); }); test('get next project cost for paid org with > 0 active projects', async () => { @@ -224,16 +232,20 @@ describe('tools', () => { priorProject.status = 'ACTIVE_HEALTHY'; const result = await callTool({ - name: 'get_cost', + name: 'get_and_confirm_cost', arguments: { type: 'project', organization_id: paidOrg.id, }, }); - expect(result).toEqual( - `The new project will cost $${PROJECT_COST_MONTHLY} monthly. You must repeat this to the user and confirm their understanding.` - ); + expect(result).toMatchObject({ + type: 'project', + recurrence: 'monthly', + amount: PROJECT_COST_MONTHLY, + confirm_cost_id: expect.any(String), + message: expect.stringContaining(`The new project will cost $${PROJECT_COST_MONTHLY} monthly`), + }); }); test('get next project cost for paid org with > 0 inactive projects', async () => { @@ -253,38 +265,39 @@ describe('tools', () => { priorProject.status = 'INACTIVE'; const result = await callTool({ - name: 'get_cost', + name: 'get_and_confirm_cost', arguments: { type: 'project', organization_id: paidOrg.id, }, }); - expect(result).toEqual( - `The new project will cost $0 monthly. You must repeat this to the user and confirm their understanding.` - ); + expect(result).toMatchObject({ + type: 'project', + recurrence: 'monthly', + amount: 0, + confirm_cost_id: expect.any(String), + message: expect.stringContaining('The new project is free'), + }); }); test('get branch cost', async () => { const { callTool } = await setup(); - const paidOrg = await createOrganization({ - name: 'Paid Org', - plan: 'pro', - allowed_release_channels: ['ga'], - }); - const result = await callTool({ - name: 'get_cost', + name: 'get_and_confirm_cost', arguments: { type: 'branch', - organization_id: paidOrg.id, }, }); - expect(result).toEqual( - `The new branch will cost $${BRANCH_COST_HOURLY} hourly. You must repeat this to the user and confirm their understanding.` - ); + expect(result).toMatchObject({ + type: 'branch', + recurrence: 'hourly', + amount: BRANCH_COST_HOURLY, + confirm_cost_id: expect.any(String), + message: expect.stringContaining(`The new branch will cost $${BRANCH_COST_HOURLY} hourly`), + }); }); test('list projects', async () => { @@ -350,12 +363,11 @@ describe('tools', () => { allowed_release_channels: ['ga'], }); - const confirm_cost_id = await callTool({ - name: 'confirm_cost', + const costResult = await callTool({ + name: 'get_and_confirm_cost', arguments: { type: 'project', - recurrence: 'monthly', - amount: 0, + organization_id: freeOrg.id, }, }); @@ -363,7 +375,7 @@ describe('tools', () => { name: 'New Project', region: 'us-east-1', organization_id: freeOrg.id, - confirm_cost_id, + confirm_cost_id: costResult.confirm_cost_id, }; const result = await callTool({ @@ -376,6 +388,7 @@ describe('tools', () => { expect(result).toEqual({ ...projectInfo, id: expect.stringMatching(/^.+$/), + message: `Project \"${newProject.name}\" created successfully. Cost: Free`, created_at: expect.stringMatching( /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/ ), @@ -392,12 +405,11 @@ describe('tools', () => { allowed_release_channels: ['ga'], }); - const confirm_cost_id = await callTool({ - name: 'confirm_cost', + const costResult = await callTool({ + name: 'get_and_confirm_cost', arguments: { type: 'project', - recurrence: 'monthly', - amount: 0, + organization_id: freeOrg.id, }, }); @@ -405,7 +417,7 @@ describe('tools', () => { name: 'New Project', region: 'us-east-1', organization_id: freeOrg.id, - confirm_cost_id, + confirm_cost_id: costResult.confirm_cost_id, }; const result = callTool({ @@ -427,19 +439,18 @@ describe('tools', () => { allowed_release_channels: ['ga'], }); - const confirm_cost_id = await callTool({ - name: 'confirm_cost', + const costResult = await callTool({ + name: 'get_and_confirm_cost', arguments: { type: 'project', - recurrence: 'monthly', - amount: 0, + organization_id: freeOrg.id, }, }); const newProject = { name: 'New Project', organization_id: freeOrg.id, - confirm_cost_id, + confirm_cost_id: costResult.confirm_cost_id, }; const createProjectPromise = callTool({ @@ -1833,12 +1844,10 @@ describe('tools', () => { }); project.status = 'ACTIVE_HEALTHY'; - const confirm_cost_id = await callTool({ - name: 'confirm_cost', + const costResult = await callTool({ + name: 'get_and_confirm_cost', arguments: { type: 'branch', - recurrence: 'hourly', - amount: BRANCH_COST_HOURLY, }, }); @@ -1848,7 +1857,7 @@ describe('tools', () => { arguments: { project_id: project.id, name: branchName, - confirm_cost_id, + confirm_cost_id: costResult.confirm_cost_id, }, }); @@ -1888,12 +1897,10 @@ describe('tools', () => { }); project.status = 'ACTIVE_HEALTHY'; - const confirm_cost_id = await callTool({ - name: 'confirm_cost', + const costResult = await callTool({ + name: 'get_and_confirm_cost', arguments: { type: 'branch', - recurrence: 'hourly', - amount: BRANCH_COST_HOURLY, }, }); @@ -1903,7 +1910,7 @@ describe('tools', () => { arguments: { project_id: project.id, name: branchName, - confirm_cost_id, + confirm_cost_id: costResult.confirm_cost_id, }, }); @@ -1960,12 +1967,10 @@ describe('tools', () => { }); project.status = 'ACTIVE_HEALTHY'; - const confirm_cost_id = await callTool({ - name: 'confirm_cost', + const costResult = await callTool({ + name: 'get_and_confirm_cost', arguments: { type: 'branch', - recurrence: 'hourly', - amount: BRANCH_COST_HOURLY, }, }); @@ -1974,7 +1979,7 @@ describe('tools', () => { arguments: { project_id: project.id, name: 'test-branch', - confirm_cost_id, + confirm_cost_id: costResult.confirm_cost_id, }, }); @@ -2115,12 +2120,10 @@ describe('tools', () => { }); project.status = 'ACTIVE_HEALTHY'; - const confirm_cost_id = await callTool({ - name: 'confirm_cost', + const costResult = await callTool({ + name: 'get_and_confirm_cost', arguments: { type: 'branch', - recurrence: 'hourly', - amount: BRANCH_COST_HOURLY, }, }); @@ -2129,7 +2132,7 @@ describe('tools', () => { arguments: { project_id: project.id, name: 'test-branch', - confirm_cost_id, + confirm_cost_id: costResult.confirm_cost_id, }, }); @@ -2220,12 +2223,10 @@ describe('tools', () => { }); project.status = 'ACTIVE_HEALTHY'; - const confirm_cost_id = await callTool({ - name: 'confirm_cost', + const costResult = await callTool({ + name: 'get_and_confirm_cost', arguments: { type: 'branch', - recurrence: 'hourly', - amount: BRANCH_COST_HOURLY, }, }); @@ -2234,7 +2235,7 @@ describe('tools', () => { arguments: { project_id: project.id, name: 'test-branch', - confirm_cost_id, + confirm_cost_id: costResult.confirm_cost_id, }, }); @@ -2334,12 +2335,10 @@ describe('tools', () => { }); project.status = 'ACTIVE_HEALTHY'; - const confirm_cost_id = await callTool({ - name: 'confirm_cost', + const costResult = await callTool({ + name: 'get_and_confirm_cost', arguments: { type: 'branch', - recurrence: 'hourly', - amount: BRANCH_COST_HOURLY, }, }); @@ -2348,7 +2347,7 @@ describe('tools', () => { arguments: { project_id: project.id, name: 'test-branch', - confirm_cost_id, + confirm_cost_id: costResult.confirm_cost_id, }, }); @@ -2436,12 +2435,10 @@ describe('tools', () => { }); project.status = 'ACTIVE_HEALTHY'; - const confirm_cost_id = await callTool({ - name: 'confirm_cost', + const costResult = await callTool({ + name: 'get_and_confirm_cost', arguments: { type: 'branch', - recurrence: 'hourly', - amount: BRANCH_COST_HOURLY, }, }); @@ -2450,7 +2447,7 @@ describe('tools', () => { arguments: { project_id: project.id, name: 'test-branch', - confirm_cost_id, + confirm_cost_id: costResult.confirm_cost_id, }, }); @@ -2582,8 +2579,7 @@ describe('feature groups', () => { 'get_organization', 'list_projects', 'get_project', - 'get_cost', - 'confirm_cost', + 'get_and_confirm_cost', 'create_project', 'pause_project', 'restore_project', @@ -2713,8 +2709,7 @@ describe('feature groups', () => { 'get_organization', 'list_projects', 'get_project', - 'get_cost', - 'confirm_cost', + 'get_and_confirm_cost', 'create_project', 'pause_project', 'restore_project',