Skip to content

Commit 96dd014

Browse files
committed
feat: cost confirmation tools
1 parent 4462c58 commit 96dd014

File tree

4 files changed

+245
-9
lines changed

4 files changed

+245
-9
lines changed
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import {
2+
assertSuccess,
3+
type ManagementApiClient,
4+
} from './management-api/index.js';
5+
6+
const PROJECT_COST_MONTHLY = 10;
7+
const BRANCH_COST_HOURLY = 0.01344;
8+
9+
export type ProjectCost = {
10+
type: 'project';
11+
recurrence: 'monthly';
12+
amount: number;
13+
};
14+
15+
export type BranchCost = {
16+
type: 'branch';
17+
recurrence: 'hourly';
18+
amount: number;
19+
};
20+
21+
export type Cost = ProjectCost | BranchCost;
22+
23+
/**
24+
* Gets the cost of the next project in an organization.
25+
*/
26+
export async function getNextProjectCost(
27+
managementApiClient: ManagementApiClient,
28+
orgId: string
29+
): Promise<Cost> {
30+
const orgResponse = await managementApiClient.GET(
31+
'/v1/organizations/{slug}',
32+
{
33+
params: {
34+
path: {
35+
slug: orgId,
36+
},
37+
},
38+
}
39+
);
40+
41+
assertSuccess(orgResponse, 'Failed to fetch organization');
42+
43+
const projectsResponse = await managementApiClient.GET('/v1/projects');
44+
45+
assertSuccess(projectsResponse, 'Failed to fetch projects');
46+
47+
const org = orgResponse.data;
48+
const activeProjects = projectsResponse.data.filter(
49+
(project) =>
50+
project.organization_id === orgId && project.status === 'ACTIVE_HEALTHY'
51+
);
52+
53+
let amount = 0;
54+
55+
if (org.plan !== 'free') {
56+
// If the organization is on a paid plan, the first project is included
57+
if (activeProjects.length === 0) {
58+
amount = 0;
59+
} else {
60+
amount = PROJECT_COST_MONTHLY;
61+
}
62+
}
63+
64+
return { type: 'project', recurrence: 'monthly', amount };
65+
}
66+
67+
/**
68+
* Gets the cost for a database branch.
69+
*/
70+
export function getBranchCost(): Cost {
71+
return { type: 'branch', recurrence: 'hourly', amount: BRANCH_COST_HOURLY };
72+
}

packages/mcp-server-supabase/src/server.ts

Lines changed: 87 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@ import {
1313
type ManagementApiClient,
1414
} from './management-api/index.js';
1515
import { generatePassword } from './password.js';
16+
import { getBranchCost, getNextProjectCost, type Cost } from './pricing.js';
1617
import {
1718
AWS_REGION_CODES,
1819
getClosestAwsRegion,
1920
getCountryCode,
2021
getCountryCoordinates,
2122
} from './regions.js';
23+
import { hashObject } from './util.js';
2224

2325
export type SupabasePlatformOptions = {
2426
apiUrl?: string;
@@ -120,9 +122,51 @@ export function createSupabaseMcpServer(options: SupabaseMcpServerOptions) {
120122
return response.data;
121123
},
122124
}),
125+
get_cost: tool({
126+
description:
127+
'Gets the cost of creating a new project or branch. Never assume organization as costs can be different for each.',
128+
parameters: z.object({
129+
type: z.enum(['project', 'branch']),
130+
organization_id: z
131+
.string()
132+
.describe('The organization ID. Always ask the user.'),
133+
}),
134+
execute: async ({ type, organization_id }) => {
135+
function generateResponse(cost: Cost) {
136+
return `The new ${type} will cost $${cost.amount} ${cost.recurrence}. You must repeat this to the user and confirm their understanding.`;
137+
}
138+
switch (type) {
139+
case 'project': {
140+
const cost = await getNextProjectCost(
141+
managementApiClient,
142+
organization_id
143+
);
144+
return generateResponse(cost);
145+
}
146+
case 'branch': {
147+
const cost = getBranchCost();
148+
return generateResponse(cost);
149+
}
150+
default:
151+
throw new Error(`Unknown cost type: ${type}`);
152+
}
153+
},
154+
}),
155+
confirm_cost: tool({
156+
description:
157+
'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`.',
158+
parameters: z.object({
159+
type: z.enum(['project', 'branch']),
160+
recurrence: z.enum(['hourly', 'monthly']),
161+
amount: z.number(),
162+
}),
163+
execute: async (cost) => {
164+
return await hashObject(cost);
165+
},
166+
}),
123167
create_project: tool({
124168
description:
125-
'Creates a new Supabase project. Always ask the user which organization to create the project in. Each new project can incur additional costs: If on a free org, the user gets 2 projects for free. On a paid org, the user should reference https://supabase.com/pricing. Confirm that the user understands this before creating new projects. The project can take a few minutes to initialize - use `getProject` to check the status.',
169+
'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.',
126170
parameters: z.object({
127171
name: z.string().describe('The name of the project'),
128172
region: z.optional(
@@ -133,8 +177,28 @@ export function createSupabaseMcpServer(options: SupabaseMcpServerOptions) {
133177
)
134178
),
135179
organization_id: z.string(),
180+
confirm_cost_id: z
181+
.string()
182+
.describe('The cost confirmation ID. Call `confirm_cost` first.'),
136183
}),
137-
execute: async ({ name, region, organization_id }) => {
184+
execute: async ({ name, region, organization_id, confirm_cost_id }) => {
185+
if (!confirm_cost_id) {
186+
throw new Error(
187+
'User must confirm understanding of costs before creating a project.'
188+
);
189+
}
190+
191+
const cost = await getNextProjectCost(
192+
managementApiClient,
193+
organization_id
194+
);
195+
const costHash = await hashObject(cost);
196+
if (costHash !== confirm_cost_id) {
197+
throw new Error(
198+
'Cost confirmation ID does not match the expected cost of creating a project.'
199+
);
200+
}
201+
138202
const response = await managementApiClient.POST('/v1/projects', {
139203
body: {
140204
name,
@@ -320,7 +384,7 @@ export function createSupabaseMcpServer(options: SupabaseMcpServerOptions) {
320384
}),
321385
execute_sql: tool({
322386
description:
323-
'Executes raw SQL in the Postgres database. Use `applyMigration` instead for DDL operations.',
387+
'Executes raw SQL in the Postgres database. Use `apply_migration` instead for DDL operations.',
324388
parameters: z.object({
325389
project_id: z.string(),
326390
query: z.string().describe('The SQL query to execute'),
@@ -441,15 +505,29 @@ export function createSupabaseMcpServer(options: SupabaseMcpServerOptions) {
441505
// Experimental features
442506
create_branch: tool({
443507
description:
444-
'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. Branching is only available on a paid org and each branch will incur additional costs. You must confirm that the user understands these costs before calling this function. Details here: https://supabase.com/docs/guides/deployment/branching#pricing',
508+
'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.',
445509
parameters: z.object({
446510
project_id: z.string(),
447-
name: z
511+
name: z.string().describe('Name of the branch to create'),
512+
confirm_cost_id: z
448513
.string()
449-
.default('develop')
450-
.describe('Name of the branch to create'),
514+
.describe('The cost confirmation ID. Call `confirm_cost` first.'),
451515
}),
452-
execute: async ({ project_id, name }) => {
516+
execute: async ({ project_id, name, confirm_cost_id }) => {
517+
if (!confirm_cost_id) {
518+
throw new Error(
519+
'User must confirm understanding of costs before creating a branch.'
520+
);
521+
}
522+
523+
const cost = getBranchCost();
524+
const costHash = await hashObject(cost);
525+
if (costHash !== confirm_cost_id) {
526+
throw new Error(
527+
'Cost confirmation ID does not match the expected cost of creating a branch.'
528+
);
529+
}
530+
453531
const createBranchResponse = await managementApiClient.POST(
454532
'/v1/projects/{ref}/branches',
455533
{
@@ -477,6 +555,7 @@ export function createSupabaseMcpServer(options: SupabaseMcpServerOptions) {
477555
},
478556
body: {
479557
branch_name: 'main',
558+
git_branch: 'main',
480559
},
481560
});
482561

packages/mcp-server-supabase/src/util.test.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from 'vitest';
2-
import { parseKeyValueList } from './util.js';
2+
import { hashObject, parseKeyValueList } from './util.js';
33

44
describe('parseKeyValueList', () => {
55
it('should parse a simple key-value string', () => {
@@ -45,3 +45,56 @@ describe('parseKeyValueList', () => {
4545
});
4646
});
4747
});
48+
49+
describe('hashObject', () => {
50+
it('should consistently hash the same object', async () => {
51+
const obj = { a: 1, b: 2, c: 3 };
52+
53+
const hash1 = await hashObject(obj);
54+
const hash2 = await hashObject(obj);
55+
56+
expect(hash1).toBe(hash2);
57+
});
58+
59+
it('should produce the same hash regardless of property order', async () => {
60+
const obj1 = { a: 1, b: 2, c: 3 };
61+
const obj2 = { c: 3, a: 1, b: 2 };
62+
63+
const hash1 = await hashObject(obj1);
64+
const hash2 = await hashObject(obj2);
65+
66+
expect(hash1).toBe(hash2);
67+
});
68+
69+
it('should produce different hashes for different objects', async () => {
70+
const obj1 = { a: 1, b: 2 };
71+
const obj2 = { a: 1, b: 3 };
72+
73+
const hash1 = await hashObject(obj1);
74+
const hash2 = await hashObject(obj2);
75+
76+
expect(hash1).not.toBe(hash2);
77+
});
78+
79+
it('should handle nested objects', async () => {
80+
const obj1 = { a: 1, b: { c: 2 } };
81+
const obj2 = { a: 1, b: { c: 3 } };
82+
83+
const hash1 = await hashObject(obj1);
84+
const hash2 = await hashObject(obj2);
85+
86+
expect(hash1).not.toBe(hash2);
87+
});
88+
89+
it('should handle arrays', async () => {
90+
const obj1 = { a: [1, 2, 3] };
91+
const obj2 = { a: [1, 2, 4] };
92+
93+
const hash1 = await hashObject(obj1);
94+
const hash2 = await hashObject(obj2);
95+
96+
console.log('obj1', obj1, hash1);
97+
98+
expect(hash1).not.toBe(hash2);
99+
});
100+
});

packages/mcp-server-supabase/src/util.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,35 @@ export function parseKeyValueList(data: string): { [key: string]: string } {
3838
.map(([key, value]) => [key, value ?? '']) // ensure value is not undefined
3939
);
4040
}
41+
42+
/**
43+
* Creates a unique hash from a JavaScript object.
44+
* @param obj - The object to hash
45+
* @param length - Optional length to truncate the hash (default: full length)
46+
*/
47+
export async function hashObject(
48+
obj: Record<string, any>,
49+
length?: number
50+
): Promise<string> {
51+
// Sort object keys to ensure consistent output regardless of original key order
52+
const str = JSON.stringify(obj, (_, value) => {
53+
if (value && typeof value === 'object' && !Array.isArray(value)) {
54+
return Object.keys(value)
55+
.sort()
56+
.reduce<Record<string, any>>((result, key) => {
57+
result[key] = value[key];
58+
return result;
59+
}, {});
60+
}
61+
return value;
62+
});
63+
64+
const buffer = await crypto.subtle.digest(
65+
'SHA-256',
66+
new TextEncoder().encode(str)
67+
);
68+
69+
// Convert to base64
70+
const base64Hash = btoa(String.fromCharCode(...new Uint8Array(buffer)));
71+
return base64Hash.slice(0, length);
72+
}

0 commit comments

Comments
 (0)