This is page 3 of 4. Use http://codebase.md/supabase-community/supabase-mcp?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .github
│ └── workflows
│ └── tests.yml
├── .gitignore
├── .nvmrc
├── .vscode
│ └── settings.json
├── biome.json
├── CONTRIBUTING.md
├── docs
│ └── production.md
├── LICENSE
├── package.json
├── packages
│ ├── mcp-server-postgrest
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── index.ts
│ │ │ ├── server.test.ts
│ │ │ ├── server.ts
│ │ │ ├── stdio.ts
│ │ │ └── util.ts
│ │ ├── tsconfig.json
│ │ └── tsup.config.ts
│ ├── mcp-server-supabase
│ │ ├── .gitignore
│ │ ├── package.json
│ │ ├── scripts
│ │ │ └── registry
│ │ │ ├── login.sh
│ │ │ └── update-version.ts
│ │ ├── server.json
│ │ ├── src
│ │ │ ├── content-api
│ │ │ │ ├── graphql.test.ts
│ │ │ │ ├── graphql.ts
│ │ │ │ └── index.ts
│ │ │ ├── edge-function.test.ts
│ │ │ ├── edge-function.ts
│ │ │ ├── index.test.ts
│ │ │ ├── index.ts
│ │ │ ├── logs.ts
│ │ │ ├── management-api
│ │ │ │ ├── index.ts
│ │ │ │ └── types.ts
│ │ │ ├── password.test.ts
│ │ │ ├── password.ts
│ │ │ ├── pg-meta
│ │ │ │ ├── columns.sql
│ │ │ │ ├── extensions.sql
│ │ │ │ ├── index.ts
│ │ │ │ ├── tables.sql
│ │ │ │ └── types.ts
│ │ │ ├── platform
│ │ │ │ ├── api-platform.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── types.ts
│ │ │ ├── pricing.ts
│ │ │ ├── regions.ts
│ │ │ ├── server.test.ts
│ │ │ ├── server.ts
│ │ │ ├── tools
│ │ │ │ ├── account-tools.ts
│ │ │ │ ├── branching-tools.ts
│ │ │ │ ├── database-operation-tools.ts
│ │ │ │ ├── debugging-tools.ts
│ │ │ │ ├── development-tools.ts
│ │ │ │ ├── docs-tools.ts
│ │ │ │ ├── edge-function-tools.ts
│ │ │ │ ├── storage-tools.ts
│ │ │ │ └── util.ts
│ │ │ ├── transports
│ │ │ │ ├── stdio.ts
│ │ │ │ ├── util.test.ts
│ │ │ │ └── util.ts
│ │ │ ├── types
│ │ │ │ └── sql.d.ts
│ │ │ ├── types.test.ts
│ │ │ ├── types.ts
│ │ │ ├── util.test.ts
│ │ │ └── util.ts
│ │ ├── test
│ │ │ ├── e2e
│ │ │ │ ├── functions.e2e.ts
│ │ │ │ ├── projects.e2e.ts
│ │ │ │ ├── prompt-injection.e2e.ts
│ │ │ │ ├── setup.ts
│ │ │ │ └── utils.ts
│ │ │ ├── extensions.d.ts
│ │ │ ├── extensions.ts
│ │ │ ├── mocks.ts
│ │ │ ├── plugins
│ │ │ │ └── text-loader.ts
│ │ │ └── stdio.integration.ts
│ │ ├── tsconfig.json
│ │ ├── tsup.config.ts
│ │ ├── vitest.config.ts
│ │ ├── vitest.setup.ts
│ │ └── vitest.workspace.ts
│ └── mcp-utils
│ ├── package.json
│ ├── README.md
│ ├── src
│ │ ├── index.ts
│ │ ├── server.test.ts
│ │ ├── server.ts
│ │ ├── stream-transport.ts
│ │ ├── types.ts
│ │ ├── util.test.ts
│ │ └── util.ts
│ ├── tsconfig.json
│ └── tsup.config.ts
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── README.md
└── supabase
├── config.toml
├── migrations
│ ├── 20241220232417_todos.sql
│ └── 20250109000000_add_todo_policies.sql
└── seed.sql
```
# Files
--------------------------------------------------------------------------------
/packages/mcp-server-supabase/src/server.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2 | import {
3 | CallToolResultSchema,
4 | type CallToolRequest,
5 | } from '@modelcontextprotocol/sdk/types.js';
6 | import { StreamTransport } from '@supabase/mcp-utils';
7 | import { codeBlock, stripIndent } from 'common-tags';
8 | import { setupServer } from 'msw/node';
9 | import { beforeEach, describe, expect, test } from 'vitest';
10 | import {
11 | ACCESS_TOKEN,
12 | API_URL,
13 | contentApiMockSchema,
14 | mockContentApiSchemaLoadCount,
15 | createOrganization,
16 | createProject,
17 | createBranch,
18 | MCP_CLIENT_NAME,
19 | MCP_CLIENT_VERSION,
20 | mockBranches,
21 | mockContentApi,
22 | mockManagementApi,
23 | mockOrgs,
24 | mockProjects,
25 | } from '../test/mocks.js';
26 | import { createSupabaseApiPlatform } from './platform/api-platform.js';
27 | import { BRANCH_COST_HOURLY, PROJECT_COST_MONTHLY } from './pricing.js';
28 | import { createSupabaseMcpServer } from './server.js';
29 | import type { SupabasePlatform } from './platform/types.js';
30 |
31 | beforeEach(async () => {
32 | mockOrgs.clear();
33 | mockProjects.clear();
34 | mockBranches.clear();
35 | mockContentApiSchemaLoadCount.value = 0;
36 |
37 | const server = setupServer(...mockContentApi, ...mockManagementApi);
38 | server.listen({ onUnhandledRequest: 'error' });
39 | });
40 |
41 | type SetupOptions = {
42 | accessToken?: string;
43 | projectId?: string;
44 | platform?: SupabasePlatform;
45 | readOnly?: boolean;
46 | features?: string[];
47 | };
48 |
49 | /**
50 | * Sets up an MCP client and server for testing.
51 | */
52 | async function setup(options: SetupOptions = {}) {
53 | const { accessToken = ACCESS_TOKEN, projectId, readOnly, features } = options;
54 | const clientTransport = new StreamTransport();
55 | const serverTransport = new StreamTransport();
56 |
57 | clientTransport.readable.pipeTo(serverTransport.writable);
58 | serverTransport.readable.pipeTo(clientTransport.writable);
59 |
60 | const client = new Client(
61 | {
62 | name: MCP_CLIENT_NAME,
63 | version: MCP_CLIENT_VERSION,
64 | },
65 | {
66 | capabilities: {},
67 | }
68 | );
69 |
70 | const platform =
71 | options.platform ??
72 | createSupabaseApiPlatform({
73 | accessToken,
74 | apiUrl: API_URL,
75 | });
76 |
77 | const server = createSupabaseMcpServer({
78 | platform,
79 | projectId,
80 | readOnly,
81 | features,
82 | });
83 |
84 | await server.connect(serverTransport);
85 | await client.connect(clientTransport);
86 |
87 | /**
88 | * Calls a tool with the given parameters.
89 | *
90 | * Wrapper around the `client.callTool` method to handle the response and errors.
91 | */
92 | async function callTool(params: CallToolRequest['params']) {
93 | const output = await client.callTool(params);
94 | const { content } = CallToolResultSchema.parse(output);
95 | const [textContent] = content;
96 |
97 | if (!textContent) {
98 | return undefined;
99 | }
100 |
101 | if (textContent.type !== 'text') {
102 | throw new Error('tool result content is not text');
103 | }
104 |
105 | if (textContent.text === '') {
106 | throw new Error('tool result content is empty');
107 | }
108 |
109 | const result = JSON.parse(textContent.text);
110 |
111 | if (output.isError) {
112 | throw new Error(result.error.message);
113 | }
114 |
115 | return result;
116 | }
117 |
118 | return { client, clientTransport, callTool, server, serverTransport };
119 | }
120 |
121 | describe('tools', () => {
122 | test('list organizations', async () => {
123 | const { callTool } = await setup();
124 |
125 | const org1 = await createOrganization({
126 | name: 'Org 1',
127 | plan: 'free',
128 | allowed_release_channels: ['ga'],
129 | });
130 | const org2 = await createOrganization({
131 | name: 'Org 2',
132 | plan: 'free',
133 | allowed_release_channels: ['ga'],
134 | });
135 |
136 | const result = await callTool({
137 | name: 'list_organizations',
138 | arguments: {},
139 | });
140 |
141 | expect(result).toEqual([
142 | { id: org1.id, name: org1.name },
143 | { id: org2.id, name: org2.name },
144 | ]);
145 | });
146 |
147 | test('get organization', async () => {
148 | const { callTool } = await setup();
149 |
150 | const org = await createOrganization({
151 | name: 'My Org',
152 | plan: 'free',
153 | allowed_release_channels: ['ga'],
154 | });
155 |
156 | const result = await callTool({
157 | name: 'get_organization',
158 | arguments: {
159 | id: org.id,
160 | },
161 | });
162 |
163 | expect(result).toEqual(org);
164 | });
165 |
166 | test('get next project cost for free org', async () => {
167 | const { callTool } = await setup();
168 |
169 | const freeOrg = await createOrganization({
170 | name: 'Free Org',
171 | plan: 'free',
172 | allowed_release_channels: ['ga'],
173 | });
174 |
175 | const result = await callTool({
176 | name: 'get_cost',
177 | arguments: {
178 | type: 'project',
179 | organization_id: freeOrg.id,
180 | },
181 | });
182 |
183 | expect(result).toEqual(
184 | 'The new project will cost $0 monthly. You must repeat this to the user and confirm their understanding.'
185 | );
186 | });
187 |
188 | test('get next project cost for paid org with 0 projects', async () => {
189 | const { callTool } = await setup();
190 |
191 | const paidOrg = await createOrganization({
192 | name: 'Paid Org',
193 | plan: 'pro',
194 | allowed_release_channels: ['ga'],
195 | });
196 |
197 | const result = await callTool({
198 | name: 'get_cost',
199 | arguments: {
200 | type: 'project',
201 | organization_id: paidOrg.id,
202 | },
203 | });
204 |
205 | expect(result).toEqual(
206 | 'The new project will cost $0 monthly. You must repeat this to the user and confirm their understanding.'
207 | );
208 | });
209 |
210 | test('get next project cost for paid org with > 0 active projects', async () => {
211 | const { callTool } = await setup();
212 |
213 | const paidOrg = await createOrganization({
214 | name: 'Paid Org',
215 | plan: 'pro',
216 | allowed_release_channels: ['ga'],
217 | });
218 |
219 | const priorProject = await createProject({
220 | name: 'Project 1',
221 | region: 'us-east-1',
222 | organization_id: paidOrg.id,
223 | });
224 | priorProject.status = 'ACTIVE_HEALTHY';
225 |
226 | const result = await callTool({
227 | name: 'get_cost',
228 | arguments: {
229 | type: 'project',
230 | organization_id: paidOrg.id,
231 | },
232 | });
233 |
234 | expect(result).toEqual(
235 | `The new project will cost $${PROJECT_COST_MONTHLY} monthly. You must repeat this to the user and confirm their understanding.`
236 | );
237 | });
238 |
239 | test('get next project cost for paid org with > 0 inactive projects', async () => {
240 | const { callTool } = await setup();
241 |
242 | const paidOrg = await createOrganization({
243 | name: 'Paid Org',
244 | plan: 'pro',
245 | allowed_release_channels: ['ga'],
246 | });
247 |
248 | const priorProject = await createProject({
249 | name: 'Project 1',
250 | region: 'us-east-1',
251 | organization_id: paidOrg.id,
252 | });
253 | priorProject.status = 'INACTIVE';
254 |
255 | const result = await callTool({
256 | name: 'get_cost',
257 | arguments: {
258 | type: 'project',
259 | organization_id: paidOrg.id,
260 | },
261 | });
262 |
263 | expect(result).toEqual(
264 | `The new project will cost $0 monthly. You must repeat this to the user and confirm their understanding.`
265 | );
266 | });
267 |
268 | test('get branch cost', async () => {
269 | const { callTool } = await setup();
270 |
271 | const paidOrg = await createOrganization({
272 | name: 'Paid Org',
273 | plan: 'pro',
274 | allowed_release_channels: ['ga'],
275 | });
276 |
277 | const result = await callTool({
278 | name: 'get_cost',
279 | arguments: {
280 | type: 'branch',
281 | organization_id: paidOrg.id,
282 | },
283 | });
284 |
285 | expect(result).toEqual(
286 | `The new branch will cost $${BRANCH_COST_HOURLY} hourly. You must repeat this to the user and confirm their understanding.`
287 | );
288 | });
289 |
290 | test('list projects', async () => {
291 | const { callTool } = await setup();
292 |
293 | const org = await createOrganization({
294 | name: 'My Org',
295 | plan: 'free',
296 | allowed_release_channels: ['ga'],
297 | });
298 |
299 | const project1 = await createProject({
300 | name: 'Project 1',
301 | region: 'us-east-1',
302 | organization_id: org.id,
303 | });
304 |
305 | const project2 = await createProject({
306 | name: 'Project 2',
307 | region: 'us-east-1',
308 | organization_id: org.id,
309 | });
310 |
311 | const result = await callTool({
312 | name: 'list_projects',
313 | arguments: {},
314 | });
315 |
316 | expect(result).toEqual([project1.details, project2.details]);
317 | });
318 |
319 | test('get project', async () => {
320 | const { callTool } = await setup();
321 |
322 | const org = await createOrganization({
323 | name: 'My Org',
324 | plan: 'free',
325 | allowed_release_channels: ['ga'],
326 | });
327 |
328 | const project = await createProject({
329 | name: 'Project 1',
330 | region: 'us-east-1',
331 | organization_id: org.id,
332 | });
333 |
334 | const result = await callTool({
335 | name: 'get_project',
336 | arguments: {
337 | id: project.id,
338 | },
339 | });
340 |
341 | expect(result).toEqual(project.details);
342 | });
343 |
344 | test('create project', async () => {
345 | const { callTool } = await setup();
346 |
347 | const freeOrg = await createOrganization({
348 | name: 'Free Org',
349 | plan: 'free',
350 | allowed_release_channels: ['ga'],
351 | });
352 |
353 | const confirm_cost_id = await callTool({
354 | name: 'confirm_cost',
355 | arguments: {
356 | type: 'project',
357 | recurrence: 'monthly',
358 | amount: 0,
359 | },
360 | });
361 |
362 | const newProject = {
363 | name: 'New Project',
364 | region: 'us-east-1',
365 | organization_id: freeOrg.id,
366 | confirm_cost_id,
367 | };
368 |
369 | const result = await callTool({
370 | name: 'create_project',
371 | arguments: newProject,
372 | });
373 |
374 | const { confirm_cost_id: _, ...projectInfo } = newProject;
375 |
376 | expect(result).toEqual({
377 | ...projectInfo,
378 | id: expect.stringMatching(/^.+$/),
379 | created_at: expect.stringMatching(
380 | /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/
381 | ),
382 | status: 'UNKNOWN',
383 | });
384 | });
385 |
386 | test('create project in read-only mode throws an error', async () => {
387 | const { callTool } = await setup({ readOnly: true });
388 |
389 | const freeOrg = await createOrganization({
390 | name: 'Free Org',
391 | plan: 'free',
392 | allowed_release_channels: ['ga'],
393 | });
394 |
395 | const confirm_cost_id = await callTool({
396 | name: 'confirm_cost',
397 | arguments: {
398 | type: 'project',
399 | recurrence: 'monthly',
400 | amount: 0,
401 | },
402 | });
403 |
404 | const newProject = {
405 | name: 'New Project',
406 | region: 'us-east-1',
407 | organization_id: freeOrg.id,
408 | confirm_cost_id,
409 | };
410 |
411 | const result = callTool({
412 | name: 'create_project',
413 | arguments: newProject,
414 | });
415 |
416 | await expect(result).rejects.toThrow(
417 | 'Cannot create a project in read-only mode.'
418 | );
419 | });
420 |
421 | test('create project without region fails', async () => {
422 | const { callTool } = await setup();
423 |
424 | const freeOrg = await createOrganization({
425 | name: 'Free Org',
426 | plan: 'free',
427 | allowed_release_channels: ['ga'],
428 | });
429 |
430 | const confirm_cost_id = await callTool({
431 | name: 'confirm_cost',
432 | arguments: {
433 | type: 'project',
434 | recurrence: 'monthly',
435 | amount: 0,
436 | },
437 | });
438 |
439 | const newProject = {
440 | name: 'New Project',
441 | organization_id: freeOrg.id,
442 | confirm_cost_id,
443 | };
444 |
445 | const createProjectPromise = callTool({
446 | name: 'create_project',
447 | arguments: newProject,
448 | });
449 |
450 | await expect(createProjectPromise).rejects.toThrow();
451 | });
452 |
453 | test('create project without cost confirmation fails', async () => {
454 | const { callTool } = await setup();
455 |
456 | const org = await createOrganization({
457 | name: 'Paid Org',
458 | plan: 'pro',
459 | allowed_release_channels: ['ga'],
460 | });
461 |
462 | const newProject = {
463 | name: 'New Project',
464 | region: 'us-east-1',
465 | organization_id: org.id,
466 | };
467 |
468 | const createProjectPromise = callTool({
469 | name: 'create_project',
470 | arguments: newProject,
471 | });
472 |
473 | await expect(createProjectPromise).rejects.toThrow(
474 | 'User must confirm understanding of costs before creating a project.'
475 | );
476 | });
477 |
478 | test('pause project', async () => {
479 | const { callTool } = await setup();
480 |
481 | const org = await createOrganization({
482 | name: 'My Org',
483 | plan: 'free',
484 | allowed_release_channels: ['ga'],
485 | });
486 |
487 | const project = await createProject({
488 | name: 'Project 1',
489 | region: 'us-east-1',
490 | organization_id: org.id,
491 | });
492 | project.status = 'ACTIVE_HEALTHY';
493 |
494 | await callTool({
495 | name: 'pause_project',
496 | arguments: {
497 | project_id: project.id,
498 | },
499 | });
500 |
501 | expect(project.status).toEqual('INACTIVE');
502 | });
503 |
504 | test('pause project in read-only mode throws an error', async () => {
505 | const { callTool } = await setup({ readOnly: true });
506 |
507 | const org = await createOrganization({
508 | name: 'My Org',
509 | plan: 'free',
510 | allowed_release_channels: ['ga'],
511 | });
512 |
513 | const project = await createProject({
514 | name: 'Project 1',
515 | region: 'us-east-1',
516 | organization_id: org.id,
517 | });
518 | project.status = 'ACTIVE_HEALTHY';
519 |
520 | const result = callTool({
521 | name: 'pause_project',
522 | arguments: {
523 | project_id: project.id,
524 | },
525 | });
526 |
527 | await expect(result).rejects.toThrow(
528 | 'Cannot pause a project in read-only mode.'
529 | );
530 | });
531 |
532 | test('restore project', async () => {
533 | const { callTool } = await setup();
534 |
535 | const org = await createOrganization({
536 | name: 'My Org',
537 | plan: 'free',
538 | allowed_release_channels: ['ga'],
539 | });
540 |
541 | const project = await createProject({
542 | name: 'Project 1',
543 | region: 'us-east-1',
544 | organization_id: org.id,
545 | });
546 | project.status = 'INACTIVE';
547 |
548 | await callTool({
549 | name: 'restore_project',
550 | arguments: {
551 | project_id: project.id,
552 | },
553 | });
554 |
555 | expect(project.status).toEqual('ACTIVE_HEALTHY');
556 | });
557 |
558 | test('restore project in read-only mode throws an error', async () => {
559 | const { callTool } = await setup({ readOnly: true });
560 |
561 | const org = await createOrganization({
562 | name: 'My Org',
563 | plan: 'free',
564 | allowed_release_channels: ['ga'],
565 | });
566 |
567 | const project = await createProject({
568 | name: 'Project 1',
569 | region: 'us-east-1',
570 | organization_id: org.id,
571 | });
572 | project.status = 'INACTIVE';
573 |
574 | const result = callTool({
575 | name: 'restore_project',
576 | arguments: {
577 | project_id: project.id,
578 | },
579 | });
580 |
581 | await expect(result).rejects.toThrow(
582 | 'Cannot restore a project in read-only mode.'
583 | );
584 | });
585 |
586 | test('get project url', async () => {
587 | const { callTool } = await setup();
588 |
589 | const org = await createOrganization({
590 | name: 'My Org',
591 | plan: 'free',
592 | allowed_release_channels: ['ga'],
593 | });
594 |
595 | const project = await createProject({
596 | name: 'Project 1',
597 | region: 'us-east-1',
598 | organization_id: org.id,
599 | });
600 | project.status = 'ACTIVE_HEALTHY';
601 |
602 | const result = await callTool({
603 | name: 'get_project_url',
604 | arguments: {
605 | project_id: project.id,
606 | },
607 | });
608 | expect(result).toEqual(`https://${project.id}.supabase.co`);
609 | });
610 |
611 | test('get anon or publishable keys', async () => {
612 | const { callTool } = await setup();
613 | const org = await createOrganization({
614 | name: 'My Org',
615 | plan: 'free',
616 | allowed_release_channels: ['ga'],
617 | });
618 | const project = await createProject({
619 | name: 'Project 1',
620 | region: 'us-east-1',
621 | organization_id: org.id,
622 | });
623 | project.status = 'ACTIVE_HEALTHY';
624 |
625 | const result = await callTool({
626 | name: 'get_publishable_keys',
627 | arguments: {
628 | project_id: project.id,
629 | },
630 | });
631 |
632 | expect(result).toBeInstanceOf(Array);
633 | expect(result.length).toBe(2);
634 |
635 | // Check legacy anon key
636 | const anonKey = result.find((key: any) => key.name === 'anon');
637 | expect(anonKey).toBeDefined();
638 | expect(anonKey.api_key).toEqual('dummy-anon-key');
639 | expect(anonKey.type).toEqual('legacy');
640 | expect(anonKey.id).toEqual('anon-key-id');
641 | expect(anonKey.disabled).toBe(true);
642 |
643 | // Check publishable key
644 | const publishableKey = result.find(
645 | (key: any) => key.type === 'publishable'
646 | );
647 | expect(publishableKey).toBeDefined();
648 | expect(publishableKey.api_key).toEqual('sb_publishable_dummy_key_1');
649 | expect(publishableKey.type).toEqual('publishable');
650 | expect(publishableKey.description).toEqual('Main publishable key');
651 | });
652 |
653 | test('list storage buckets', async () => {
654 | const { callTool } = await setup({ features: ['storage'] });
655 |
656 | const org = await createOrganization({
657 | name: 'My Org',
658 | plan: 'free',
659 | allowed_release_channels: ['ga'],
660 | });
661 |
662 | const project = await createProject({
663 | name: 'Project 1',
664 | region: 'us-east-1',
665 | organization_id: org.id,
666 | });
667 | project.status = 'ACTIVE_HEALTHY';
668 |
669 | project.createStorageBucket('bucket1', true);
670 | project.createStorageBucket('bucket2', false);
671 |
672 | const result = await callTool({
673 | name: 'list_storage_buckets',
674 | arguments: {
675 | project_id: project.id,
676 | },
677 | });
678 |
679 | expect(Array.isArray(result)).toBe(true);
680 | expect(result.length).toBe(2);
681 | expect(result[0]).toEqual(
682 | expect.objectContaining({
683 | name: 'bucket1',
684 | public: true,
685 | created_at: expect.any(String),
686 | updated_at: expect.any(String),
687 | })
688 | );
689 | expect(result[1]).toEqual(
690 | expect.objectContaining({
691 | name: 'bucket2',
692 | public: false,
693 | created_at: expect.any(String),
694 | updated_at: expect.any(String),
695 | })
696 | );
697 | });
698 |
699 | test('get storage config', async () => {
700 | const { callTool } = await setup({ features: ['storage'] });
701 |
702 | const org = await createOrganization({
703 | name: 'My Org',
704 | plan: 'free',
705 | allowed_release_channels: ['ga'],
706 | });
707 |
708 | const project = await createProject({
709 | name: 'Project 1',
710 | region: 'us-east-1',
711 | organization_id: org.id,
712 | });
713 | project.status = 'ACTIVE_HEALTHY';
714 |
715 | const result = await callTool({
716 | name: 'get_storage_config',
717 | arguments: {
718 | project_id: project.id,
719 | },
720 | });
721 |
722 | expect(result).toEqual({
723 | fileSizeLimit: expect.any(Number),
724 | features: {
725 | imageTransformation: { enabled: expect.any(Boolean) },
726 | s3Protocol: { enabled: expect.any(Boolean) },
727 | },
728 | });
729 | });
730 |
731 | test('update storage config', async () => {
732 | const { callTool } = await setup({ features: ['storage'] });
733 |
734 | const org = await createOrganization({
735 | name: 'My Org',
736 | plan: 'free',
737 | allowed_release_channels: ['ga'],
738 | });
739 |
740 | const project = await createProject({
741 | name: 'Project 1',
742 | region: 'us-east-1',
743 | organization_id: org.id,
744 | });
745 | project.status = 'ACTIVE_HEALTHY';
746 |
747 | const config = {
748 | fileSizeLimit: 50,
749 | features: {
750 | imageTransformation: { enabled: true },
751 | s3Protocol: { enabled: false },
752 | },
753 | };
754 |
755 | const result = await callTool({
756 | name: 'update_storage_config',
757 | arguments: {
758 | project_id: project.id,
759 | config,
760 | },
761 | });
762 |
763 | expect(result).toEqual({ success: true });
764 | });
765 |
766 | test('update storage config in read-only mode throws an error', async () => {
767 | const { callTool } = await setup({ readOnly: true, features: ['storage'] });
768 |
769 | const org = await createOrganization({
770 | name: 'My Org',
771 | plan: 'free',
772 | allowed_release_channels: ['ga'],
773 | });
774 |
775 | const project = await createProject({
776 | name: 'Project 1',
777 | region: 'us-east-1',
778 | organization_id: org.id,
779 | });
780 | project.status = 'ACTIVE_HEALTHY';
781 |
782 | const config = {
783 | fileSizeLimit: 50,
784 | features: {
785 | imageTransformation: { enabled: true },
786 | s3Protocol: { enabled: false },
787 | },
788 | };
789 |
790 | const result = callTool({
791 | name: 'update_storage_config',
792 | arguments: {
793 | project_id: project.id,
794 | config,
795 | },
796 | });
797 |
798 | await expect(result).rejects.toThrow(
799 | 'Cannot update storage config in read-only mode.'
800 | );
801 | });
802 |
803 | test('execute sql', async () => {
804 | const { callTool } = await setup();
805 |
806 | const org = await createOrganization({
807 | name: 'My Org',
808 | plan: 'free',
809 | allowed_release_channels: ['ga'],
810 | });
811 |
812 | const project = await createProject({
813 | name: 'Project 1',
814 | region: 'us-east-1',
815 | organization_id: org.id,
816 | });
817 | project.status = 'ACTIVE_HEALTHY';
818 |
819 | const query = 'select 1+1 as sum';
820 |
821 | const result = await callTool({
822 | name: 'execute_sql',
823 | arguments: {
824 | project_id: project.id,
825 | query,
826 | },
827 | });
828 |
829 | expect(result).toContain('untrusted user data');
830 | expect(result).toMatch(/<untrusted-data-\w{8}-\w{4}-\w{4}-\w{4}-\w{12}>/);
831 | expect(result).toContain(JSON.stringify([{ sum: 2 }]));
832 | expect(result).toMatch(/<\/untrusted-data-\w{8}-\w{4}-\w{4}-\w{4}-\w{12}>/);
833 | });
834 |
835 | test('can run read queries in read-only mode', async () => {
836 | const { callTool } = await setup({ readOnly: true });
837 |
838 | const org = await createOrganization({
839 | name: 'My Org',
840 | plan: 'free',
841 | allowed_release_channels: ['ga'],
842 | });
843 |
844 | const project = await createProject({
845 | name: 'Project 1',
846 | region: 'us-east-1',
847 | organization_id: org.id,
848 | });
849 | project.status = 'ACTIVE_HEALTHY';
850 |
851 | const query = 'select 1+1 as sum';
852 |
853 | const result = await callTool({
854 | name: 'execute_sql',
855 | arguments: {
856 | project_id: project.id,
857 | query,
858 | },
859 | });
860 |
861 | expect(result).toContain('untrusted user data');
862 | expect(result).toMatch(/<untrusted-data-\w{8}-\w{4}-\w{4}-\w{4}-\w{12}>/);
863 | expect(result).toContain(JSON.stringify([{ sum: 2 }]));
864 | expect(result).toMatch(/<\/untrusted-data-\w{8}-\w{4}-\w{4}-\w{4}-\w{12}>/);
865 | });
866 |
867 | test('cannot run write queries in read-only mode', async () => {
868 | const { callTool } = await setup({ readOnly: true });
869 |
870 | const org = await createOrganization({
871 | name: 'My Org',
872 | plan: 'free',
873 | allowed_release_channels: ['ga'],
874 | });
875 |
876 | const project = await createProject({
877 | name: 'Project 1',
878 | region: 'us-east-1',
879 | organization_id: org.id,
880 | });
881 | project.status = 'ACTIVE_HEALTHY';
882 |
883 | const query =
884 | 'create table test (id integer generated always as identity primary key)';
885 |
886 | const resultPromise = callTool({
887 | name: 'execute_sql',
888 | arguments: {
889 | project_id: project.id,
890 | query,
891 | },
892 | });
893 |
894 | await expect(resultPromise).rejects.toThrow(
895 | 'permission denied for schema public'
896 | );
897 | });
898 |
899 | test('apply migration, list migrations, check tables', async () => {
900 | const { callTool } = await setup();
901 |
902 | const org = await createOrganization({
903 | name: 'My Org',
904 | plan: 'free',
905 | allowed_release_channels: ['ga'],
906 | });
907 |
908 | const project = await createProject({
909 | name: 'Project 1',
910 | region: 'us-east-1',
911 | organization_id: org.id,
912 | });
913 | project.status = 'ACTIVE_HEALTHY';
914 |
915 | const name = 'test_migration';
916 | const query =
917 | 'create table test (id integer generated always as identity primary key)';
918 |
919 | const result = await callTool({
920 | name: 'apply_migration',
921 | arguments: {
922 | project_id: project.id,
923 | name,
924 | query,
925 | },
926 | });
927 |
928 | expect(result).toEqual({ success: true });
929 |
930 | const listMigrationsResult = await callTool({
931 | name: 'list_migrations',
932 | arguments: {
933 | project_id: project.id,
934 | },
935 | });
936 |
937 | expect(listMigrationsResult).toEqual([
938 | {
939 | name,
940 | version: expect.stringMatching(/^\d{14}$/),
941 | },
942 | ]);
943 |
944 | const listTablesResult = await callTool({
945 | name: 'list_tables',
946 | arguments: {
947 | project_id: project.id,
948 | schemas: ['public'],
949 | },
950 | });
951 |
952 | expect(listTablesResult).toEqual([
953 | {
954 | schema: 'public',
955 | name: 'test',
956 | rls_enabled: false,
957 | rows: 0,
958 | columns: [
959 | {
960 | name: 'id',
961 | data_type: 'integer',
962 | format: 'int4',
963 | options: ['identity', 'updatable'],
964 | identity_generation: 'ALWAYS',
965 | },
966 | ],
967 | primary_keys: ['id'],
968 | },
969 | ]);
970 | });
971 |
972 | test('cannot apply migration in read-only mode', async () => {
973 | const { callTool } = await setup({ readOnly: true });
974 |
975 | const org = await createOrganization({
976 | name: 'My Org',
977 | plan: 'free',
978 | allowed_release_channels: ['ga'],
979 | });
980 |
981 | const project = await createProject({
982 | name: 'Project 1',
983 | region: 'us-east-1',
984 | organization_id: org.id,
985 | });
986 | project.status = 'ACTIVE_HEALTHY';
987 |
988 | const name = 'test-migration';
989 | const query =
990 | 'create table test (id integer generated always as identity primary key)';
991 |
992 | const resultPromise = callTool({
993 | name: 'apply_migration',
994 | arguments: {
995 | project_id: project.id,
996 | name,
997 | query,
998 | },
999 | });
1000 |
1001 | await expect(resultPromise).rejects.toThrow(
1002 | 'Cannot apply migration in read-only mode.'
1003 | );
1004 | });
1005 |
1006 | test('list tables only under a specific schema', async () => {
1007 | const { callTool } = await setup();
1008 |
1009 | const org = await createOrganization({
1010 | name: 'My Org',
1011 | plan: 'free',
1012 | allowed_release_channels: ['ga'],
1013 | });
1014 |
1015 | const project = await createProject({
1016 | name: 'Project 1',
1017 | region: 'us-east-1',
1018 | organization_id: org.id,
1019 | });
1020 | project.status = 'ACTIVE_HEALTHY';
1021 |
1022 | await project.db.exec('create schema test;');
1023 | await project.db.exec(
1024 | 'create table public.test_1 (id serial primary key);'
1025 | );
1026 | await project.db.exec('create table test.test_2 (id serial primary key);');
1027 |
1028 | const result = await callTool({
1029 | name: 'list_tables',
1030 | arguments: {
1031 | project_id: project.id,
1032 | schemas: ['test'],
1033 | },
1034 | });
1035 |
1036 | expect(result).toEqual(
1037 | expect.arrayContaining([expect.objectContaining({ name: 'test_2' })])
1038 | );
1039 | expect(result).not.toEqual(
1040 | expect.arrayContaining([expect.objectContaining({ name: 'test_1' })])
1041 | );
1042 | });
1043 |
1044 | test('listing all tables excludes system schemas', async () => {
1045 | const { callTool } = await setup();
1046 |
1047 | const org = await createOrganization({
1048 | name: 'My Org',
1049 | plan: 'free',
1050 | allowed_release_channels: ['ga'],
1051 | });
1052 |
1053 | const project = await createProject({
1054 | name: 'Project 1',
1055 | region: 'us-east-1',
1056 | organization_id: org.id,
1057 | });
1058 | project.status = 'ACTIVE_HEALTHY';
1059 |
1060 | const result = await callTool({
1061 | name: 'list_tables',
1062 | arguments: {
1063 | project_id: project.id,
1064 | },
1065 | });
1066 |
1067 | expect(result).not.toEqual(
1068 | expect.arrayContaining([
1069 | expect.objectContaining({ schema: 'pg_catalog' }),
1070 | ])
1071 | );
1072 |
1073 | expect(result).not.toEqual(
1074 | expect.arrayContaining([
1075 | expect.objectContaining({ schema: 'information_schema' }),
1076 | ])
1077 | );
1078 |
1079 | expect(result).not.toEqual(
1080 | expect.arrayContaining([expect.objectContaining({ schema: 'pg_toast' })])
1081 | );
1082 | });
1083 |
1084 | test('list_tables is not vulnerable to SQL injection via schemas parameter', async () => {
1085 | const { callTool } = await setup();
1086 |
1087 | const org = await createOrganization({
1088 | name: 'SQLi Org',
1089 | plan: 'free',
1090 | allowed_release_channels: ['ga'],
1091 | });
1092 |
1093 | const project = await createProject({
1094 | name: 'SQLi Project',
1095 | region: 'us-east-1',
1096 | organization_id: org.id,
1097 | });
1098 | project.status = 'ACTIVE_HEALTHY';
1099 |
1100 | // Attempt SQL injection via schemas parameter using payload from HackerOne report
1101 | // This payload attempts to break out of the string and inject a division by zero expression
1102 | // Reference: https://linear.app/supabase/issue/AI-139
1103 | const maliciousSchema = "public') OR (SELECT 1)=1/0--";
1104 |
1105 | // With proper parameterization, this should NOT throw "division by zero" error
1106 | // The literal schema name doesn't exist, so it should return empty array
1107 | // WITHOUT parameterization, this would throw: "division by zero" error
1108 | const maliciousResult = await callTool({
1109 | name: 'list_tables',
1110 | arguments: {
1111 | project_id: project.id,
1112 | schemas: [maliciousSchema],
1113 | },
1114 | });
1115 |
1116 | // Should return empty array without errors, proving the SQL injection was prevented
1117 | expect(maliciousResult).toEqual([]);
1118 | });
1119 |
1120 | test('list extensions', async () => {
1121 | const { callTool } = await setup();
1122 |
1123 | const org = await createOrganization({
1124 | name: 'My Org',
1125 | plan: 'free',
1126 | allowed_release_channels: ['ga'],
1127 | });
1128 |
1129 | const project = await createProject({
1130 | name: 'Project 1',
1131 | region: 'us-east-1',
1132 | organization_id: org.id,
1133 | });
1134 | project.status = 'ACTIVE_HEALTHY';
1135 |
1136 | const result = await callTool({
1137 | name: 'list_extensions',
1138 | arguments: {
1139 | project_id: project.id,
1140 | },
1141 | });
1142 |
1143 | expect(result).toMatchInlineSnapshot(`
1144 | [
1145 | {
1146 | "comment": "PL/pgSQL procedural language",
1147 | "default_version": "1.0",
1148 | "installed_version": "1.0",
1149 | "name": "plpgsql",
1150 | "schema": "pg_catalog",
1151 | },
1152 | ]
1153 | `);
1154 | });
1155 |
1156 | test('invalid access token', async () => {
1157 | const { callTool } = await setup({ accessToken: 'bad-token' });
1158 |
1159 | const listOrganizationsPromise = callTool({
1160 | name: 'list_organizations',
1161 | arguments: {},
1162 | });
1163 |
1164 | await expect(listOrganizationsPromise).rejects.toThrow('Unauthorized.');
1165 | });
1166 |
1167 | test('invalid sql for apply_migration', async () => {
1168 | const { callTool } = await setup();
1169 |
1170 | const org = await createOrganization({
1171 | name: 'My Org',
1172 | plan: 'free',
1173 | allowed_release_channels: ['ga'],
1174 | });
1175 |
1176 | const project = await createProject({
1177 | name: 'Project 1',
1178 | region: 'us-east-1',
1179 | organization_id: org.id,
1180 | });
1181 | project.status = 'ACTIVE_HEALTHY';
1182 |
1183 | const name = 'test-migration';
1184 | const query = 'invalid sql';
1185 |
1186 | const applyMigrationPromise = callTool({
1187 | name: 'apply_migration',
1188 | arguments: {
1189 | project_id: project.id,
1190 | name,
1191 | query,
1192 | },
1193 | });
1194 |
1195 | await expect(applyMigrationPromise).rejects.toThrow(
1196 | 'syntax error at or near "invalid"'
1197 | );
1198 | });
1199 |
1200 | test('invalid sql for execute_sql', async () => {
1201 | const { callTool } = await setup();
1202 |
1203 | const org = await createOrganization({
1204 | name: 'My Org',
1205 | plan: 'free',
1206 | allowed_release_channels: ['ga'],
1207 | });
1208 |
1209 | const project = await createProject({
1210 | name: 'Project 1',
1211 | region: 'us-east-1',
1212 | organization_id: org.id,
1213 | });
1214 | project.status = 'ACTIVE_HEALTHY';
1215 |
1216 | const query = 'invalid sql';
1217 |
1218 | const executeSqlPromise = callTool({
1219 | name: 'execute_sql',
1220 | arguments: {
1221 | project_id: project.id,
1222 | query,
1223 | },
1224 | });
1225 |
1226 | await expect(executeSqlPromise).rejects.toThrow(
1227 | 'syntax error at or near "invalid"'
1228 | );
1229 | });
1230 |
1231 | test('get logs for each service type', async () => {
1232 | const { callTool } = await setup();
1233 |
1234 | const org = await createOrganization({
1235 | name: 'My Org',
1236 | plan: 'free',
1237 | allowed_release_channels: ['ga'],
1238 | });
1239 |
1240 | const project = await createProject({
1241 | name: 'Project 1',
1242 | region: 'us-east-1',
1243 | organization_id: org.id,
1244 | });
1245 | project.status = 'ACTIVE_HEALTHY';
1246 |
1247 | const services = [
1248 | 'api',
1249 | 'branch-action',
1250 | 'postgres',
1251 | 'edge-function',
1252 | 'auth',
1253 | 'storage',
1254 | 'realtime',
1255 | ] as const;
1256 |
1257 | for (const service of services) {
1258 | const result = await callTool({
1259 | name: 'get_logs',
1260 | arguments: {
1261 | project_id: project.id,
1262 | service,
1263 | },
1264 | });
1265 |
1266 | expect(result).toEqual([]);
1267 | }
1268 | });
1269 |
1270 | test('get security advisors', async () => {
1271 | const { callTool } = await setup();
1272 |
1273 | const org = await createOrganization({
1274 | name: 'My Org',
1275 | plan: 'free',
1276 | allowed_release_channels: ['ga'],
1277 | });
1278 |
1279 | const project = await createProject({
1280 | name: 'Project 1',
1281 | region: 'us-east-1',
1282 | organization_id: org.id,
1283 | });
1284 | project.status = 'ACTIVE_HEALTHY';
1285 |
1286 | const result = await callTool({
1287 | name: 'get_advisors',
1288 | arguments: {
1289 | project_id: project.id,
1290 | type: 'security',
1291 | },
1292 | });
1293 |
1294 | expect(result).toEqual({ lints: [] });
1295 | });
1296 |
1297 | test('get performance advisors', async () => {
1298 | const { callTool } = await setup();
1299 |
1300 | const org = await createOrganization({
1301 | name: 'My Org',
1302 | plan: 'free',
1303 | allowed_release_channels: ['ga'],
1304 | });
1305 |
1306 | const project = await createProject({
1307 | name: 'Project 1',
1308 | region: 'us-east-1',
1309 | organization_id: org.id,
1310 | });
1311 | project.status = 'ACTIVE_HEALTHY';
1312 |
1313 | const result = await callTool({
1314 | name: 'get_advisors',
1315 | arguments: {
1316 | project_id: project.id,
1317 | type: 'performance',
1318 | },
1319 | });
1320 |
1321 | expect(result).toEqual({ lints: [] });
1322 | });
1323 |
1324 | test('get logs for invalid service type', async () => {
1325 | const { callTool } = await setup();
1326 |
1327 | const org = await createOrganization({
1328 | name: 'My Org',
1329 | plan: 'free',
1330 | allowed_release_channels: ['ga'],
1331 | });
1332 |
1333 | const project = await createProject({
1334 | name: 'Project 1',
1335 | region: 'us-east-1',
1336 | organization_id: org.id,
1337 | });
1338 | project.status = 'ACTIVE_HEALTHY';
1339 |
1340 | const invalidService = 'invalid-service';
1341 | const getLogsPromise = callTool({
1342 | name: 'get_logs',
1343 | arguments: {
1344 | project_id: project.id,
1345 | service: invalidService,
1346 | },
1347 | });
1348 | await expect(getLogsPromise).rejects.toThrow('Invalid enum value');
1349 | });
1350 |
1351 | test('list edge functions', async () => {
1352 | const { callTool } = await setup();
1353 |
1354 | const org = await createOrganization({
1355 | name: 'My Org',
1356 | plan: 'free',
1357 | allowed_release_channels: ['ga'],
1358 | });
1359 |
1360 | const project = await createProject({
1361 | name: 'Project 1',
1362 | region: 'us-east-1',
1363 | organization_id: org.id,
1364 | });
1365 | project.status = 'ACTIVE_HEALTHY';
1366 |
1367 | const indexContent = codeBlock`
1368 | Deno.serve(async (req: Request) => {
1369 | return new Response('Hello world!', { headers: { 'Content-Type': 'text/plain' } })
1370 | });
1371 | `;
1372 |
1373 | const edgeFunction = await project.deployEdgeFunction(
1374 | {
1375 | name: 'hello-world',
1376 | entrypoint_path: 'index.ts',
1377 | },
1378 | [
1379 | new File([indexContent], 'index.ts', {
1380 | type: 'application/typescript',
1381 | }),
1382 | ]
1383 | );
1384 |
1385 | const result = await callTool({
1386 | name: 'list_edge_functions',
1387 | arguments: {
1388 | project_id: project.id,
1389 | },
1390 | });
1391 |
1392 | expect(result).toEqual([
1393 | {
1394 | id: edgeFunction.id,
1395 | slug: edgeFunction.slug,
1396 | version: edgeFunction.version,
1397 | name: edgeFunction.name,
1398 | status: edgeFunction.status,
1399 | entrypoint_path: 'index.ts',
1400 | import_map_path: undefined,
1401 | import_map: false,
1402 | verify_jwt: true,
1403 | created_at: expect.stringMatching(
1404 | /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/
1405 | ),
1406 | updated_at: expect.stringMatching(
1407 | /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/
1408 | ),
1409 | },
1410 | ]);
1411 | });
1412 |
1413 | test('get edge function', async () => {
1414 | const { callTool } = await setup();
1415 |
1416 | const org = await createOrganization({
1417 | name: 'My Org',
1418 | plan: 'free',
1419 | allowed_release_channels: ['ga'],
1420 | });
1421 |
1422 | const project = await createProject({
1423 | name: 'Project 1',
1424 | region: 'us-east-1',
1425 | organization_id: org.id,
1426 | });
1427 | project.status = 'ACTIVE_HEALTHY';
1428 |
1429 | const indexContent = codeBlock`
1430 | Deno.serve(async (req: Request) => {
1431 | return new Response('Hello world!', { headers: { 'Content-Type': 'text/plain' } })
1432 | });
1433 | `;
1434 |
1435 | const edgeFunction = await project.deployEdgeFunction(
1436 | {
1437 | name: 'hello-world',
1438 | entrypoint_path: 'index.ts',
1439 | },
1440 | [
1441 | new File([indexContent], 'index.ts', {
1442 | type: 'application/typescript',
1443 | }),
1444 | ]
1445 | );
1446 |
1447 | const result = await callTool({
1448 | name: 'get_edge_function',
1449 | arguments: {
1450 | project_id: project.id,
1451 | function_slug: edgeFunction.slug,
1452 | },
1453 | });
1454 |
1455 | expect(result).toEqual({
1456 | id: edgeFunction.id,
1457 | slug: edgeFunction.slug,
1458 | version: edgeFunction.version,
1459 | name: edgeFunction.name,
1460 | status: edgeFunction.status,
1461 | entrypoint_path: 'index.ts',
1462 | import_map_path: undefined,
1463 | import_map: false,
1464 | verify_jwt: true,
1465 | created_at: expect.stringMatching(
1466 | /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/
1467 | ),
1468 | updated_at: expect.stringMatching(
1469 | /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/
1470 | ),
1471 | files: [
1472 | {
1473 | name: 'index.ts',
1474 | content: indexContent,
1475 | },
1476 | ],
1477 | });
1478 | });
1479 |
1480 | test('deploy new edge function', async () => {
1481 | const { callTool } = await setup();
1482 |
1483 | const org = await createOrganization({
1484 | name: 'My Org',
1485 | plan: 'free',
1486 | allowed_release_channels: ['ga'],
1487 | });
1488 |
1489 | const project = await createProject({
1490 | name: 'Project 1',
1491 | region: 'us-east-1',
1492 | organization_id: org.id,
1493 | });
1494 | project.status = 'ACTIVE_HEALTHY';
1495 |
1496 | const functionName = 'hello-world';
1497 | const functionCode = 'console.log("Hello, world!");';
1498 |
1499 | const result = await callTool({
1500 | name: 'deploy_edge_function',
1501 | arguments: {
1502 | project_id: project.id,
1503 | name: functionName,
1504 | files: [
1505 | {
1506 | name: 'index.ts',
1507 | content: functionCode,
1508 | },
1509 | ],
1510 | },
1511 | });
1512 |
1513 | expect(result).toEqual({
1514 | id: expect.stringMatching(/^.+$/),
1515 | slug: functionName,
1516 | version: 1,
1517 | name: functionName,
1518 | status: 'ACTIVE',
1519 | entrypoint_path: expect.stringMatching(/index\.ts$/),
1520 | import_map_path: undefined,
1521 | import_map: false,
1522 | verify_jwt: true,
1523 | created_at: expect.stringMatching(
1524 | /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/
1525 | ),
1526 | updated_at: expect.stringMatching(
1527 | /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/
1528 | ),
1529 | });
1530 | });
1531 |
1532 | test('deploy edge function in read-only mode throws an error', async () => {
1533 | const { callTool } = await setup({ readOnly: true });
1534 |
1535 | const org = await createOrganization({
1536 | name: 'test-org',
1537 | plan: 'free',
1538 | allowed_release_channels: ['ga'],
1539 | });
1540 |
1541 | const project = await createProject({
1542 | name: 'test-app',
1543 | region: 'us-east-1',
1544 | organization_id: org.id,
1545 | });
1546 | project.status = 'ACTIVE_HEALTHY';
1547 |
1548 | const functionName = 'hello-world';
1549 | const functionCode = 'console.log("Hello, world!");';
1550 |
1551 | const result = callTool({
1552 | name: 'deploy_edge_function',
1553 | arguments: {
1554 | project_id: project.id,
1555 | name: functionName,
1556 | files: [
1557 | {
1558 | name: 'index.ts',
1559 | content: functionCode,
1560 | },
1561 | ],
1562 | },
1563 | });
1564 |
1565 | await expect(result).rejects.toThrow(
1566 | 'Cannot deploy an edge function in read-only mode.'
1567 | );
1568 | });
1569 |
1570 | test('deploy new version of existing edge function', async () => {
1571 | const { callTool } = await setup();
1572 | const org = await createOrganization({
1573 | name: 'My Org',
1574 | plan: 'free',
1575 | allowed_release_channels: ['ga'],
1576 | });
1577 |
1578 | const project = await createProject({
1579 | name: 'Project 1',
1580 | region: 'us-east-1',
1581 | organization_id: org.id,
1582 | });
1583 | project.status = 'ACTIVE_HEALTHY';
1584 |
1585 | const functionName = 'hello-world';
1586 |
1587 | const edgeFunction = await project.deployEdgeFunction(
1588 | {
1589 | name: functionName,
1590 | entrypoint_path: 'index.ts',
1591 | },
1592 | [
1593 | new File(['console.log("Hello, world!");'], 'index.ts', {
1594 | type: 'application/typescript',
1595 | }),
1596 | ]
1597 | );
1598 |
1599 | expect(edgeFunction.version).toEqual(1);
1600 |
1601 | const originalCreatedAt = edgeFunction.created_at.getTime();
1602 | const originalUpdatedAt = edgeFunction.updated_at.getTime();
1603 |
1604 | const result = await callTool({
1605 | name: 'deploy_edge_function',
1606 | arguments: {
1607 | project_id: project.id,
1608 | name: functionName,
1609 | files: [
1610 | {
1611 | name: 'index.ts',
1612 | content: 'console.log("Hello, world! v2");',
1613 | },
1614 | ],
1615 | },
1616 | });
1617 |
1618 | expect(result).toEqual({
1619 | id: edgeFunction.id,
1620 | slug: functionName,
1621 | version: 2,
1622 | name: functionName,
1623 | status: 'ACTIVE',
1624 | entrypoint_path: expect.stringMatching(/index\.ts$/),
1625 | import_map_path: undefined,
1626 | import_map: false,
1627 | verify_jwt: true,
1628 | created_at: expect.stringMatching(
1629 | /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/
1630 | ),
1631 | updated_at: expect.stringMatching(
1632 | /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/
1633 | ),
1634 | });
1635 |
1636 | expect(new Date(result.created_at).getTime()).toEqual(originalCreatedAt);
1637 | expect(new Date(result.updated_at).getTime()).toBeGreaterThan(
1638 | originalUpdatedAt
1639 | );
1640 | });
1641 |
1642 | test('custom edge function import map', async () => {
1643 | const { callTool } = await setup();
1644 |
1645 | const org = await createOrganization({
1646 | name: 'My Org',
1647 | plan: 'free',
1648 | allowed_release_channels: ['ga'],
1649 | });
1650 |
1651 | const project = await createProject({
1652 | name: 'Project 1',
1653 | region: 'us-east-1',
1654 | organization_id: org.id,
1655 | });
1656 |
1657 | const functionName = 'hello-world';
1658 | const functionCode = 'console.log("Hello, world!");';
1659 |
1660 | const result = await callTool({
1661 | name: 'deploy_edge_function',
1662 | arguments: {
1663 | project_id: project.id,
1664 | name: functionName,
1665 | import_map_path: 'custom-map.json',
1666 | files: [
1667 | {
1668 | name: 'index.ts',
1669 | content: functionCode,
1670 | },
1671 | {
1672 | name: 'custom-map.json',
1673 | content: '{}',
1674 | },
1675 | ],
1676 | },
1677 | });
1678 |
1679 | expect(result.import_map).toBe(true);
1680 | expect(result.import_map_path).toMatch(/custom-map\.json$/);
1681 | });
1682 |
1683 | test('default edge function import map to deno.json', async () => {
1684 | const { callTool } = await setup();
1685 |
1686 | const org = await createOrganization({
1687 | name: 'My Org',
1688 | plan: 'free',
1689 | allowed_release_channels: ['ga'],
1690 | });
1691 |
1692 | const project = await createProject({
1693 | name: 'Project 1',
1694 | region: 'us-east-1',
1695 | organization_id: org.id,
1696 | });
1697 |
1698 | const functionName = 'hello-world';
1699 | const functionCode = 'console.log("Hello, world!");';
1700 |
1701 | const result = await callTool({
1702 | name: 'deploy_edge_function',
1703 | arguments: {
1704 | project_id: project.id,
1705 | name: functionName,
1706 | files: [
1707 | {
1708 | name: 'index.ts',
1709 | content: functionCode,
1710 | },
1711 | {
1712 | name: 'deno.json',
1713 | content: '{}',
1714 | },
1715 | ],
1716 | },
1717 | });
1718 |
1719 | expect(result.import_map).toBe(true);
1720 | expect(result.import_map_path).toMatch(/deno\.json$/);
1721 | });
1722 |
1723 | test('default edge function import map to import_map.json', async () => {
1724 | const { callTool } = await setup();
1725 |
1726 | const org = await createOrganization({
1727 | name: 'My Org',
1728 | plan: 'free',
1729 | allowed_release_channels: ['ga'],
1730 | });
1731 |
1732 | const project = await createProject({
1733 | name: 'Project 1',
1734 | region: 'us-east-1',
1735 | organization_id: org.id,
1736 | });
1737 |
1738 | const functionName = 'hello-world';
1739 | const functionCode = 'console.log("Hello, world!");';
1740 |
1741 | const result = await callTool({
1742 | name: 'deploy_edge_function',
1743 | arguments: {
1744 | project_id: project.id,
1745 | name: functionName,
1746 | files: [
1747 | {
1748 | name: 'index.ts',
1749 | content: functionCode,
1750 | },
1751 | {
1752 | name: 'import_map.json',
1753 | content: '{}',
1754 | },
1755 | ],
1756 | },
1757 | });
1758 |
1759 | expect(result.import_map).toBe(true);
1760 | expect(result.import_map_path).toMatch(/import_map\.json$/);
1761 | });
1762 |
1763 | test('updating edge function with missing import_map_path defaults to previous value', async () => {
1764 | const { callTool } = await setup();
1765 | const org = await createOrganization({
1766 | name: 'My Org',
1767 | plan: 'free',
1768 | allowed_release_channels: ['ga'],
1769 | });
1770 |
1771 | const project = await createProject({
1772 | name: 'Project 1',
1773 | region: 'us-east-1',
1774 | organization_id: org.id,
1775 | });
1776 | project.status = 'ACTIVE_HEALTHY';
1777 |
1778 | const functionName = 'hello-world';
1779 |
1780 | const edgeFunction = await project.deployEdgeFunction(
1781 | {
1782 | name: functionName,
1783 | entrypoint_path: 'index.ts',
1784 | import_map_path: 'custom-map.json',
1785 | },
1786 | [
1787 | new File(['console.log("Hello, world!");'], 'index.ts', {
1788 | type: 'application/typescript',
1789 | }),
1790 | new File(['{}'], 'custom-map.json', {
1791 | type: 'application/json',
1792 | }),
1793 | ]
1794 | );
1795 |
1796 | const result = await callTool({
1797 | name: 'deploy_edge_function',
1798 | arguments: {
1799 | project_id: project.id,
1800 | name: functionName,
1801 | files: [
1802 | {
1803 | name: 'index.ts',
1804 | content: 'console.log("Hello, world! v2");',
1805 | },
1806 | {
1807 | name: 'custom-map.json',
1808 | content: '{}',
1809 | },
1810 | ],
1811 | },
1812 | });
1813 |
1814 | expect(result.import_map).toBe(true);
1815 | expect(result.import_map_path).toMatch(/custom-map\.json$/);
1816 | });
1817 |
1818 | test('create branch', async () => {
1819 | const { callTool } = await setup({
1820 | features: ['account', 'branching'],
1821 | });
1822 |
1823 | const org = await createOrganization({
1824 | name: 'My Org',
1825 | plan: 'free',
1826 | allowed_release_channels: ['ga'],
1827 | });
1828 |
1829 | const project = await createProject({
1830 | name: 'Project 1',
1831 | region: 'us-east-1',
1832 | organization_id: org.id,
1833 | });
1834 | project.status = 'ACTIVE_HEALTHY';
1835 |
1836 | const confirm_cost_id = await callTool({
1837 | name: 'confirm_cost',
1838 | arguments: {
1839 | type: 'branch',
1840 | recurrence: 'hourly',
1841 | amount: BRANCH_COST_HOURLY,
1842 | },
1843 | });
1844 |
1845 | const branchName = 'test-branch';
1846 | const result = await callTool({
1847 | name: 'create_branch',
1848 | arguments: {
1849 | project_id: project.id,
1850 | name: branchName,
1851 | confirm_cost_id,
1852 | },
1853 | });
1854 |
1855 | expect(result).toEqual({
1856 | id: expect.stringMatching(/^.+$/),
1857 | name: branchName,
1858 | project_ref: expect.stringMatching(/^.+$/),
1859 | parent_project_ref: project.id,
1860 | is_default: false,
1861 | persistent: false,
1862 | status: 'CREATING_PROJECT',
1863 | created_at: expect.stringMatching(
1864 | /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/
1865 | ),
1866 | updated_at: expect.stringMatching(
1867 | /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/
1868 | ),
1869 | });
1870 | });
1871 |
1872 | test('create branch in read-only mode throws an error', async () => {
1873 | const { callTool } = await setup({
1874 | readOnly: true,
1875 | features: ['account', 'branching'],
1876 | });
1877 |
1878 | const org = await createOrganization({
1879 | name: 'My Org',
1880 | plan: 'free',
1881 | allowed_release_channels: ['ga'],
1882 | });
1883 |
1884 | const project = await createProject({
1885 | name: 'Project 1',
1886 | region: 'us-east-1',
1887 | organization_id: org.id,
1888 | });
1889 | project.status = 'ACTIVE_HEALTHY';
1890 |
1891 | const confirm_cost_id = await callTool({
1892 | name: 'confirm_cost',
1893 | arguments: {
1894 | type: 'branch',
1895 | recurrence: 'hourly',
1896 | amount: BRANCH_COST_HOURLY,
1897 | },
1898 | });
1899 |
1900 | const branchName = 'test-branch';
1901 | const result = callTool({
1902 | name: 'create_branch',
1903 | arguments: {
1904 | project_id: project.id,
1905 | name: branchName,
1906 | confirm_cost_id,
1907 | },
1908 | });
1909 |
1910 | await expect(result).rejects.toThrow(
1911 | 'Cannot create a branch in read-only mode.'
1912 | );
1913 | });
1914 |
1915 | test('create branch without cost confirmation fails', async () => {
1916 | const { callTool } = await setup({ features: ['branching'] });
1917 |
1918 | const org = await createOrganization({
1919 | name: 'Paid Org',
1920 | plan: 'pro',
1921 | allowed_release_channels: ['ga'],
1922 | });
1923 |
1924 | const project = await createProject({
1925 | name: 'Project 1',
1926 | region: 'us-east-1',
1927 | organization_id: org.id,
1928 | });
1929 | project.status = 'ACTIVE_HEALTHY';
1930 |
1931 | const branchName = 'test-branch';
1932 | const createBranchPromise = callTool({
1933 | name: 'create_branch',
1934 | arguments: {
1935 | project_id: project.id,
1936 | name: branchName,
1937 | },
1938 | });
1939 |
1940 | await expect(createBranchPromise).rejects.toThrow(
1941 | 'User must confirm understanding of costs before creating a branch.'
1942 | );
1943 | });
1944 |
1945 | test('delete branch', async () => {
1946 | const { callTool } = await setup({
1947 | features: ['account', 'branching'],
1948 | });
1949 |
1950 | const org = await createOrganization({
1951 | name: 'My Org',
1952 | plan: 'free',
1953 | allowed_release_channels: ['ga'],
1954 | });
1955 |
1956 | const project = await createProject({
1957 | name: 'Project 1',
1958 | region: 'us-east-1',
1959 | organization_id: org.id,
1960 | });
1961 | project.status = 'ACTIVE_HEALTHY';
1962 |
1963 | const confirm_cost_id = await callTool({
1964 | name: 'confirm_cost',
1965 | arguments: {
1966 | type: 'branch',
1967 | recurrence: 'hourly',
1968 | amount: BRANCH_COST_HOURLY,
1969 | },
1970 | });
1971 |
1972 | const branch = await callTool({
1973 | name: 'create_branch',
1974 | arguments: {
1975 | project_id: project.id,
1976 | name: 'test-branch',
1977 | confirm_cost_id,
1978 | },
1979 | });
1980 |
1981 | const listBranchesResult = await callTool({
1982 | name: 'list_branches',
1983 | arguments: {
1984 | project_id: project.id,
1985 | },
1986 | });
1987 |
1988 | expect(listBranchesResult).toContainEqual(
1989 | expect.objectContaining({ id: branch.id })
1990 | );
1991 | expect(listBranchesResult).toHaveLength(2);
1992 |
1993 | await callTool({
1994 | name: 'delete_branch',
1995 | arguments: {
1996 | branch_id: branch.id,
1997 | },
1998 | });
1999 |
2000 | const listBranchesResultAfterDelete = await callTool({
2001 | name: 'list_branches',
2002 | arguments: {
2003 | project_id: project.id,
2004 | },
2005 | });
2006 |
2007 | expect(listBranchesResultAfterDelete).not.toContainEqual(
2008 | expect.objectContaining({ id: branch.id })
2009 | );
2010 | expect(listBranchesResultAfterDelete).toHaveLength(1);
2011 |
2012 | const mainBranch = listBranchesResultAfterDelete[0];
2013 |
2014 | const deleteBranchPromise = callTool({
2015 | name: 'delete_branch',
2016 | arguments: {
2017 | branch_id: mainBranch.id,
2018 | },
2019 | });
2020 |
2021 | await expect(deleteBranchPromise).rejects.toThrow(
2022 | 'Cannot delete the default branch.'
2023 | );
2024 | });
2025 |
2026 | test('delete branch in read-only mode throws an error', async () => {
2027 | const { callTool } = await setup({
2028 | readOnly: true,
2029 | features: ['account', 'branching'],
2030 | });
2031 |
2032 | const org = await createOrganization({
2033 | name: 'My Org',
2034 | plan: 'free',
2035 | allowed_release_channels: ['ga'],
2036 | });
2037 |
2038 | const project = await createProject({
2039 | name: 'Project 1',
2040 | region: 'us-east-1',
2041 | organization_id: org.id,
2042 | });
2043 | project.status = 'ACTIVE_HEALTHY';
2044 |
2045 | const branch = await createBranch({
2046 | name: 'test-branch',
2047 | parent_project_ref: project.id,
2048 | });
2049 |
2050 | const listBranchesResult = await callTool({
2051 | name: 'list_branches',
2052 | arguments: {
2053 | project_id: project.id,
2054 | },
2055 | });
2056 |
2057 | expect(listBranchesResult).toHaveLength(1);
2058 | expect(listBranchesResult).toContainEqual(
2059 | expect.objectContaining({ id: branch.id })
2060 | );
2061 |
2062 | const result = callTool({
2063 | name: 'delete_branch',
2064 | arguments: {
2065 | branch_id: branch.id,
2066 | },
2067 | });
2068 |
2069 | await expect(result).rejects.toThrow(
2070 | 'Cannot delete a branch in read-only mode.'
2071 | );
2072 | });
2073 |
2074 | test('list branches', async () => {
2075 | const { callTool } = await setup({ features: ['branching'] });
2076 |
2077 | const org = await createOrganization({
2078 | name: 'My Org',
2079 | plan: 'free',
2080 | allowed_release_channels: ['ga'],
2081 | });
2082 |
2083 | const project = await createProject({
2084 | name: 'Project 1',
2085 | region: 'us-east-1',
2086 | organization_id: org.id,
2087 | });
2088 | project.status = 'ACTIVE_HEALTHY';
2089 |
2090 | const result = await callTool({
2091 | name: 'list_branches',
2092 | arguments: {
2093 | project_id: project.id,
2094 | },
2095 | });
2096 |
2097 | expect(result).toStrictEqual([]);
2098 | });
2099 |
2100 | test('merge branch', async () => {
2101 | const { callTool } = await setup({
2102 | features: ['account', 'branching', 'database'],
2103 | });
2104 |
2105 | const org = await createOrganization({
2106 | name: 'My Org',
2107 | plan: 'free',
2108 | allowed_release_channels: ['ga'],
2109 | });
2110 |
2111 | const project = await createProject({
2112 | name: 'Project 1',
2113 | region: 'us-east-1',
2114 | organization_id: org.id,
2115 | });
2116 | project.status = 'ACTIVE_HEALTHY';
2117 |
2118 | const confirm_cost_id = await callTool({
2119 | name: 'confirm_cost',
2120 | arguments: {
2121 | type: 'branch',
2122 | recurrence: 'hourly',
2123 | amount: BRANCH_COST_HOURLY,
2124 | },
2125 | });
2126 |
2127 | const branch = await callTool({
2128 | name: 'create_branch',
2129 | arguments: {
2130 | project_id: project.id,
2131 | name: 'test-branch',
2132 | confirm_cost_id,
2133 | },
2134 | });
2135 |
2136 | const migrationName = 'sample_migration';
2137 | const migrationQuery =
2138 | 'create table sample (id integer generated always as identity primary key)';
2139 | await callTool({
2140 | name: 'apply_migration',
2141 | arguments: {
2142 | project_id: branch.project_ref,
2143 | name: migrationName,
2144 | query: migrationQuery,
2145 | },
2146 | });
2147 |
2148 | await callTool({
2149 | name: 'merge_branch',
2150 | arguments: {
2151 | branch_id: branch.id,
2152 | },
2153 | });
2154 |
2155 | // Check that the migration was applied to the parent project
2156 | const listResult = await callTool({
2157 | name: 'list_migrations',
2158 | arguments: {
2159 | project_id: project.id,
2160 | },
2161 | });
2162 |
2163 | expect(listResult).toContainEqual({
2164 | name: migrationName,
2165 | version: expect.stringMatching(/^\d{14}$/),
2166 | });
2167 | });
2168 |
2169 | test('merge branch in read-only mode throws an error', async () => {
2170 | const { callTool } = await setup({
2171 | readOnly: true,
2172 | features: ['account', 'branching', 'database'],
2173 | });
2174 |
2175 | const org = await createOrganization({
2176 | name: 'My Org',
2177 | plan: 'free',
2178 | allowed_release_channels: ['ga'],
2179 | });
2180 |
2181 | const project = await createProject({
2182 | name: 'Project 1',
2183 | region: 'us-east-1',
2184 | organization_id: org.id,
2185 | });
2186 | project.status = 'ACTIVE_HEALTHY';
2187 |
2188 | const branch = await createBranch({
2189 | name: 'test-branch',
2190 | parent_project_ref: project.id,
2191 | });
2192 |
2193 | const result = callTool({
2194 | name: 'merge_branch',
2195 | arguments: {
2196 | branch_id: branch.id,
2197 | },
2198 | });
2199 |
2200 | await expect(result).rejects.toThrow(
2201 | 'Cannot merge a branch in read-only mode.'
2202 | );
2203 | });
2204 |
2205 | test('reset branch', async () => {
2206 | const { callTool } = await setup({
2207 | features: ['account', 'branching', 'database'],
2208 | });
2209 |
2210 | const org = await createOrganization({
2211 | name: 'My Org',
2212 | plan: 'free',
2213 | allowed_release_channels: ['ga'],
2214 | });
2215 |
2216 | const project = await createProject({
2217 | name: 'Project 1',
2218 | region: 'us-east-1',
2219 | organization_id: org.id,
2220 | });
2221 | project.status = 'ACTIVE_HEALTHY';
2222 |
2223 | const confirm_cost_id = await callTool({
2224 | name: 'confirm_cost',
2225 | arguments: {
2226 | type: 'branch',
2227 | recurrence: 'hourly',
2228 | amount: BRANCH_COST_HOURLY,
2229 | },
2230 | });
2231 |
2232 | const branch = await callTool({
2233 | name: 'create_branch',
2234 | arguments: {
2235 | project_id: project.id,
2236 | name: 'test-branch',
2237 | confirm_cost_id,
2238 | },
2239 | });
2240 |
2241 | // Create a table via execute_sql so that it is untracked
2242 | const query =
2243 | 'create table test_untracked (id integer generated always as identity primary key)';
2244 | await callTool({
2245 | name: 'execute_sql',
2246 | arguments: {
2247 | project_id: branch.project_ref,
2248 | query,
2249 | },
2250 | });
2251 |
2252 | const firstTablesResult = await callTool({
2253 | name: 'list_tables',
2254 | arguments: {
2255 | project_id: branch.project_ref,
2256 | },
2257 | });
2258 |
2259 | expect(firstTablesResult).toContainEqual(
2260 | expect.objectContaining({ name: 'test_untracked' })
2261 | );
2262 |
2263 | await callTool({
2264 | name: 'reset_branch',
2265 | arguments: {
2266 | branch_id: branch.id,
2267 | },
2268 | });
2269 |
2270 | const secondTablesResult = await callTool({
2271 | name: 'list_tables',
2272 | arguments: {
2273 | project_id: branch.project_ref,
2274 | },
2275 | });
2276 |
2277 | // Expect the untracked table to be removed after reset
2278 | expect(secondTablesResult).not.toContainEqual(
2279 | expect.objectContaining({ name: 'test_untracked' })
2280 | );
2281 | });
2282 |
2283 | test('reset branch in read-only mode throws an error', async () => {
2284 | const { callTool } = await setup({
2285 | readOnly: true,
2286 | features: ['account', 'branching', 'database'],
2287 | });
2288 |
2289 | const org = await createOrganization({
2290 | name: 'My Org',
2291 | plan: 'free',
2292 | allowed_release_channels: ['ga'],
2293 | });
2294 |
2295 | const project = await createProject({
2296 | name: 'Project 1',
2297 | region: 'us-east-1',
2298 | organization_id: org.id,
2299 | });
2300 | project.status = 'ACTIVE_HEALTHY';
2301 |
2302 | const branch = await createBranch({
2303 | name: 'test-branch',
2304 | parent_project_ref: project.id,
2305 | });
2306 |
2307 | const result = callTool({
2308 | name: 'reset_branch',
2309 | arguments: {
2310 | branch_id: branch.id,
2311 | },
2312 | });
2313 |
2314 | await expect(result).rejects.toThrow(
2315 | 'Cannot reset a branch in read-only mode.'
2316 | );
2317 | });
2318 |
2319 | test('revert migrations', async () => {
2320 | const { callTool } = await setup({
2321 | features: ['account', 'branching', 'database'],
2322 | });
2323 |
2324 | const org = await createOrganization({
2325 | name: 'My Org',
2326 | plan: 'free',
2327 | allowed_release_channels: ['ga'],
2328 | });
2329 |
2330 | const project = await createProject({
2331 | name: 'Project 1',
2332 | region: 'us-east-1',
2333 | organization_id: org.id,
2334 | });
2335 | project.status = 'ACTIVE_HEALTHY';
2336 |
2337 | const confirm_cost_id = await callTool({
2338 | name: 'confirm_cost',
2339 | arguments: {
2340 | type: 'branch',
2341 | recurrence: 'hourly',
2342 | amount: BRANCH_COST_HOURLY,
2343 | },
2344 | });
2345 |
2346 | const branch = await callTool({
2347 | name: 'create_branch',
2348 | arguments: {
2349 | project_id: project.id,
2350 | name: 'test-branch',
2351 | confirm_cost_id,
2352 | },
2353 | });
2354 |
2355 | const migrationName = 'sample_migration';
2356 | const migrationQuery =
2357 | 'create table sample (id integer generated always as identity primary key)';
2358 | await callTool({
2359 | name: 'apply_migration',
2360 | arguments: {
2361 | project_id: branch.project_ref,
2362 | name: migrationName,
2363 | query: migrationQuery,
2364 | },
2365 | });
2366 |
2367 | // Check that migration has been applied to the branch
2368 | const firstListResult = await callTool({
2369 | name: 'list_migrations',
2370 | arguments: {
2371 | project_id: branch.project_ref,
2372 | },
2373 | });
2374 |
2375 | expect(firstListResult).toContainEqual({
2376 | name: migrationName,
2377 | version: expect.stringMatching(/^\d{14}$/),
2378 | });
2379 |
2380 | const firstTablesResult = await callTool({
2381 | name: 'list_tables',
2382 | arguments: {
2383 | project_id: branch.project_ref,
2384 | },
2385 | });
2386 |
2387 | expect(firstTablesResult).toContainEqual(
2388 | expect.objectContaining({ name: 'sample' })
2389 | );
2390 |
2391 | await callTool({
2392 | name: 'reset_branch',
2393 | arguments: {
2394 | branch_id: branch.id,
2395 | migration_version: '0',
2396 | },
2397 | });
2398 |
2399 | // Check that all migrations have been reverted
2400 | const secondListResult = await callTool({
2401 | name: 'list_migrations',
2402 | arguments: {
2403 | project_id: branch.project_ref,
2404 | },
2405 | });
2406 |
2407 | expect(secondListResult).toStrictEqual([]);
2408 |
2409 | const secondTablesResult = await callTool({
2410 | name: 'list_tables',
2411 | arguments: {
2412 | project_id: branch.project_ref,
2413 | },
2414 | });
2415 |
2416 | expect(secondTablesResult).not.toContainEqual(
2417 | expect.objectContaining({ name: 'sample' })
2418 | );
2419 | });
2420 |
2421 | test('rebase branch', async () => {
2422 | const { callTool } = await setup({
2423 | features: ['account', 'branching', 'database'],
2424 | });
2425 |
2426 | const org = await createOrganization({
2427 | name: 'My Org',
2428 | plan: 'free',
2429 | allowed_release_channels: ['ga'],
2430 | });
2431 |
2432 | const project = await createProject({
2433 | name: 'Project 1',
2434 | region: 'us-east-1',
2435 | organization_id: org.id,
2436 | });
2437 | project.status = 'ACTIVE_HEALTHY';
2438 |
2439 | const confirm_cost_id = await callTool({
2440 | name: 'confirm_cost',
2441 | arguments: {
2442 | type: 'branch',
2443 | recurrence: 'hourly',
2444 | amount: BRANCH_COST_HOURLY,
2445 | },
2446 | });
2447 |
2448 | const branch = await callTool({
2449 | name: 'create_branch',
2450 | arguments: {
2451 | project_id: project.id,
2452 | name: 'test-branch',
2453 | confirm_cost_id,
2454 | },
2455 | });
2456 |
2457 | const migrationName = 'sample_migration';
2458 | const migrationQuery =
2459 | 'create table sample (id integer generated always as identity primary key)';
2460 | await callTool({
2461 | name: 'apply_migration',
2462 | arguments: {
2463 | project_id: project.id,
2464 | name: migrationName,
2465 | query: migrationQuery,
2466 | },
2467 | });
2468 |
2469 | await callTool({
2470 | name: 'rebase_branch',
2471 | arguments: {
2472 | branch_id: branch.id,
2473 | },
2474 | });
2475 |
2476 | // Check that the production migration was applied to the branch
2477 | const listResult = await callTool({
2478 | name: 'list_migrations',
2479 | arguments: {
2480 | project_id: branch.project_ref,
2481 | },
2482 | });
2483 |
2484 | expect(listResult).toContainEqual({
2485 | name: migrationName,
2486 | version: expect.stringMatching(/^\d{14}$/),
2487 | });
2488 | });
2489 |
2490 | test('rebase branch in read-only mode throws an error', async () => {
2491 | const { callTool } = await setup({
2492 | readOnly: true,
2493 | features: ['account', 'branching', 'database'],
2494 | });
2495 |
2496 | const org = await createOrganization({
2497 | name: 'My Org',
2498 | plan: 'free',
2499 | allowed_release_channels: ['ga'],
2500 | });
2501 |
2502 | const project = await createProject({
2503 | name: 'Project 1',
2504 | region: 'us-east-1',
2505 | organization_id: org.id,
2506 | });
2507 | project.status = 'ACTIVE_HEALTHY';
2508 |
2509 | const branch = await createBranch({
2510 | name: 'test-branch',
2511 | parent_project_ref: project.id,
2512 | });
2513 |
2514 | const result = callTool({
2515 | name: 'rebase_branch',
2516 | arguments: {
2517 | branch_id: branch.id,
2518 | },
2519 | });
2520 |
2521 | await expect(result).rejects.toThrow(
2522 | 'Cannot rebase a branch in read-only mode.'
2523 | );
2524 | });
2525 |
2526 | // We use snake_case because it aligns better with most MCP clients
2527 | test('all tools follow snake_case naming convention', async () => {
2528 | const { client } = await setup();
2529 |
2530 | const { tools } = await client.listTools();
2531 |
2532 | for (const tool of tools) {
2533 | expect(tool.name, 'expected tool name to be snake_case').toMatch(
2534 | /^[a-z0-9_]+$/
2535 | );
2536 |
2537 | const parameterNames = Object.keys(tool.inputSchema.properties ?? {});
2538 | for (const name of parameterNames) {
2539 | expect(name, 'expected parameter to be snake_case').toMatch(
2540 | /^[a-z0-9_]+$/
2541 | );
2542 | }
2543 | }
2544 | });
2545 |
2546 | test('all tools provide annotations', async () => {
2547 | const { client } = await setup();
2548 |
2549 | const { tools } = await client.listTools();
2550 |
2551 | for (const tool of tools) {
2552 | expect(tool.annotations, `${tool.name} tool`).toBeDefined();
2553 | expect(tool.annotations!.title, `${tool.name} tool`).toBeDefined();
2554 | expect(tool.annotations!.readOnlyHint, `${tool.name} tool`).toBeDefined();
2555 | expect(
2556 | tool.annotations!.destructiveHint,
2557 | `${tool.name} tool`
2558 | ).toBeDefined();
2559 | expect(
2560 | tool.annotations!.idempotentHint,
2561 | `${tool.name} tool`
2562 | ).toBeDefined();
2563 | expect(
2564 | tool.annotations!.openWorldHint,
2565 | `${tool.name} tool`
2566 | ).toBeDefined();
2567 | }
2568 | });
2569 | });
2570 |
2571 | describe('feature groups', () => {
2572 | test('account tools', async () => {
2573 | const { client } = await setup({
2574 | features: ['account'],
2575 | });
2576 |
2577 | const { tools } = await client.listTools();
2578 | const toolNames = tools.map((tool) => tool.name);
2579 |
2580 | expect(toolNames).toEqual([
2581 | 'list_organizations',
2582 | 'get_organization',
2583 | 'list_projects',
2584 | 'get_project',
2585 | 'get_cost',
2586 | 'confirm_cost',
2587 | 'create_project',
2588 | 'pause_project',
2589 | 'restore_project',
2590 | ]);
2591 | });
2592 |
2593 | test('database tools', async () => {
2594 | const { client } = await setup({
2595 | features: ['database'],
2596 | });
2597 |
2598 | const { tools } = await client.listTools();
2599 | const toolNames = tools.map((tool) => tool.name);
2600 |
2601 | expect(toolNames).toEqual([
2602 | 'list_tables',
2603 | 'list_extensions',
2604 | 'list_migrations',
2605 | 'apply_migration',
2606 | 'execute_sql',
2607 | ]);
2608 | });
2609 |
2610 | test('debugging tools', async () => {
2611 | const { client } = await setup({
2612 | features: ['debugging'],
2613 | });
2614 |
2615 | const { tools } = await client.listTools();
2616 | const toolNames = tools.map((tool) => tool.name);
2617 |
2618 | expect(toolNames).toEqual(['get_logs', 'get_advisors']);
2619 | });
2620 |
2621 | test('development tools', async () => {
2622 | const { client } = await setup({
2623 | features: ['development'],
2624 | });
2625 |
2626 | const { tools } = await client.listTools();
2627 | const toolNames = tools.map((tool) => tool.name);
2628 |
2629 | expect(toolNames).toEqual([
2630 | 'get_project_url',
2631 | 'get_publishable_keys',
2632 | 'generate_typescript_types',
2633 | ]);
2634 | });
2635 |
2636 | test('docs tools', async () => {
2637 | const { client } = await setup({
2638 | features: ['docs'],
2639 | });
2640 |
2641 | const { tools } = await client.listTools();
2642 | const toolNames = tools.map((tool) => tool.name);
2643 |
2644 | expect(toolNames).toEqual(['search_docs']);
2645 | });
2646 |
2647 | test('functions tools', async () => {
2648 | const { client } = await setup({
2649 | features: ['functions'],
2650 | });
2651 |
2652 | const { tools } = await client.listTools();
2653 | const toolNames = tools.map((tool) => tool.name);
2654 |
2655 | expect(toolNames).toEqual([
2656 | 'list_edge_functions',
2657 | 'get_edge_function',
2658 | 'deploy_edge_function',
2659 | ]);
2660 | });
2661 |
2662 | test('branching tools', async () => {
2663 | const { client } = await setup({
2664 | features: ['branching'],
2665 | });
2666 |
2667 | const { tools } = await client.listTools();
2668 | const toolNames = tools.map((tool) => tool.name);
2669 |
2670 | expect(toolNames).toEqual([
2671 | 'create_branch',
2672 | 'list_branches',
2673 | 'delete_branch',
2674 | 'merge_branch',
2675 | 'reset_branch',
2676 | 'rebase_branch',
2677 | ]);
2678 | });
2679 |
2680 | test('storage tools', async () => {
2681 | const { client } = await setup({
2682 | features: ['storage'],
2683 | });
2684 |
2685 | const { tools } = await client.listTools();
2686 | const toolNames = tools.map((tool) => tool.name);
2687 |
2688 | expect(toolNames).toEqual([
2689 | 'list_storage_buckets',
2690 | 'get_storage_config',
2691 | 'update_storage_config',
2692 | ]);
2693 | });
2694 |
2695 | test('invalid group fails', async () => {
2696 | const setupPromise = setup({
2697 | features: ['my-invalid-group'],
2698 | });
2699 |
2700 | await expect(setupPromise).rejects.toThrow('Invalid enum value');
2701 | });
2702 |
2703 | test('duplicate group behaves like single group', async () => {
2704 | const { client: duplicateClient } = await setup({
2705 | features: ['account', 'account'],
2706 | });
2707 |
2708 | const { tools } = await duplicateClient.listTools();
2709 | const toolNames = tools.map((tool) => tool.name);
2710 |
2711 | expect(toolNames).toEqual([
2712 | 'list_organizations',
2713 | 'get_organization',
2714 | 'list_projects',
2715 | 'get_project',
2716 | 'get_cost',
2717 | 'confirm_cost',
2718 | 'create_project',
2719 | 'pause_project',
2720 | 'restore_project',
2721 | ]);
2722 | });
2723 |
2724 | test('tools filtered to available platform operations', async () => {
2725 | const platform: SupabasePlatform = {
2726 | database: {
2727 | executeSql() {
2728 | throw new Error('Not implemented');
2729 | },
2730 | listMigrations() {
2731 | throw new Error('Not implemented');
2732 | },
2733 | applyMigration() {
2734 | throw new Error('Not implemented');
2735 | },
2736 | },
2737 | };
2738 |
2739 | const { client } = await setup({ platform });
2740 | const { tools } = await client.listTools();
2741 | const toolNames = tools.map((tool) => tool.name);
2742 |
2743 | expect(toolNames).toEqual([
2744 | 'search_docs',
2745 | 'list_tables',
2746 | 'list_extensions',
2747 | 'list_migrations',
2748 | 'apply_migration',
2749 | 'execute_sql',
2750 | ]);
2751 | });
2752 |
2753 | test('unimplemented feature group produces custom error message', async () => {
2754 | const platform: SupabasePlatform = {
2755 | database: {
2756 | executeSql() {
2757 | throw new Error('Not implemented');
2758 | },
2759 | listMigrations() {
2760 | throw new Error('Not implemented');
2761 | },
2762 | applyMigration() {
2763 | throw new Error('Not implemented');
2764 | },
2765 | },
2766 | };
2767 |
2768 | const setupPromise = setup({ platform, features: ['account'] });
2769 |
2770 | await expect(setupPromise).rejects.toThrow(
2771 | "This platform does not support the 'account' feature group"
2772 | );
2773 | });
2774 | });
2775 |
2776 | describe('project scoped tools', () => {
2777 | test('no account level tools should exist', async () => {
2778 | const org = await createOrganization({
2779 | name: 'My Org',
2780 | plan: 'free',
2781 | allowed_release_channels: ['ga'],
2782 | });
2783 |
2784 | const project = await createProject({
2785 | name: 'Project 1',
2786 | region: 'us-east-1',
2787 | organization_id: org.id,
2788 | });
2789 |
2790 | const { client } = await setup({ projectId: project.id });
2791 |
2792 | const result = await client.listTools();
2793 |
2794 | const accountLevelToolNames = [
2795 | 'list_organizations',
2796 | 'get_organization',
2797 | 'list_projects',
2798 | 'get_project',
2799 | 'get_cost',
2800 | 'confirm_cost',
2801 | 'create_project',
2802 | 'pause_project',
2803 | 'restore_project',
2804 | ];
2805 |
2806 | const toolNames = result.tools.map((tool) => tool.name);
2807 |
2808 | for (const accountLevelToolName of accountLevelToolNames) {
2809 | expect(
2810 | toolNames,
2811 | `tool ${accountLevelToolName} should not be available in project scope`
2812 | ).not.toContain(accountLevelToolName);
2813 | }
2814 | });
2815 |
2816 | test('no tool should accept a project_id', async () => {
2817 | const org = await createOrganization({
2818 | name: 'My Org',
2819 | plan: 'free',
2820 | allowed_release_channels: ['ga'],
2821 | });
2822 |
2823 | const project = await createProject({
2824 | name: 'Project 1',
2825 | region: 'us-east-1',
2826 | organization_id: org.id,
2827 | });
2828 |
2829 | const { client } = await setup({ projectId: project.id });
2830 |
2831 | const result = await client.listTools();
2832 |
2833 | expect(result.tools).toBeDefined();
2834 | expect(Array.isArray(result.tools)).toBe(true);
2835 |
2836 | for (const tool of result.tools) {
2837 | const schemaProperties = tool.inputSchema.properties ?? {};
2838 | expect(
2839 | 'project_id' in schemaProperties,
2840 | `tool ${tool.name} should not accept a project_id`
2841 | ).toBe(false);
2842 | }
2843 | });
2844 |
2845 | test('invalid project ID should throw an error', async () => {
2846 | const { callTool } = await setup({ projectId: 'invalid-project-id' });
2847 |
2848 | const listTablesPromise = callTool({
2849 | name: 'list_tables',
2850 | arguments: {
2851 | schemas: ['public'],
2852 | },
2853 | });
2854 |
2855 | await expect(listTablesPromise).rejects.toThrow('Project not found');
2856 | });
2857 |
2858 | test('passing project_id to a tool should throw an error', async () => {
2859 | const org = await createOrganization({
2860 | name: 'My Org',
2861 | plan: 'free',
2862 | allowed_release_channels: ['ga'],
2863 | });
2864 |
2865 | const project = await createProject({
2866 | name: 'Project 1',
2867 | region: 'us-east-1',
2868 | organization_id: org.id,
2869 | });
2870 | project.status = 'ACTIVE_HEALTHY';
2871 |
2872 | const { callTool } = await setup({ projectId: project.id });
2873 |
2874 | const listTablesPromise = callTool({
2875 | name: 'list_tables',
2876 | arguments: {
2877 | project_id: 'my-project-id',
2878 | schemas: ['public'],
2879 | },
2880 | });
2881 |
2882 | await expect(listTablesPromise).rejects.toThrow('Unrecognized key');
2883 | });
2884 |
2885 | test('listing tables implicitly uses the scoped project_id', async () => {
2886 | const org = await createOrganization({
2887 | name: 'My Org',
2888 | plan: 'free',
2889 | allowed_release_channels: ['ga'],
2890 | });
2891 |
2892 | const project = await createProject({
2893 | name: 'Project 1',
2894 | region: 'us-east-1',
2895 | organization_id: org.id,
2896 | });
2897 | project.status = 'ACTIVE_HEALTHY';
2898 |
2899 | project.db
2900 | .sql`create table test (id integer generated always as identity primary key)`;
2901 |
2902 | const { callTool } = await setup({ projectId: project.id });
2903 |
2904 | const result = await callTool({
2905 | name: 'list_tables',
2906 | arguments: {
2907 | schemas: ['public'],
2908 | },
2909 | });
2910 |
2911 | expect(result).toEqual([
2912 | expect.objectContaining({
2913 | name: 'test',
2914 | schema: 'public',
2915 | columns: [
2916 | expect.objectContaining({
2917 | name: 'id',
2918 | options: expect.arrayContaining(['identity']),
2919 | }),
2920 | ],
2921 | }),
2922 | ]);
2923 | });
2924 | });
2925 |
2926 | describe('docs tools', () => {
2927 | test('gets content', async () => {
2928 | const { callTool } = await setup();
2929 | const query = stripIndent`
2930 | query ContentQuery {
2931 | searchDocs(query: "typescript") {
2932 | nodes {
2933 | title
2934 | href
2935 | }
2936 | }
2937 | }
2938 | `;
2939 |
2940 | const result = await callTool({
2941 | name: 'search_docs',
2942 | arguments: {
2943 | graphql_query: query,
2944 | },
2945 | });
2946 |
2947 | expect(result).toEqual({ dummy: true });
2948 | });
2949 |
2950 | test('tool description contains schema', async () => {
2951 | const { client } = await setup();
2952 |
2953 | const { tools } = await client.listTools();
2954 |
2955 | const tool = tools.find((tool) => tool.name === 'search_docs');
2956 |
2957 | if (!tool) {
2958 | throw new Error('tool not found');
2959 | }
2960 |
2961 | if (!tool.description) {
2962 | throw new Error('tool description not found');
2963 | }
2964 |
2965 | expect(tool.description.includes(contentApiMockSchema)).toBe(true);
2966 | });
2967 |
2968 | test('schema is only loaded when listing tools', async () => {
2969 | const { client, callTool } = await setup();
2970 |
2971 | expect(mockContentApiSchemaLoadCount.value).toBe(0);
2972 |
2973 | // "tools/list" requests fetch the schema
2974 | await client.listTools();
2975 | expect(mockContentApiSchemaLoadCount.value).toBe(1);
2976 |
2977 | // "tools/call" should not fetch the schema again
2978 | await callTool({
2979 | name: 'search_docs',
2980 | arguments: {
2981 | graphql_query: '{ searchDocs(query: "test") { nodes { title } } }',
2982 | },
2983 | });
2984 | expect(mockContentApiSchemaLoadCount.value).toBe(1);
2985 |
2986 | // Additional "tools/list" requests fetch the schema again
2987 | await client.listTools();
2988 | expect(mockContentApiSchemaLoadCount.value).toBe(2);
2989 | });
2990 | });
2991 |
```