diff --git a/cmd/init.go b/cmd/init.go index a9d2d90a8..515423026 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/cobra" "github.com/spf13/viper" _init "github.com/supabase/cli/internal/init" + mcpinit "github.com/supabase/cli/internal/mcp/init" "github.com/supabase/cli/internal/utils" ) @@ -43,7 +44,30 @@ var ( createIntellijSettings = nil } ctx, _ := signal.NotifyContext(cmd.Context(), os.Interrupt) - return _init.Run(ctx, fsys, createVscodeSettings, createIntellijSettings, initParams) + + // Run core project init first + if err := _init.Run(ctx, fsys, createVscodeSettings, createIntellijSettings, initParams); err != nil { + return err + } + + // Prompt for MCP configuration if in interactive mode + console := utils.NewConsole() + if configureMCP, err := console.PromptYesNo(ctx, "Configure Supabase MCP server locally?", false); err != nil { + return err + } else if configureMCP { + clientName, err := mcpinit.PromptMCPClient(ctx) + if err != nil { + return err + } + // Skip configuration if user selected "other" + if clientName != "other" { + if err := mcpinit.Run(ctx, fsys, clientName); err != nil { + return err + } + } + } + + return nil }, PostRun: func(cmd *cobra.Command, args []string) { fmt.Println("Finished " + utils.Aqua("supabase init") + ".") diff --git a/cmd/mcp.go b/cmd/mcp.go new file mode 100644 index 000000000..cddd74f70 --- /dev/null +++ b/cmd/mcp.go @@ -0,0 +1,18 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +var ( + mcpCmd = &cobra.Command{ + GroupID: groupQuickStart, + Use: "mcp", + Short: "Manage Model Context Protocol (MCP) configuration", + Long: "Commands for setting up and managing MCP server configurations for AI assistants like Cursor, VS Code Copilot, and Claude Desktop.", + } +) + +func init() { + rootCmd.AddCommand(mcpCmd) +} diff --git a/cmd/mcp_init.go b/cmd/mcp_init.go new file mode 100644 index 000000000..cddceffe7 --- /dev/null +++ b/cmd/mcp_init.go @@ -0,0 +1,39 @@ +package cmd + +import ( + "github.com/spf13/afero" + "github.com/spf13/cobra" + mcpinit "github.com/supabase/cli/internal/mcp/init" +) + +var ( + mcpInitCmd = &cobra.Command{ + Use: "init", + Short: "Configure Supabase MCP server for AI assistant clients", + Long: `Configure the Supabase MCP server for your AI assistant clients. + +This command will detect installed MCP clients and guide you through the setup process. +Currently supports: Claude Code, Cursor, VS Code (with more clients coming soon). + +The Supabase MCP server allows AI assistants to interact with your Supabase projects, +providing tools for database operations, edge functions, storage, and more. + +Examples: + # Auto-detect and configure installed clients + supabase mcp init + + # Configure a specific client + supabase mcp init --client claude-code + supabase mcp init --client cursor + supabase mcp init --client vscode`, + RunE: func(cmd *cobra.Command, args []string) error { + client, _ := cmd.Flags().GetString("client") + return mcpinit.Run(cmd.Context(), afero.NewOsFs(), client) + }, + } +) + +func init() { + mcpInitCmd.Flags().StringP("client", "c", "", "Target specific client (e.g., claude-code)") + mcpCmd.AddCommand(mcpInitCmd) +} diff --git a/internal/mcp/init/README.md b/internal/mcp/init/README.md new file mode 100644 index 000000000..6d90f35cb --- /dev/null +++ b/internal/mcp/init/README.md @@ -0,0 +1,219 @@ +# MCP Init - Client Configuration System + +This package provides a scalable system for configuring the Supabase MCP server with various AI assistant clients. + +## Architecture + +The system uses a client registry pattern where each client implements the `Client` interface: + +```go +type Client interface { + Name() string // CLI identifier (e.g., "claude-code") + DisplayName() string // Human-readable name (e.g., "Claude Code") + IsInstalled() bool // Check if client is installed + InstallInstructions() string // Installation instructions + Configure(ctx context.Context, fsys afero.Fs) error // Perform configuration +} +``` + +## Adding a New Client + +### Step 1: Implement the Client Interface + +Create a new struct that implements the `Client` interface. Here's a complete example: + +```go +// cursorClient implements the Client interface for Cursor +type cursorClient struct{} + +func (c *cursorClient) Name() string { + return "cursor" +} + +func (c *cursorClient) DisplayName() string { + return "Cursor" +} + +func (c *cursorClient) IsInstalled() bool { + // Check if cursor command exists or app is installed + return commandExists("cursor") || appExists("Cursor") +} + +func (c *cursorClient) InstallInstructions() string { + return "Download from https://cursor.sh" +} + +func (c *cursorClient) Configure(ctx context.Context, fsys afero.Fs) error { + fmt.Println("Configuring Cursor...") + fmt.Println() + + // Option 1: Run a CLI command + cmd := exec.CommandContext(ctx, "cursor", "config", "add", "mcp", "supabase") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to configure Cursor: %w", err) + } + + // Option 2: Write a config file + // configPath := filepath.Join(os.Getenv("HOME"), ".cursor", "mcp.json") + // ... write JSON config ... + + // Option 3: Display manual instructions + // fmt.Println("Manual setup instructions:") + // fmt.Println("1. Open Cursor settings...") + + fmt.Println("✓ Successfully configured Cursor!") + return nil +} +``` + +### Step 2: Register the Client + +Add your new client to the `clientRegistry` slice: + +```go +var clientRegistry = []Client{ + &claudeCodeClient{}, + &cursorClient{}, // Add your new client here + &vscodeClient{}, // Add more as needed +} +``` + +### Step 3: Test + +Test the new client: + +```bash +# Auto-detect and configure +supabase mcp init + +# Or target your specific client +supabase mcp init --client cursor +``` + +## Configuration Approaches + +Depending on the client, you can use different configuration approaches: + +### 1. CLI Command Execution + +Best for clients with a CLI that supports adding MCP servers: + +```go +cmd := exec.CommandContext(ctx, "client-cli", "mcp", "add", "supabase", "https://mcp.supabase.com/mcp") +cmd.Stdout = os.Stdout +cmd.Stderr = os.Stderr +return cmd.Run() +``` + +### 2. JSON Configuration File + +Best for clients that read MCP config from a JSON file: + +```go +import ( + "encoding/json" + "path/filepath" +) + +func (c *myClient) Configure(ctx context.Context, fsys afero.Fs) error { + homeDir, _ := os.UserHomeDir() + configPath := filepath.Join(homeDir, ".client", "mcp.json") + + config := map[string]interface{}{ + "mcpServers": map[string]interface{}{ + "supabase": map[string]interface{}{ + "type": "remote", + "url": "https://mcp.supabase.com/mcp", + }, + }, + } + + // Create directory if needed + if err := fsys.MkdirAll(filepath.Dir(configPath), 0755); err != nil { + return err + } + + // Read existing config to merge + existingData, _ := afero.ReadFile(fsys, configPath) + var existing map[string]interface{} + if len(existingData) > 0 { + json.Unmarshal(existingData, &existing) + // Merge configs... + } + + // Write config + configJSON, _ := json.MarshalIndent(config, "", " ") + return afero.WriteFile(fsys, configPath, configJSON, 0644) +} +``` + +### 3. Manual Instructions + +Best for clients that require manual setup or don't have automation support: + +```go +func (c *myClient) Configure(ctx context.Context, fsys afero.Fs) error { + fmt.Println("Manual Configuration Required") + fmt.Println("==============================") + fmt.Println() + fmt.Println("1. Open Client Settings") + fmt.Println("2. Navigate to MCP Servers") + fmt.Println("3. Add the following configuration:") + fmt.Println() + fmt.Println(`{ + "supabase": { + "type": "remote", + "url": "https://mcp.supabase.com/mcp" + } +}`) + fmt.Println() + fmt.Println("4. Save and restart the client") + return nil +} +``` + +## Helper Functions + +### `commandExists(command string) bool` + +Checks if a command-line tool is available: + +```go +if commandExists("cursor") { + // cursor CLI is available +} +``` + +### `appExists(appName string) bool` (to be added if needed) + +Checks if a macOS application is installed: + +```go +func appExists(appName string) bool { + if runtime.GOOS == "darwin" { + locations := []string{ + fmt.Sprintf("/Applications/%s.app", appName), + fmt.Sprintf("%s/Applications/%s.app", os.Getenv("HOME"), appName), + } + for _, location := range locations { + if _, err := os.Stat(location); err == nil { + return true + } + } + } + return false +} +``` + +## User Experience Flow + +1. **No clients installed**: Shows list of available clients with install instructions +2. **One client installed**: Auto-configures that client +3. **Multiple clients installed**: Shows options and prompts user to choose +4. **Specific client requested**: Configures that client if installed, shows install instructions otherwise + +## Examples + +See `claudeCodeClient` in `init.go` for a complete working example. diff --git a/internal/mcp/init/claude_code.go b/internal/mcp/init/claude_code.go new file mode 100644 index 000000000..7773397d5 --- /dev/null +++ b/internal/mcp/init/claude_code.go @@ -0,0 +1,70 @@ +package mcpinit + +import ( + "context" + "fmt" + "os" + "os/exec" + + "github.com/spf13/afero" + "github.com/supabase/cli/internal/utils" +) + +// claudeCodeClient implements the Client interface for Claude Code +type claudeCodeClient struct { + baseClient +} + +func newClaudeCodeClient() *claudeCodeClient { + return &claudeCodeClient{ + baseClient: baseClient{ + name: "claude-code", + displayName: "Claude Code", + installInstructions: "npm install -g @anthropic-ai/claude-cli", + checkInstalled: func() bool { + return commandExists("claude") + }, + }, + } +} + +func (c *claudeCodeClient) Configure(ctx context.Context, fsys afero.Fs) error { + fmt.Println("Configuring Claude Code...") + fmt.Println() + + // Use utils.PromptChoice for dropdown + choice, err := utils.PromptChoice(ctx, "Where would you like to add the Claude Code MCP server?", []utils.PromptItem{ + {Summary: "local", Details: "Local (only for you in this project)"}, + {Summary: "project", Details: "Project (shared via .mcp.json in project root)"}, + {Summary: "user", Details: "User (available across all projects for your user)"}, + }) + if err != nil { + fmt.Printf("⚠️ Warning: failed to select scope for Claude Code MCP server: %v\n", err) + fmt.Println("Defaulting to local scope.") + choice = utils.PromptItem{Summary: "local"} + } + + cmdArgs := []string{"mcp", "add", "--transport", "http", "supabase", "http://localhost:54321/mcp"} + if choice.Summary != "local" { + cmdArgs = append(cmdArgs, "--scope", choice.Summary) + } + cmd := exec.CommandContext(ctx, "claude", cmdArgs...) + + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + err = cmd.Run() + if err != nil { + fmt.Println() + fmt.Printf("⚠️ Warning: failed to configure Claude Code MCP server: %v\n", err) + fmt.Println("You may need to configure it manually.") + } else { + fmt.Println() + fmt.Println("✓ Successfully added Supabase MCP server to Claude Code!") + fmt.Println() + // Command string display removed (cmdStr no longer exists) + fmt.Println() + fmt.Println("The server is now available in your Claude Code environment.") + } + return nil +} diff --git a/internal/mcp/init/client.go b/internal/mcp/init/client.go new file mode 100644 index 000000000..ae80203b8 --- /dev/null +++ b/internal/mcp/init/client.go @@ -0,0 +1,59 @@ +package mcpinit + +import ( + "context" + "os/exec" + + "github.com/spf13/afero" +) + +// Client represents an MCP client that can be configured +type Client interface { + // Name returns the client identifier (e.g., "claude-code") + Name() string + + // DisplayName returns the human-readable name (e.g., "Claude Code") + DisplayName() string + + // IsInstalled checks if the client is installed on the system + IsInstalled() bool + + // InstallInstructions returns instructions for installing the client + InstallInstructions() string + + // Configure performs the configuration for this client + Configure(ctx context.Context, fsys afero.Fs) error +} + +// baseClient provides default implementations for the Client interface +type baseClient struct { + name string + displayName string + installInstructions string + checkInstalled func() bool +} + +func (b *baseClient) Name() string { + return b.name +} + +func (b *baseClient) DisplayName() string { + return b.displayName +} + +func (b *baseClient) IsInstalled() bool { + if b.checkInstalled != nil { + return b.checkInstalled() + } + return false +} + +func (b *baseClient) InstallInstructions() string { + return b.installInstructions +} + +// commandExists checks if a command-line tool is available +func commandExists(command string) bool { + _, err := exec.LookPath(command) + return err == nil +} diff --git a/internal/mcp/init/cursor.go b/internal/mcp/init/cursor.go new file mode 100644 index 000000000..9c2d81a3f --- /dev/null +++ b/internal/mcp/init/cursor.go @@ -0,0 +1,119 @@ +package mcpinit + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/spf13/afero" + "github.com/supabase/cli/internal/utils" +) + +// cursorClient implements the Client interface for Cursor +type cursorClient struct { + baseClient +} + +func newCursorClient() *cursorClient { + return &cursorClient{ + baseClient: baseClient{ + name: "cursor", + displayName: "Cursor", + installInstructions: "Download from https://cursor.sh", + checkInstalled: func() bool { + return commandExists("cursor") || appExists("Cursor") + }, + }, + } +} + +func (c *cursorClient) Configure(ctx context.Context, fsys afero.Fs) error { + fmt.Println("Configuring Cursor...") + fmt.Println() + + choice, err := utils.PromptChoice(ctx, "Where would you like to add the configuration?", []utils.PromptItem{ + {Summary: "project", Details: "Project-local (in .cursor/mcp.json)"}, + {Summary: "global", Details: "Global (in your home directory)"}, + }) + if err != nil { + return err + } + + var configPath string + if choice.Summary == "global" { + // Global config + homeDir, _ := os.UserHomeDir() + configPath = filepath.Join(homeDir, ".cursor", "mcp.json") + } else { + // Project-local config + cwd, _ := os.Getwd() + configPath = filepath.Join(cwd, ".cursor", "mcp.json") + } + + // Prepare the Supabase MCP server config + supabaseConfig := map[string]interface{}{ + "type": "http", + "url": "http://localhost:54321/mcp", + } + + // Read existing config if it exists + var config map[string]interface{} + existingData, err := afero.ReadFile(fsys, configPath) + if err == nil && len(existingData) > 0 { + if err := json.Unmarshal(existingData, &config); err != nil { + // If existing file is invalid JSON, start fresh + config = make(map[string]interface{}) + } + } else { + config = make(map[string]interface{}) + } + + // Ensure mcpServers exists + mcpServers, ok := config["mcpServers"].(map[string]interface{}) + if !ok { + mcpServers = make(map[string]interface{}) + config["mcpServers"] = mcpServers + } + + // Add or update Supabase server + mcpServers["supabase"] = supabaseConfig + + // Ensure directory exists + configDir := filepath.Dir(configPath) + if err := fsys.MkdirAll(configDir, 0755); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + // Write config + configJSON, err := json.MarshalIndent(config, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + if err := afero.WriteFile(fsys, configPath, configJSON, 0644); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + // Generate example for display + configExample, _ := json.MarshalIndent(map[string]interface{}{ + "mcpServers": map[string]interface{}{ + "supabase": supabaseConfig, + }, + }, "", " ") + + fmt.Println() + fmt.Printf("✓ Successfully configured Cursor at: %s\n", configPath) + fmt.Println() + fmt.Println("Configuration added:") + fmt.Println(string(configExample)) + fmt.Println() + fmt.Println("Next steps:") + fmt.Println(" 1. Open Cursor") + fmt.Println(" 2. Navigate to Cursor Settings > Tools & MCP") + fmt.Println(" 3. Enable the 'supabase' MCP server") + fmt.Println() + fmt.Println("The Supabase MCP server will then be available in Cursor!") + return nil +} diff --git a/internal/mcp/init/init.go b/internal/mcp/init/init.go new file mode 100644 index 000000000..432f7bd81 --- /dev/null +++ b/internal/mcp/init/init.go @@ -0,0 +1,123 @@ +package mcpinit + +import ( + "context" + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/spf13/afero" + "github.com/supabase/cli/internal/utils" +) + +// clientRegistry holds all supported clients +var clientRegistry = []Client{ + newClaudeCodeClient(), + newCursorClient(), + newVSCodeClient(), +} + +// PromptMCPClient prompts the user to select an MCP client from the available options +func PromptMCPClient(ctx context.Context, opts ...tea.ProgramOption) (string, error) { + // Add all clients plus "Other" option + items := make([]utils.PromptItem, len(clientRegistry)+1) + for i, client := range clientRegistry { + items[i] = utils.PromptItem{ + Summary: client.Name(), + Details: client.DisplayName(), + } + } + // Add "Other" option at the end + items[len(clientRegistry)] = utils.PromptItem{ + Summary: "other", + Details: "Configure it manually", + } + + choice, err := utils.PromptChoice(ctx, "Which client do you want to configure?", items, opts...) + if err != nil { + return "", err + } + + return choice.Summary, nil +} + +func Run(ctx context.Context, fsys afero.Fs, clientFlag string) error { + // If a specific client is requested + if clientFlag != "" { + return configureSpecificClient(ctx, fsys, clientFlag) + } + + // Find installed clients + var installedClients []Client + for _, client := range clientRegistry { + if client.IsInstalled() { + installedClients = append(installedClients, client) + } + } + + // If no clients installed, show available options + if len(installedClients) == 0 { + fmt.Println("No MCP clients detected on this system.") + fmt.Println() + fmt.Println("Available clients:") + for _, client := range clientRegistry { + fmt.Printf(" • %s\n", client.DisplayName()) + fmt.Printf(" Install: %s\n", client.InstallInstructions()) + fmt.Println() + } + fmt.Println("After installing a client, run this command again.") + return nil + } + + // If only one client is installed, configure it directly + if len(installedClients) == 1 { + client := installedClients[0] + fmt.Printf("Detected %s\n", client.DisplayName()) + fmt.Println() + return client.Configure(ctx, fsys) + } + + // Multiple clients installed - show options + fmt.Println("Multiple MCP clients detected:") + for i, client := range installedClients { + fmt.Printf(" %d. %s\n", i+1, client.DisplayName()) + } + fmt.Println() + fmt.Println("Use the --client flag to configure a specific client:") + for _, client := range installedClients { + fmt.Printf(" supabase mcp init --client %s\n", client.Name()) + } + + return nil +} + +func configureSpecificClient(ctx context.Context, fsys afero.Fs, clientName string) error { + // Find the requested client + var targetClient Client + for _, client := range clientRegistry { + if client.Name() == clientName { + targetClient = client + break + } + } + + if targetClient == nil { + fmt.Printf("❌ Unknown client: %s\n\n", clientName) + fmt.Println("Supported clients:") + for _, client := range clientRegistry { + fmt.Printf(" • %s\n", client.Name()) + } + return fmt.Errorf("unknown client: %s", clientName) + } + + // Check if installed + if !targetClient.IsInstalled() { + fmt.Printf("❌ %s is not installed on this system.\n\n", targetClient.DisplayName()) + fmt.Println("To install:") + fmt.Printf(" %s\n", targetClient.InstallInstructions()) + return nil + } + + // Configure + fmt.Printf("Configuring %s...\n\n", targetClient.DisplayName()) + return targetClient.Configure(ctx, fsys) +} diff --git a/internal/mcp/init/utils.go b/internal/mcp/init/utils.go new file mode 100644 index 000000000..fbd1e6784 --- /dev/null +++ b/internal/mcp/init/utils.go @@ -0,0 +1,23 @@ +package mcpinit + +import ( + "fmt" + "os" + "runtime" +) + +// appExists checks if a macOS application is installed +func appExists(appName string) bool { + if runtime.GOOS == "darwin" { + locations := []string{ + fmt.Sprintf("/Applications/%s.app", appName), + fmt.Sprintf("%s/Applications/%s.app", os.Getenv("HOME"), appName), + } + for _, location := range locations { + if _, err := os.Stat(location); err == nil { + return true + } + } + } + return false +} diff --git a/internal/mcp/init/vscode.go b/internal/mcp/init/vscode.go new file mode 100644 index 000000000..8d5c7f945 --- /dev/null +++ b/internal/mcp/init/vscode.go @@ -0,0 +1,124 @@ +package mcpinit + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + + tea "github.com/charmbracelet/bubbletea" + "github.com/spf13/afero" + "github.com/supabase/cli/internal/utils" +) + +// vscodeClient implements the Client interface for VS Code +type vscodeClient struct { + baseClient +} + +func newVSCodeClient() *vscodeClient { + return &vscodeClient{ + baseClient: baseClient{ + name: "vscode", + displayName: "VS Code", + installInstructions: "Download from https://code.visualstudio.com", + checkInstalled: func() bool { + return commandExists("code") + }, + }, + } +} + +func (c *vscodeClient) Configure(ctx context.Context, fsys afero.Fs) error { + fmt.Println("Configuring VS Code...") + fmt.Println() + + // Prompt for config scope using dropdown + items := []utils.PromptItem{ + { + Summary: "project", + Details: "Project-local (in .vscode/mcp.json)", + }, + { + Summary: "global", + Details: "Global (in your home directory)", + }, + } + + choice, err := utils.PromptChoice(ctx, "Where would you like to add the configuration?", items, tea.WithOutput(os.Stderr)) + if err != nil { + return err + } + + var configPath string + if choice.Summary == "global" { + // Global config + homeDir, _ := os.UserHomeDir() + configPath = filepath.Join(homeDir, ".vscode", "mcp.json") + } else { + // Project-local config + cwd, _ := os.Getwd() + configPath = filepath.Join(cwd, ".vscode", "mcp.json") + } + + // Prepare the Supabase MCP server config + supabaseConfig := map[string]interface{}{ + "type": "http", + "url": "http://localhost:54321/mcp", + } + + // Read existing config if it exists + var config map[string]interface{} + existingData, err := afero.ReadFile(fsys, configPath) + if err == nil && len(existingData) > 0 { + if err := json.Unmarshal(existingData, &config); err != nil { + // If existing file is invalid JSON, start fresh + config = make(map[string]interface{}) + } + } else { + config = make(map[string]interface{}) + } + + // Ensure servers exists + servers, ok := config["servers"].(map[string]interface{}) + if !ok { + servers = make(map[string]interface{}) + config["servers"] = servers + } + + // Add or update Supabase server + servers["supabase"] = supabaseConfig + + // Ensure directory exists + configDir := filepath.Dir(configPath) + if err := fsys.MkdirAll(configDir, 0755); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + // Write config + configJSON, err := json.MarshalIndent(config, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + if err := afero.WriteFile(fsys, configPath, configJSON, 0644); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + // Generate example for display + configExample, _ := json.MarshalIndent(map[string]interface{}{ + "servers": map[string]interface{}{ + "supabase": supabaseConfig, + }, + }, "", " ") + + fmt.Println() + fmt.Printf("✓ Successfully configured VS Code at: %s\n", configPath) + fmt.Println() + fmt.Println("Configuration added:") + fmt.Println(string(configExample)) + fmt.Println() + fmt.Println("The Supabase MCP server is now available in VS Code!") + return nil +}