Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
10 changes: 2 additions & 8 deletions packages/mcp-server-supabase/src/platform/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,10 +234,7 @@ export type DevelopmentOperations = {

export type StorageOperations = {
getStorageConfig(projectId: string): Promise<StorageConfig>;
updateStorageConfig(
projectId: string,
config: StorageConfig
): Promise<void>;
updateStorageConfig(projectId: string, config: StorageConfig): Promise<void>;
listAllBuckets(projectId: string): Promise<StorageBucket[]>;
};

Expand All @@ -249,10 +246,7 @@ export type BranchingOperations = {
): Promise<Branch>;
deleteBranch(branchId: string): Promise<void>;
mergeBranch(branchId: string): Promise<void>;
resetBranch(
branchId: string,
options: ResetBranchOptions
): Promise<void>;
resetBranch(branchId: string, options: ResetBranchOptions): Promise<void>;
rebaseBranch(branchId: string): Promise<void>;
};

Expand Down
3 changes: 2 additions & 1 deletion packages/mcp-server-supabase/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')) {
Expand All @@ -144,6 +144,7 @@ export function createSupabaseMcpServer(options: SupabaseMcpServerOptions) {
database,
projectId,
readOnly,
server,
})
);
}
Expand Down
145 changes: 109 additions & 36 deletions packages/mcp-server-supabase/src/tools/account-tools.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { tool } from '@supabase/mcp-utils';
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';
Expand All @@ -10,9 +12,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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Think this is unused now that it's accessing the server via ToolExecuteContext

}: AccountToolsOptions) {
return {
list_organizations: tool({
description: 'Lists all organizations that the user is a member of.',
Expand Down Expand Up @@ -76,11 +83,16 @@ export function getAccountTools({ account, readOnly }: AccountToolsOptions) {
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({
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was originally thinking:

  • If elicitations are supported, hide get_cost and confirm_cost tools, and run the elicitation directly on create_project and create_branch
  • If elicitations are not supported, keep both get_cost and confirm_cost as is

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In practice having the elicitation in create_project felt odd to me because you have to approve a create_project tool call before knowing how much the project will cost or that a safety net will follow (Slack context).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mattrossman pointed out that users might get confused and might even panic when they see create_project or create_branch running by the user without checking and confirming the cost (even hough that an elicitation request will prompt before creating the project/branch). I do agree with Matt on this because most users will be prompt to accept running create_project or create_branch

description: async () => {
const clientCapabilities = server?.getClientCapabilities();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getClientCapabilities() will not be reliable in our stateless http setup. Capabilities are sent on the init message, which will be lost on future messages. @mattrossman is capturing this in the session ID JWT though - do you have have thoughts on how we can inject this back into the server instance every request?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SDK lets us populate it via ServerOptions in the server constructor as a fallback if no "initialize" message was received for the instance. That populates ._capabilities which is read in getClientCapabilities()

I figure we'd expose a capabilities option in our createMcpServer / createSupabaseMcpServer functions to forward to those server options.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct me if I'm wrong, but isn't server capabilities different from client capabilities? IIUC ServerOptions allows you to set server capabilities that are advertised to the client but not vice versa (_capabilities vs _clientCapabilities)

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,
Expand All @@ -90,48 +102,94 @@ export function getAccountTools({ account, readOnly }: AccountToolsOptions) {
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,
},
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. 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,
Expand All @@ -150,13 +208,16 @@ export function getAccountTools({ account, readOnly }: AccountToolsOptions) {
required_error:
'User must confirm understanding of costs before creating a project.',
})
.describe('The cost confirmation ID. Call `confirm_cost` first.'),
.describe(
'The cost confirmation ID. Call `get_and_confirm_cost` first.'
),
}),
execute: async ({ name, region, organization_id, confirm_cost_id }) => {
if (readOnly) {
throw new Error('Cannot create a project in read-only mode.');
}

// Verify the confirmation ID matches the expected cost
const cost = await getNextProjectCost(account, organization_id);
const costHash = await hashObject(cost);
if (costHash !== confirm_cost_id) {
Expand All @@ -165,11 +226,23 @@ export function getAccountTools({ account, readOnly }: AccountToolsOptions) {
);
}

return await account.createProject({
// Create the project
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({
Expand Down
6 changes: 4 additions & 2 deletions packages/mcp-server-supabase/src/tools/branching-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -42,7 +42,9 @@ export function getBranchingTools({
required_error:
'User must confirm understanding of costs before creating a branch.',
})
.describe('The cost confirmation ID. Call `confirm_cost` first.'),
.describe(
'The cost confirmation ID. Call `get_and_confirm_cost` first.'
),
}),
inject: { project_id },
execute: async ({ project_id, name, confirm_cost_id }) => {
Expand Down
42 changes: 42 additions & 0 deletions packages/mcp-server-supabase/src/tools/database-operation-tools.ts
Original file line number Diff line number Diff line change
@@ -1,4 +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 {
Expand All @@ -14,12 +16,14 @@ export type DatabaseOperationToolsOptions = {
database: DatabaseOperations;
projectId?: string;
readOnly?: boolean;
server?: Server;
};

export function getDatabaseTools({
database,
projectId,
readOnly,
server,
}: DatabaseOperationToolsOptions) {
const project_id = projectId;

Expand Down Expand Up @@ -215,6 +219,44 @@ 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'],
},
},
},
ElicitResultSchema
);
Comment on lines +225 to +245
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to be careful about code flow with elicitations on stateless HTTP. Stateless HTTP is simulating bidirectional communication via requests and responses, but each new request has a brand new state so we need to make sure our mental model matches this in our implementations.

IIUC, elicitations are sent back via HTTP response? If yes, the result of the elicitation will be sent as a brand new request to the server and will never land back at this point in the code flow. Perhaps the best way to confirm this is to develop/test against a remote MCP server instead of stdio.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment suggests elicitations don't work with stateless :/

I'd think it run both requests in parallel (the outer tool call and the inner elicitation) but I guess keeping the tool call long-running w/o streaming isn't great.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, looks like a blocker. From what I can piece together from the spec, server initiated requests need to come from the stateful SSE GET route (which we don't implement). The spec does say that POST responses can include server initiated requests, but only when responding with an SSE stream which always has to end with a server response anyways. I could be missing something but to me this means the only way to implement this is via SSE GET route + complex communication between multiple requests.


// 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,
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp-server-supabase/src/tools/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<z.ZodObject<any, any, any, CleanParams>, Result>;
}
Loading
Loading