Skip to content

Commit a9b3bae

Browse files
committed
feat: list and deploy edge functions
1 parent 865e19d commit a9b3bae

File tree

13 files changed

+982
-18
lines changed

13 files changed

+982
-18
lines changed

package-lock.json

Lines changed: 64 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/mcp-server-supabase/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,11 @@
2929
}
3030
},
3131
"dependencies": {
32+
"@deno/eszip": "^0.84.0",
3233
"@modelcontextprotocol/sdk": "^1.4.1",
3334
"@supabase/mcp-utils": "0.1.3",
3435
"common-tags": "^1.8.2",
35-
"openapi-fetch": "^0.13.4",
36+
"openapi-fetch": "^0.13.5",
3637
"zod": "^3.24.1"
3738
},
3839
"devDependencies": {
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* Gets the deployment ID for an Edge Function.
3+
*/
4+
export function getDeploymentId(
5+
projectId: string,
6+
functionId: string,
7+
functionVersion: number
8+
): string {
9+
return `${projectId}_${functionId}_${functionVersion}`;
10+
}
11+
12+
/**
13+
* Gets the path prefix applied to each file in an Edge Function.
14+
*/
15+
export function getPathPrefix(deploymentId: string) {
16+
return `/tmp/user_fn_${deploymentId}/`;
17+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { codeBlock } from 'common-tags';
2+
import { open, type FileHandle } from 'node:fs/promises';
3+
import { describe, expect, test } from 'vitest';
4+
import { bundleFiles, extractFiles } from './eszip.js';
5+
6+
describe('eszip', () => {
7+
test('extract files', async () => {
8+
const helloContent = codeBlock`
9+
export function hello(): string {
10+
return 'Hello, world!';
11+
}
12+
`;
13+
const helloFile = new File([helloContent], 'hello.ts', {
14+
type: 'application/typescript',
15+
});
16+
17+
const indexContent = codeBlock`
18+
import { hello } from './hello.ts';
19+
20+
Deno.serve(async (req: Request) => {
21+
return new Response(hello(), { headers: { 'Content-Type': 'text/plain' } })
22+
});
23+
`;
24+
const indexFile = new File([indexContent], 'index.ts', {
25+
type: 'application/typescript',
26+
});
27+
28+
const eszip = await bundleFiles([indexFile, helloFile]);
29+
const extractedFiles = await extractFiles(eszip);
30+
31+
expect(extractedFiles).toHaveLength(2);
32+
33+
const extractedIndexFile = extractedFiles.find(
34+
(file) => file.name === 'index.ts'
35+
);
36+
const extractedHelloFile = extractedFiles.find(
37+
(file) => file.name === 'hello.ts'
38+
);
39+
40+
expect(extractedIndexFile).toBeDefined();
41+
expect(extractedIndexFile!.type).toBe('application/typescript');
42+
await expect(extractedIndexFile!.text()).resolves.toBe(indexContent);
43+
44+
expect(extractedHelloFile).toBeDefined();
45+
expect(extractedHelloFile!.type).toBe('application/typescript');
46+
await expect(extractedHelloFile!.text()).resolves.toBe(helloContent);
47+
});
48+
});
49+
50+
export class Source implements UnderlyingSource<Uint8Array> {
51+
type = 'bytes' as const;
52+
autoAllocateChunkSize = 1024;
53+
54+
path: string | URL;
55+
controller?: ReadableByteStreamController;
56+
file?: FileHandle;
57+
58+
constructor(path: string | URL) {
59+
this.path = path;
60+
}
61+
62+
async start(controller: ReadableStreamController<Uint8Array>) {
63+
if (!('byobRequest' in controller)) {
64+
throw new Error('ReadableStreamController does not support byobRequest');
65+
}
66+
67+
this.file = await open(this.path);
68+
this.controller = controller;
69+
}
70+
71+
async pull() {
72+
console.log('Pulling data...');
73+
if (!this.controller || !this.file) {
74+
throw new Error('ReadableStream has not been started');
75+
}
76+
77+
if (!this.controller.byobRequest) {
78+
throw new Error('ReadableStreamController does not support byobRequest');
79+
}
80+
81+
const view = this.controller.byobRequest.view as NodeJS.ArrayBufferView;
82+
83+
if (!view) {
84+
throw new Error('ReadableStreamController does not have a view');
85+
}
86+
87+
const { bytesRead } = await this.file.read({
88+
buffer: view,
89+
offset: view.byteOffset,
90+
length: view.byteLength,
91+
});
92+
93+
if (bytesRead === 0) {
94+
await this.file.close();
95+
this.controller.close();
96+
}
97+
98+
this.controller.byobRequest.respond(view.byteLength);
99+
}
100+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { build, Parser } from '@deno/eszip';
2+
import { join, relative } from 'node:path';
3+
import { fileURLToPath } from 'node:url';
4+
import { z } from 'zod';
5+
6+
const parser = await Parser.createInstance();
7+
const sourceMapSchema = z.object({
8+
version: z.number(),
9+
sources: z.array(z.string()),
10+
sourcesContent: z.array(z.string()).optional(),
11+
names: z.array(z.string()),
12+
mappings: z.string(),
13+
});
14+
15+
/**
16+
* Extracts source files from an eszip archive.
17+
*
18+
* Optionally removes the given path prefix from file names.
19+
*
20+
* If a file contains a source map, it will return the
21+
* original TypeScript source instead of the transpiled file.
22+
*/
23+
export async function extractFiles(
24+
eszip: Uint8Array,
25+
pathPrefix: string = '/'
26+
) {
27+
let specifiers: string[] = [];
28+
29+
if (eszip instanceof ReadableStream) {
30+
const reader = eszip.getReader({ mode: 'byob' });
31+
specifiers = await parser.parse(reader);
32+
} else {
33+
specifiers = await parser.parseBytes(eszip);
34+
}
35+
36+
await parser.load();
37+
38+
const fileSpecifiers = specifiers.filter((specifier) =>
39+
specifier.startsWith('file://')
40+
);
41+
42+
const files = await Promise.all(
43+
fileSpecifiers.map(async (specifier) => {
44+
const source: string = await parser.getModuleSource(specifier);
45+
const sourceMapString: string =
46+
await parser.getModuleSourceMap(specifier);
47+
48+
const filePath = relative(pathPrefix, fileURLToPath(specifier));
49+
50+
const file = new File([source], filePath, {
51+
type: 'text/plain',
52+
});
53+
54+
if (!sourceMapString) {
55+
return file;
56+
}
57+
58+
const sourceMap = sourceMapSchema.parse(JSON.parse(sourceMapString));
59+
60+
const [typeScriptSource] = sourceMap.sourcesContent ?? [];
61+
62+
if (!typeScriptSource) {
63+
return file;
64+
}
65+
66+
const sourceFile = new File([typeScriptSource], filePath, {
67+
type: 'application/typescript',
68+
});
69+
70+
return sourceFile;
71+
})
72+
);
73+
74+
return files;
75+
}
76+
77+
/**
78+
* Bundles files into an eszip archive.
79+
*
80+
* Optionally prefixes the file names with a given path.
81+
*/
82+
export async function bundleFiles(files: File[], pathPrefix: string = '/') {
83+
const specifiers = files.map(
84+
(file) => `file://${join(pathPrefix, file.name)}`
85+
);
86+
const eszip = await build(specifiers, async (specifier: string) => {
87+
if (specifier.startsWith('file://')) {
88+
const file = files.find(
89+
(file) => `file://${join(pathPrefix, file.name)}` === specifier
90+
);
91+
92+
if (!file) {
93+
throw new Error(`File not found: ${specifier}`);
94+
}
95+
96+
return {
97+
kind: 'module',
98+
specifier,
99+
headers: {
100+
'content-type': file.type,
101+
},
102+
content: await file.text(),
103+
};
104+
}
105+
});
106+
107+
return eszip;
108+
}

packages/mcp-server-supabase/src/management-api/index.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ export function createManagementApiClient(
1919
return createClient<paths>({
2020
baseUrl,
2121
headers: {
22-
'Content-Type': 'application/json',
2322
Authorization: `Bearer ${accessToken}`,
2423
...headers,
2524
},
@@ -53,7 +52,7 @@ export function assertSuccess<
5352
if ('error' in response) {
5453
if (response.response.status === 401) {
5554
throw new Error(
56-
'Unauthorized. Please provide a valid access token to the MCP server via the --access-token flag.'
55+
'Unauthorized. Please provide a valid access token to the MCP server via the --access-token flag or SUPABASE_ACCESS_TOKEN.'
5756
);
5857
}
5958

0 commit comments

Comments
 (0)