Skip to main content
Glama

supabase-mcp

index.ts33.5 kB
#!/usr/bin/env node import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import express, { Request, Response } from 'express'; import cors from 'cors'; import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js'; import { createClient, SupabaseClient } from '@supabase/supabase-js'; import type { SupabaseQueryResult, SupabaseFilterBuilder, AdminAuthOperations } from './types/supabase.js'; // Ensure environment variables are defined const SUPABASE_URL = process.env.SUPABASE_URL ?? ''; const SUPABASE_KEY = process.env.SUPABASE_KEY ?? ''; const SUPABASE_ACCESS_TOKEN = process.env.SUPABASE_ACCESS_TOKEN ?? ''; if (!SUPABASE_URL || !SUPABASE_KEY) { throw new Error('SUPABASE_URL and SUPABASE_KEY environment variables are required'); } if (!SUPABASE_ACCESS_TOKEN) { throw new Error('SUPABASE_ACCESS_TOKEN environment variable is required for management operations'); } // Management API base URL const MANAGEMENT_API_URL = 'https://api.supabase.com/v1'; interface ToolResponse { content: { type: string; text: string }[]; isError?: boolean; } class SupabaseServer { private server: Server; private supabase: SupabaseClient; private auth: { admin: AdminAuthOperations }; private toolApprovalCallbacks: Map<string, (approved: boolean) => void>; private toolRefreshCallbacks: Set<() => void>; constructor() { this.server = new Server( { name: 'supabase-server', version: '0.1.0', }, { capabilities: { tools: {}, }, } ); this.supabase = createClient(SUPABASE_URL, SUPABASE_KEY); this.auth = this.supabase.auth as any; this.toolApprovalCallbacks = new Map(); this.toolRefreshCallbacks = new Set(); this.setupToolHandlers(); this.server.onerror = (error) => console.error('[MCP Error]', error); process.on('SIGINT', async () => { await this.server.close(); process.exit(0); }); } private setupToolHandlers() { this.server.setRequestHandler(ListToolsRequestSchema, async () => { // Notify tool refresh listeners this.toolRefreshCallbacks.forEach(callback => callback()); return { tools: [ { name: 'list_tables', description: 'List all tables in the database', inputSchema: { type: 'object', properties: { schema: { type: 'string', description: 'Schema name (optional, defaults to public)', }, }, }, }, { name: 'create_record', description: 'Create a new record in a Supabase table', inputSchema: { type: 'object', properties: { table: { type: 'string', description: 'Table name', }, data: { type: 'object', description: 'Record data', }, returning: { type: 'array', items: { type: 'string' }, description: 'Fields to return (optional)', }, }, required: ['table', 'data'], }, }, { name: 'read_records', description: 'Read records from a Supabase table', inputSchema: { type: 'object', properties: { table: { type: 'string', description: 'Table name', }, filter: { type: 'object', description: 'Filter conditions (optional)', }, select: { type: 'array', items: { type: 'string' }, description: 'Fields to select (optional)', }, joins: { type: 'array', items: { type: 'object', properties: { type: { type: 'string', enum: ['inner', 'left', 'right', 'full'], description: 'Join type', }, table: { type: 'string', description: 'Table to join with', }, on: { type: 'string', description: 'Join condition (e.g., "posts.user_id=users.id")', }, }, required: ['table', 'on'], }, description: 'Table joins (optional)', }, limit: { type: 'number', description: 'Maximum number of records to return (optional)', }, }, required: ['table'], }, }, { name: 'update_records', description: 'Update records in a Supabase table', inputSchema: { type: 'object', properties: { table: { type: 'string', description: 'Table name', }, data: { type: 'object', description: 'Update data', }, filter: { type: 'object', description: 'Filter conditions', }, returning: { type: 'array', items: { type: 'string' }, description: 'Fields to return (optional)', }, }, required: ['table', 'data', 'filter'], }, }, { name: 'delete_records', description: 'Delete records from a Supabase table', inputSchema: { type: 'object', properties: { table: { type: 'string', description: 'Table name', }, filter: { type: 'object', description: 'Filter conditions', }, returning: { type: 'array', items: { type: 'string' }, description: 'Fields to return (optional)', }, }, required: ['table', 'filter'], }, }, { name: 'upload_file', description: 'Upload a file to Supabase Storage', inputSchema: { type: 'object', properties: { bucket: { type: 'string', description: 'Storage bucket name', }, path: { type: 'string', description: 'File path within the bucket', }, file: { type: 'object', description: 'File data as base64 string', }, options: { type: 'object', properties: { cacheControl: { type: 'string', description: 'Cache control header', }, contentType: { type: 'string', description: 'Content type of the file', }, upsert: { type: 'boolean', description: 'Whether to overwrite existing file', }, }, }, }, required: ['bucket', 'path', 'file'], }, }, { name: 'download_file', description: 'Download a file from Supabase Storage', inputSchema: { type: 'object', properties: { bucket: { type: 'string', description: 'Storage bucket name', }, path: { type: 'string', description: 'File path within the bucket', }, }, required: ['bucket', 'path'], }, }, { name: 'invoke_function', description: 'Invoke a Supabase Edge Function', inputSchema: { type: 'object', properties: { function: { type: 'string', description: 'Function name', }, params: { type: 'object', description: 'Function parameters', }, options: { type: 'object', properties: { headers: { type: 'object', description: 'Custom headers', }, responseType: { type: 'string', enum: ['json', 'text', 'arraybuffer'], description: 'Response type', }, }, }, }, required: ['function'], }, }, { name: 'list_users', description: 'List users with pagination', inputSchema: { type: 'object', properties: { page: { type: 'number', description: 'Page number', }, per_page: { type: 'number', description: 'Items per page', }, }, }, }, { name: 'create_user', description: 'Create a new user', inputSchema: { type: 'object', properties: { email: { type: 'string', description: 'User email', }, password: { type: 'string', description: 'User password', }, data: { type: 'object', description: 'Additional user metadata', }, }, required: ['email', 'password'], }, }, { name: 'update_user', description: 'Update user details', inputSchema: { type: 'object', properties: { user_id: { type: 'string', description: 'User ID', }, email: { type: 'string', description: 'New email', }, password: { type: 'string', description: 'New password', }, data: { type: 'object', description: 'Updated user metadata', }, }, required: ['user_id'], }, }, { name: 'delete_user', description: 'Delete a user', inputSchema: { type: 'object', properties: { user_id: { type: 'string', description: 'User ID', }, }, required: ['user_id'], }, }, { name: 'assign_user_role', description: 'Assign a role to a user', inputSchema: { type: 'object', properties: { user_id: { type: 'string', description: 'User ID', }, role: { type: 'string', description: 'Role name', }, }, required: ['user_id', 'role'], }, }, { name: 'remove_user_role', description: 'Remove a role from a user', inputSchema: { type: 'object', properties: { user_id: { type: 'string', description: 'User ID', }, role: { type: 'string', description: 'Role name', }, }, required: ['user_id', 'role'], }, } ], }; }); this.server.setRequestHandler(CallToolRequestSchema, async (request) => { try { const { name, arguments: args } = request.params; const typedArgs = args as Record<string, unknown>; // Create a promise that resolves when the tool is approved/denied const approved = await new Promise<boolean>((resolve) => { const requestId = Math.random().toString(36).substring(7); this.toolApprovalCallbacks.set(requestId, resolve); // Format tool call for display console.log(JSON.stringify({ type: 'tool_call', requestId, tool: name, arguments: args, description: this.getToolDescription(name, typedArgs) })); }); if (!approved) { throw new McpError( ErrorCode.InvalidRequest, 'Tool call was denied by user' ); } // Execute the tool call (keeping all existing tool implementations) switch (name) { case 'create_record': { if (!typedArgs.table || !typedArgs.data) { throw new McpError(ErrorCode.InvalidParams, 'Missing required parameters: table, data'); } const { data, error } = await this.supabase .from(typedArgs.table as string) .insert(typedArgs.data as Record<string, unknown>) .select((typedArgs.returning as string[])?.join(',') || '*'); if (error) throw error; return { content: [ { type: 'text', text: JSON.stringify(data, null, 2), }, ], }; } case 'list_tables': { const schema = (typedArgs.schema as string) || 'public'; const { data, error } = await this.supabase .rpc('list_tables', { p_schema: schema }) .select(); if (error) { // If RPC fails, try table_list view const { data: viewData, error: viewError } = await this.supabase .from('table_list') .select('table_name'); if (!viewError) { return { content: [ { type: 'text', text: JSON.stringify(viewData?.map(row => row.table_name) || [], null, 2), }, ], }; } // If view doesn't exist, fall back to direct query const { data: fallbackData, error: fallbackError } = await this.supabase .from('information_schema.tables') .select('table_name') .eq('table_schema', schema) .eq('table_type', 'BASE TABLE'); if (fallbackError) throw fallbackError; return { content: [ { type: 'text', text: JSON.stringify(fallbackData?.map(row => row.table_name) || [], null, 2), }, ], }; } return { content: [ { type: 'text', text: JSON.stringify(data || [], null, 2), }, ], }; } case 'read_records': { if (!typedArgs.table) { throw new McpError(ErrorCode.InvalidParams, 'Missing required parameter: table'); } let query = this.supabase .from(typedArgs.table as string) as SupabaseFilterBuilder<any>; // Apply filters before select to ensure proper typing if (typedArgs.filter) { Object.entries(typedArgs.filter as Record<string, unknown>).forEach(([key, value]) => { query = query.eq(key, value); }); } // Apply joins if specified if (typedArgs.joins) { (typedArgs.joins as Array<{ type?: string; table: string; on: string }>).forEach((join) => { const joinType = join.type || 'left'; switch (joinType) { case 'inner': query = query.innerJoin(join.table, join.on); break; case 'left': query = query.leftJoin(join.table, join.on); break; case 'right': query = query.rightJoin(join.table, join.on); break; case 'full': query = query.fullOuterJoin(join.table, join.on); break; } }); } // Now apply select and limit const filteredQuery = query.select((typedArgs.select as string[])?.join(',') || '*'); const finalQuery = typedArgs.limit ? filteredQuery.limit(typedArgs.limit as number) : filteredQuery; const { data, error } = await finalQuery; if (error) throw error; return { content: [ { type: 'text', text: JSON.stringify(data, null, 2), }, ], }; } case 'update_records': { if (!typedArgs.table || !typedArgs.data || !typedArgs.filter) { throw new McpError(ErrorCode.InvalidParams, 'Missing required parameters: table, data, filter'); } let query = this.supabase .from(typedArgs.table as string) as SupabaseFilterBuilder<any>; // Apply filters first Object.entries(typedArgs.filter as Record<string, unknown>).forEach(([key, value]) => { query = query.eq(key, value); }); // Then update const updateQuery = query.update(typedArgs.data as Record<string, unknown>); const finalQuery = typedArgs.returning ? updateQuery.select((typedArgs.returning as string[]).join(',')) : updateQuery; const { data, error } = await finalQuery; if (error) throw error; return { content: [ { type: 'text', text: JSON.stringify(data, null, 2), }, ], }; } case 'delete_records': { if (!typedArgs.table || !typedArgs.filter) { throw new McpError(ErrorCode.InvalidParams, 'Missing required parameters: table, filter'); } let query = this.supabase .from(typedArgs.table as string) as SupabaseFilterBuilder<any>; // Apply filters first Object.entries(typedArgs.filter as Record<string, unknown>).forEach(([key, value]) => { query = query.eq(key, value); }); // Then delete const deleteQuery = query.delete(); const finalQuery = typedArgs.returning ? deleteQuery.select((typedArgs.returning as string[]).join(',')) : deleteQuery; const { data, error } = await finalQuery; if (error) throw error; return { content: [ { type: 'text', text: JSON.stringify(data, null, 2), }, ], }; } case 'upload_file': { if (!typedArgs.bucket || !typedArgs.path || !typedArgs.file) { throw new McpError(ErrorCode.InvalidParams, 'Missing required parameters: bucket, path, file'); } // Convert base64 string to Blob for upload const fileData = typedArgs.file as { type: string; data: string }; const byteCharacters = atob(fileData.data); const byteArrays = []; for (let offset = 0; offset < byteCharacters.length; offset += 512) { const slice = byteCharacters.slice(offset, offset + 512); const byteNumbers = new Array(slice.length); for (let i = 0; i < slice.length; i++) { byteNumbers[i] = slice.charCodeAt(i); } const byteArray = new Uint8Array(byteNumbers); byteArrays.push(byteArray); } const blob = new Blob(byteArrays, { type: fileData.type }); const { data, error } = await this.supabase.storage .from(typedArgs.bucket as string) .upload( typedArgs.path as string, blob, typedArgs.options as { cacheControl?: string; contentType?: string; upsert?: boolean } ); if (error) throw error; return { content: [ { type: 'text', text: JSON.stringify(data, null, 2), }, ], }; } case 'download_file': { if (!typedArgs.bucket || !typedArgs.path) { throw new McpError(ErrorCode.InvalidParams, 'Missing required parameters: bucket, path'); } const { data, error } = await this.supabase.storage .from(typedArgs.bucket as string) .download(typedArgs.path as string); if (error) throw error; return { content: [ { type: 'text', text: JSON.stringify(data, null, 2), }, ], }; } case 'invoke_function': { if (!typedArgs.function) { throw new McpError(ErrorCode.InvalidParams, 'Missing required parameter: function'); } const { data, error } = await this.supabase.functions .invoke( typedArgs.function as string, { body: typedArgs.params as Record<string, unknown>, headers: (typedArgs.options as any)?.headers, } ); if (error) throw error; return { content: [ { type: 'text', text: JSON.stringify(data, null, 2), }, ], }; } case 'list_users': { const { data, error } = await this.auth.admin.listUsers({ page: typedArgs.page as number, perPage: typedArgs.per_page as number, }); if (error) throw error; return { content: [ { type: 'text', text: JSON.stringify(data, null, 2), }, ], }; } case 'create_user': { if (!typedArgs.email || !typedArgs.password) { throw new McpError(ErrorCode.InvalidParams, 'Missing required parameters: email, password'); } const { data, error } = await this.auth.admin.createUser({ email: typedArgs.email as string, password: typedArgs.password as string, user_metadata: typedArgs.data as Record<string, unknown>, }); if (error) throw error; return { content: [ { type: 'text', text: JSON.stringify(data, null, 2), }, ], }; } case 'update_user': { if (!typedArgs.user_id) { throw new McpError(ErrorCode.InvalidParams, 'Missing required parameter: user_id'); } const { data, error } = await this.auth.admin.updateUserById( typedArgs.user_id as string, { email: typedArgs.email as string, password: typedArgs.password as string, user_metadata: typedArgs.data as Record<string, unknown>, } ); if (error) throw error; return { content: [ { type: 'text', text: JSON.stringify(data, null, 2), }, ], }; } case 'delete_user': { if (!typedArgs.user_id) { throw new McpError(ErrorCode.InvalidParams, 'Missing required parameter: user_id'); } const { error } = await this.auth.admin.deleteUser(typedArgs.user_id as string); if (error) throw error; return { content: [ { type: 'text', text: JSON.stringify({ success: true }, null, 2), }, ], }; } case 'assign_user_role': { if (!typedArgs.user_id || !typedArgs.role) { throw new McpError(ErrorCode.InvalidParams, 'Missing required parameters: user_id, role'); } const { error } = await this.auth.admin.assignUserRole( typedArgs.user_id as string, typedArgs.role as string ); if (error) throw error; return { content: [ { type: 'text', text: JSON.stringify({ success: true }, null, 2), }, ], }; } case 'remove_user_role': { if (!typedArgs.user_id || !typedArgs.role) { throw new McpError(ErrorCode.InvalidParams, 'Missing required parameters: user_id, role'); } const { error } = await this.auth.admin.removeUserRole( typedArgs.user_id as string, typedArgs.role as string ); if (error) throw error; return { content: [ { type: 'text', text: JSON.stringify({ success: true }, null, 2), }, ], }; } default: throw new McpError( ErrorCode.MethodNotFound, `Unknown tool: ${name}` ); } } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; console.error('[Tool Error]', error); return { content: [ { type: 'text', text: `Error: ${errorMessage}`, }, ], isError: true, }; } }); } // Helper method to format tool descriptions for approval display private getToolDescription(name: string, args: Record<string, unknown>): string { switch (name) { case 'create_record': return `Create record in table '${args.table}' with data: ${JSON.stringify(args.data)}`; case 'list_tables': return `List tables in schema '${args.schema || 'public'}'`; case 'read_records': return `Read records from table '${args.table}'${args.filter ? ` with filter: ${JSON.stringify(args.filter)}` : ''}${args.joins ? ` with joins: ${JSON.stringify(args.joins)}` : ''}`; case 'update_records': return `Update records in table '${args.table}' with data: ${JSON.stringify(args.data)} and filter: ${JSON.stringify(args.filter)}`; case 'delete_records': return `Delete records from table '${args.table}' with filter: ${JSON.stringify(args.filter)}`; case 'upload_file': return `Upload file to bucket '${args.bucket}' at path '${args.path}'`; case 'download_file': return `Download file from bucket '${args.bucket}' at path '${args.path}'`; case 'invoke_function': return `Invoke function '${args.function}'${args.params ? ` with params: ${JSON.stringify(args.params)}` : ''}`; case 'list_users': return `List users${args.page ? ` (page ${args.page}, ${args.per_page} per page)` : ''}`; case 'create_user': return `Create user with email '${args.email}'`; case 'update_user': return `Update user '${args.user_id}'`; case 'delete_user': return `Delete user '${args.user_id}'`; case 'assign_user_role': return `Assign role '${args.role}' to user '${args.user_id}'`; case 'remove_user_role': return `Remove role '${args.role}' from user '${args.user_id}'`; default: return `Execute ${name} with arguments: ${JSON.stringify(args)}`; } } // Method to handle tool approval/denial public handleToolApproval(requestId: string, approved: boolean) { const callback = this.toolApprovalCallbacks.get(requestId); if (callback) { callback(approved); this.toolApprovalCallbacks.delete(requestId); } } // Method to register tool refresh callback public onToolRefresh(callback: () => void) { this.toolRefreshCallbacks.add(callback); return () => { this.toolRefreshCallbacks.delete(callback); }; } public async run(transport: 'stdio' | 'sse' = 'stdio', port?: number): Promise<void> { if (transport === 'sse') { const app = express(); app.use(cors()); // Create a promise that resolves when SSE connection is established const sseConnectionPromise = new Promise<SSEServerTransport>((resolve, reject) => { let timeoutId: NodeJS.Timeout; // Set a timeout for SSE connection timeoutId = setTimeout(() => { reject(new Error('SSE connection timeout after 30 seconds')); }, 30000); app.get('/sse', async (req: Request, res: Response) => { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); try { const transport = new SSEServerTransport('/message', res); await transport.start(); clearTimeout(timeoutId); resolve(transport); } catch (error) { clearTimeout(timeoutId); reject(error); } }); }); // Handle incoming messages app.post('/message', async (req: Request, res: Response) => { try { const transport = await sseConnectionPromise; await transport.handlePostMessage(req, res); } catch (error) { res.status(500).json({ error: 'Failed to handle message' }); } }); // Start HTTP server and wait for SSE connection const httpServer = app.listen(port || 8765, () => { console.error(`Supabase MCP server running on SSE at port ${port || 8765}`); }); try { const transport = await sseConnectionPromise; await this.server.connect(transport); } catch (error) { httpServer.close(); throw error; } } else { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error('Supabase MCP server running on stdio'); } } } // Create wrapper script for environment variables const createWrapperScript = () => { const scriptContent = `#!/bin/bash export SUPABASE_URL="\${SUPABASE_URL:-your_supabase_url_here}" export SUPABASE_KEY="\${SUPABASE_KEY:-your_supabase_key_here}" export SUPABASE_ACCESS_TOKEN="\${SUPABASE_ACCESS_TOKEN:-your_access_token_here}" node "${process.argv[1]}" "$@" `; const fs = require('fs'); const path = require('path'); const wrapperPath = path.join(process.cwd(), 'run-supabase-server.sh'); fs.writeFileSync(wrapperPath, scriptContent); fs.chmodSync(wrapperPath, '755'); console.log(`Created wrapper script at ${wrapperPath}`); }; // Parse command line arguments const args = process.argv.slice(2); if (args.includes('--create-wrapper')) { createWrapperScript(); } else { const transportType = args[0] || 'stdio'; const port = args[1] ? parseInt(args[1]) : undefined; const server = new SupabaseServer(); server.run(transportType as 'stdio' | 'sse', port).catch(console.error); }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/DynamicEndpoints/supabase-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server