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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
277 changes: 254 additions & 23 deletions packages/mcp-server-supabase/src/server.test.ts

Large diffs are not rendered by default.

79 changes: 44 additions & 35 deletions packages/mcp-server-supabase/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,7 @@ import { createContentApiClient } from './content-api/index.js';
import type { SupabasePlatform } from './platform/types.js';
import { getAccountTools } from './tools/account-tools.js';
import { getBranchingTools } from './tools/branching-tools.js';
import {
type CostConfirmationState,
isFormCapable,
} from './tools/cost-confirmation.js';
import { type ConfirmationState, isFormCapable } from './tools/confirmation.js';
import { getDatabaseTools } from './tools/database-operation-tools.js';
import { getDebuggingTools } from './tools/debugging-tools.js';
import { getDevelopmentTools } from './tools/development-tools.js';
Expand Down Expand Up @@ -62,21 +59,26 @@ export type SupabaseMcpServerOptions = {
onToolCall?: ToolCallCallback;

/**
* Enables cost confirmation via elicitation for clients that declare
* per-request form-elicitation capability. Clients without that
* capability keep using `get_cost` -> `confirm_cost` -> the relevant
* project or branch `confirm_cost_id` flow (`create_project` or
* `create_branch`).
* Enables confirmation elicitations for clients that declare per-request
* form-elicitation capability. Cost confirmation applies to `create_project`
* and `create_branch`. Destructive SQL confirmation applies to `execute_sql`
* and `apply_migration`. Clients without that capability keep today's SQL
* tool behavior and use the existing cost-confirmation flow.
*/
costConfirmation?: {
confirmation?: {
/** HMAC key for the `requestState` codec. MUST be at least 32 bytes. */
requestStateKey: string | Uint8Array;
/** The authenticated principal `requestState` is bound to. */
principal: string;
/** How long a minted `requestState` stays valid, in seconds. */
ttlSeconds?: number;
/** Tools that accept a cost-confirmation elicitation. */
enabledTools: readonly ('create_project' | 'create_branch')[];
/** Tools that accept a confirmation elicitation. */
enabledTools: readonly (
| 'create_project'
| 'create_branch'
| 'execute_sql'
| 'apply_migration'
)[];
};
};

Expand Down Expand Up @@ -120,7 +122,7 @@ export function createSupabaseMcpServer(options: SupabaseMcpServerOptions) {
features,
contentApiUrl = 'https://supabase.com/docs/api/graphql',
onToolCall,
costConfirmation,
confirmation,
} = options;

const contentApiClientPromise = createContentApiClient(contentApiUrl, {
Expand All @@ -140,11 +142,11 @@ export function createSupabaseMcpServer(options: SupabaseMcpServerOptions) {
features ?? availableDefaultFeatures
);

const costConfirmationCodec = costConfirmation?.enabledTools.length
? createRequestStateCodec<CostConfirmationState>({
key: costConfirmation.requestStateKey,
ttlSeconds: costConfirmation.ttlSeconds,
bind: (ctx) => `${ctx.mcpReq.method}:${costConfirmation.principal}`,
const confirmationCodec = confirmation?.enabledTools.length
? createRequestStateCodec<ConfirmationState>({
key: confirmation.requestStateKey,
ttlSeconds: confirmation.ttlSeconds,
bind: (ctx) => `${ctx.mcpReq.method}:${confirmation.principal}`,
})
: undefined;

Expand All @@ -167,8 +169,8 @@ export function createSupabaseMcpServer(options: SupabaseMcpServerOptions) {
]);
},
onToolCall,
requestState: costConfirmationCodec && {
verify: costConfirmationCodec.verify,
requestState: confirmationCodec && {
verify: confirmationCodec.verify,
},
tools: async (ctx) => {
const contentApiClient = await contentApiClientPromise;
Expand All @@ -194,10 +196,10 @@ export function createSupabaseMcpServer(options: SupabaseMcpServerOptions) {
getAccountTools({
account,
readOnly,
costConfirmation:
costConfirmationCodec &&
costConfirmation?.enabledTools.includes('create_project')
? { codec: costConfirmationCodec }
confirmation:
confirmationCodec &&
confirmation?.enabledTools.includes('create_project')
? { codec: confirmationCodec }
: undefined,
})
);
Expand All @@ -210,6 +212,18 @@ export function createSupabaseMcpServer(options: SupabaseMcpServerOptions) {
database,
projectId,
readOnly,
confirmation:
confirmationCodec &&
(confirmation?.enabledTools.includes('execute_sql') ||
confirmation?.enabledTools.includes('apply_migration'))
? {
codec: confirmationCodec,
enabledTools: confirmation.enabledTools.filter(
(tool): tool is 'execute_sql' | 'apply_migration' =>
tool === 'execute_sql' || tool === 'apply_migration'
),
}
: undefined,
})
);
}
Expand All @@ -236,10 +250,10 @@ export function createSupabaseMcpServer(options: SupabaseMcpServerOptions) {
branching,
projectId,
readOnly,
costConfirmation:
costConfirmationCodec &&
costConfirmation?.enabledTools.includes('create_branch')
? { codec: costConfirmationCodec }
confirmation:
confirmationCodec &&
confirmation?.enabledTools.includes('create_branch')
? { codec: confirmationCodec }
: undefined,
})
);
Expand All @@ -261,17 +275,12 @@ export function createSupabaseMcpServer(options: SupabaseMcpServerOptions) {
// drop `confirm_cost_id` and the legacy cost tools only offer the types
// that still need them. With nothing left to quote they are hidden
// entirely.
if (
costConfirmationCodec &&
costConfirmation &&
ctx &&
isFormCapable(ctx)
) {
if (confirmationCodec && confirmation && ctx && isFormCapable(ctx)) {
const legacyCostTypes: ('project' | 'branch')[] = [];
for (const type of ['project', 'branch'] as const) {
const name = `create_${type}` as const;
const tool = tools[name];
if (!costConfirmation.enabledTools.includes(name)) {
if (!confirmation.enabledTools.includes(name)) {
legacyCostTypes.push(type);
} else if (tool) {
tools[name] = {
Expand Down
139 changes: 43 additions & 96 deletions packages/mcp-server-supabase/src/tools/account-tools.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import {
inputRequired,
inputResponse,
type RequestStateCodec,
type ServerContext,
} from '@modelcontextprotocol/server';
Expand All @@ -9,9 +8,11 @@ import { z } from 'zod/v4';
import type { ToolDefs } from './util.js';
import {
actionOnlyElicitationSchema,
checkConfirmationState,
isFormCapable,
type CostConfirmationState,
} from './cost-confirmation.js';
projectCostStateSchema,
type ConfirmationState,
} from './confirmation.js';
import type { AccountOperations } from '../platform/types.js';
import { organizationSchema, projectSchema } from '../platform/types.js';
import { getBranchCost, getNextProjectCost } from '../pricing.js';
Expand All @@ -22,13 +23,13 @@ type AccountToolsOptions = {
account: AccountOperations;
readOnly?: boolean;
/**
* Enables cost confirmation via elicitation inside `create_project` for
* clients that declare per-request form capability (see
* `isFormCapable`). Absent, `create_project` keeps requiring
* `confirm_cost_id` from `confirm_cost` unchanged.
* Enables confirmation via elicitation inside `create_project` for clients
* that declare per-request form capability (see `isFormCapable`). Absent,
* `create_project` keeps requiring `confirm_cost_id` from `confirm_cost`
* unchanged.
*/
costConfirmation?: {
codec: RequestStateCodec<CostConfirmationState>;
confirmation?: {
codec: RequestStateCodec<ConfirmationState>;
};
};

Expand Down Expand Up @@ -249,7 +250,7 @@ export const accountToolDefs = {
export function getAccountTools({
account,
readOnly,
costConfirmation,
confirmation,
}: AccountToolsOptions) {
return {
list_organizations: tool({
Expand Down Expand Up @@ -297,7 +298,7 @@ export function getAccountTools({
}),
create_project: tool({
...accountToolDefs.create_project,
parameters: costConfirmation
parameters: confirmation
? createProjectInputSchemaWithElicitation
: createProjectInputSchema,
execute: async (
Expand All @@ -313,10 +314,10 @@ export function getAccountTools({
throw new Error('Cannot create a project in read-only mode.');
}

if (costConfirmation && isFormCapable(ctx)) {
const { codec } = costConfirmation;
if (confirmation && isFormCapable(ctx)) {
const { codec } = confirmation;
const cost = await getNextProjectCost(account, organization_id);
const state = ctx.mcpReq.requestState<CostConfirmationState>();
const state = ctx.mcpReq.requestState<unknown>();
if (!state && cost.amount === 0) {
return await account.createProject({
name,
Expand Down Expand Up @@ -346,90 +347,36 @@ export function getAccountTools({
),
});

if (!state) {
return askForConfirmation();
}

if (state.tool !== 'create_project') {
return {
content: [
{
type: 'text' as const,
text: 'Request state was not issued for create_project.',
},
],
structuredContent: { status: 'error' },
isError: true,
};
}

if (
state.name !== name ||
state.region !== region ||
state.organization_id !== organization_id
) {
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: 'Project creation was declined.',
},
],
structuredContent: { status: 'declined' },
};
}

if (response.action !== 'accept') {
return {
content: [
{
type: 'text' as const,
text: 'Project creation was cancelled.',
},
],
structuredContent: { status: 'cancelled' },
};
}
const confirmationState = await checkConfirmationState({
ctx,
tool: 'create_project',
schema: projectCostStateSchema,
requestKey: 'confirm_cost',
askForConfirmation,
argsMatch: (state) =>
state.name === name &&
state.region === region &&
state.organization_id === organization_id,
payloadMatch: (state) =>
cost.amount === 0 ||
(state.cost.type === cost.type &&
state.cost.recurrence === cost.recurrence &&
state.cost.amount === cost.amount),
declinedText: 'Project creation was declined.',
cancelledText: 'Project creation was cancelled.',
});

if (
cost.amount !== 0 &&
(state.cost.type !== cost.type ||
state.cost.recurrence !== cost.recurrence ||
state.cost.amount !== cost.amount)
) {
// Pricing changed since the state was minted (e.g. the org's
// plan or active-project count shifted) - reissue a fresh
// prompt bound to the recomputed cost rather than honoring a
// stale quote.
return askForConfirmation();
switch (confirmationState.kind) {
case 'reprompt':
case 'terminal':
return confirmationState.result;
case 'proceed':
return await account.createProject({
name: confirmationState.state.name,
region: confirmationState.state.region,
organization_id: confirmationState.state.organization_id,
});
}

return await account.createProject({
name: state.name,
region: state.region,
organization_id: state.organization_id,
});
}

const cost = await getNextProjectCost(account, organization_id);
Expand Down
Loading
Loading