diff --git a/internal/tiger/cmd/db.go b/internal/tiger/cmd/db.go index 4cf7d4dc..286234c9 100644 --- a/internal/tiger/cmd/db.go +++ b/internal/tiger/cmd/db.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "io" "os" "os/exec" "time" @@ -61,7 +60,17 @@ Examples: return err } - connectionString, err := buildConnectionString(service, dbConnectionStringPooled, dbConnectionStringRole, dbConnectionStringWithPassword, cmd.ErrOrStderr()) + passwordMode := password.PasswordExclude + if dbConnectionStringWithPassword { + passwordMode = password.PasswordRequired + } + + connectionString, err := password.BuildConnectionString(service, password.ConnectionStringOptions{ + Pooled: dbConnectionStringPooled, + Role: dbConnectionStringRole, + PasswordMode: passwordMode, + WarnWriter: cmd.ErrOrStderr(), + }) if err != nil { return fmt.Errorf("failed to build connection string: %w", err) } @@ -133,8 +142,12 @@ Examples: return fmt.Errorf("psql client not found. Please install PostgreSQL client tools") } - // Get connection string using existing logic - connectionString, err := buildConnectionString(service, dbConnectPooled, dbConnectRole, false, cmd.ErrOrStderr()) + connectionString, err := password.BuildConnectionString(service, password.ConnectionStringOptions{ + Pooled: dbConnectPooled, + Role: dbConnectRole, + PasswordMode: password.PasswordExclude, + WarnWriter: cmd.ErrOrStderr(), + }) if err != nil { return fmt.Errorf("failed to build connection string: %w", err) } @@ -192,8 +205,13 @@ Examples: return exitWithCode(ExitInvalidParameters, err) } - // Build connection string for testing - connectionString, err := buildConnectionString(service, dbTestConnectionPooled, dbTestConnectionRole, false, cmd.ErrOrStderr()) + // Build connection string for testing with password (if available) + connectionString, err := password.BuildConnectionString(service, password.ConnectionStringOptions{ + Pooled: dbTestConnectionPooled, + Role: dbTestConnectionRole, + PasswordMode: password.PasswordOptional, + WarnWriter: cmd.ErrOrStderr(), + }) if err != nil { return exitWithCode(ExitInvalidParameters, fmt.Errorf("failed to build connection string: %w", err)) } @@ -204,7 +222,7 @@ Examples: } // Test the connection - return testDatabaseConnection(connectionString, dbTestConnectionTimeout, service, cmd) + return testDatabaseConnection(cmd.Context(), connectionString, dbTestConnectionTimeout, cmd) }, } @@ -254,62 +272,6 @@ func getServicePassword(service api.Service) (string, error) { return passwd, nil } -// buildConnectionString creates a PostgreSQL connection string from service details -func buildConnectionString(service api.Service, pooled bool, role string, withPassword bool, output io.Writer) (string, error) { - if service.Endpoint == nil { - return "", fmt.Errorf("service endpoint not available") - } - - var endpoint *api.Endpoint - var host string - var port int - - // Use pooler endpoint if requested and available, otherwise use direct endpoint - if pooled && service.ConnectionPooler != nil && service.ConnectionPooler.Endpoint != nil { - endpoint = service.ConnectionPooler.Endpoint - } else { - // If pooled was requested but no pooler is available, warn the user - if pooled { - fmt.Fprintf(output, "⚠️ Warning: Connection pooler not available for this service, using direct connection\n") - } - endpoint = service.Endpoint - } - - if endpoint.Host == nil { - return "", fmt.Errorf("endpoint host not available") - } - host = *endpoint.Host - - if endpoint.Port != nil { - port = *endpoint.Port - } else { - port = 5432 // Default PostgreSQL port - } - - // Database is always "tsdb" for TimescaleDB/PostgreSQL services - database := "tsdb" - - // Build connection string in PostgreSQL URI format - var connectionString string - if withPassword { - // Get password from storage if requested - passwd, err := getServicePassword(service) - if err != nil { - return "", err - } - - // Include password in connection string - connectionString = fmt.Sprintf("postgresql://%s:%s@%s:%d/%s?sslmode=require", role, passwd, host, port, database) - } else { - // Build connection string without password (default behavior) - // Password is handled separately via PGPASSWORD env var or ~/.pgpass file - // This ensures credentials are never visible in process arguments - connectionString = fmt.Sprintf("postgresql://%s@%s:%d/%s?sslmode=require", role, host, port, database) - } - - return connectionString, nil -} - // getServiceDetails is a helper that handles common service lookup logic and returns the service details func getServiceDetails(cmd *cobra.Command, args []string) (api.Service, error) { // Get config @@ -350,7 +312,7 @@ func getServiceDetails(cmd *cobra.Command, args []string) (api.Service, error) { } // Fetch service details - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) defer cancel() resp, err := client.GetProjectsProjectIdServicesServiceIdWithResponse(ctx, projectID, serviceID) @@ -437,50 +399,18 @@ func buildPsqlCommand(connectionString, psqlPath string, additionalFlags []strin return psqlCmd } -// buildConnectionConfig creates a pgx connection config with proper password handling -func buildConnectionConfig(connectionString string, service api.Service) (*pgx.ConnConfig, error) { - // Parse the connection string first to validate it - config, err := pgx.ParseConfig(connectionString) - if err != nil { - return nil, err - } - - // Set password from keyring storage if available - // pgpass storage works automatically since pgx checks ~/.pgpass file - storage := password.GetPasswordStorage() - if _, isKeyring := storage.(*password.KeyringStorage); isKeyring { - if password, err := storage.Get(service); err == nil && password != "" { - config.Password = password - } - // Note: If keyring password retrieval fails, we let pgx try without it - // This allows fallback to other authentication methods - } - - return config, nil -} - // testDatabaseConnection tests the database connection and returns appropriate exit codes -func testDatabaseConnection(connectionString string, timeout time.Duration, service api.Service, cmd *cobra.Command) error { +func testDatabaseConnection(ctx context.Context, connectionString string, timeout time.Duration, cmd *cobra.Command) error { // Create context with timeout if specified - var ctx context.Context var cancel context.CancelFunc - if timeout > 0 { - ctx, cancel = context.WithTimeout(context.Background(), timeout) + ctx, cancel = context.WithTimeout(ctx, timeout) defer cancel() - } else { - ctx = context.Background() - } - - // Build connection config with proper password handling - config, err := buildConnectionConfig(connectionString, service) - if err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "Failed to build connection config: %v\n", err) - return exitWithCode(ExitInvalidParameters, err) } // Attempt to connect to the database - conn, err := pgx.ConnectConfig(ctx, config) + // The connection string already includes the password (if available) thanks to PasswordOptional mode + conn, err := pgx.Connect(ctx, connectionString) if err != nil { // Determine the appropriate exit code based on error type if isContextDeadlineExceeded(err) { diff --git a/internal/tiger/cmd/db_test.go b/internal/tiger/cmd/db_test.go index c046d9ec..51c830f3 100644 --- a/internal/tiger/cmd/db_test.go +++ b/internal/tiger/cmd/db_test.go @@ -2,6 +2,7 @@ package cmd import ( "bytes" + "context" "fmt" "os" "strings" @@ -121,7 +122,7 @@ func TestDBConnectionString_NoAuth(t *testing.T) { func TestDBConnectionString_PoolerWarning(t *testing.T) { // This test demonstrates that the warning functionality works - // by directly testing the buildConnectionString function + // by directly testing the password.BuildConnectionString function // Service without connection pooler service := api.Service{ @@ -132,13 +133,16 @@ func TestDBConnectionString_PoolerWarning(t *testing.T) { ConnectionPooler: nil, // No pooler available } - // Create a test command to capture stderr - cmd := &cobra.Command{} + // Create a buffer to capture stderr errBuf := new(bytes.Buffer) - cmd.SetErr(errBuf) // Request pooled connection when pooler is not available - connectionString, err := buildConnectionString(service, true, "tsdbadmin", false, cmd.ErrOrStderr()) + connectionString, err := password.BuildConnectionString(service, password.ConnectionStringOptions{ + Pooled: true, + Role: "tsdbadmin", + PasswordMode: password.PasswordExclude, + WarnWriter: errBuf, + }) if err != nil { t.Fatalf("Unexpected error: %v", err) @@ -412,85 +416,6 @@ func TestBuildPsqlCommand_PgpassStorage_NoEnvVar(t *testing.T) { } } -func TestBuildConnectionConfig_KeyringPassword(t *testing.T) { - // This test verifies that buildConnectionConfig properly sets password from keyring - - // Set keyring as the password storage method for this test - originalStorage := viper.GetString("password_storage") - viper.Set("password_storage", "keyring") - defer viper.Set("password_storage", originalStorage) - - // Create a test service - serviceID := "test-connection-config-service" - projectID := "test-connection-config-project" - service := api.Service{ - ServiceId: &serviceID, - ProjectId: &projectID, - } - - // Store a test password in keyring - testPassword := "test-connection-config-password-789" - storage := password.GetPasswordStorage() - err := storage.Save(service, testPassword) - if err != nil { - t.Fatalf("Failed to save test password: %v", err) - } - defer storage.Remove(service) // Clean up after test - - connectionString := "postgresql://testuser@testhost:5432/testdb?sslmode=require" - - // Call the actual production function that builds the config - config, err := buildConnectionConfig(connectionString, service) - - if err != nil { - t.Fatalf("buildConnectionConfig failed: %v", err) - } - - if config == nil { - t.Fatal("buildConnectionConfig returned nil config") - } - - // Verify that the password was set in the config - if config.Password != testPassword { - t.Errorf("Expected password '%s' to be set in config, but got '%s'", testPassword, config.Password) - } -} - -func TestBuildConnectionConfig_PgpassStorage_NoPasswordSet(t *testing.T) { - // This test verifies that buildConnectionConfig doesn't set password for pgpass storage - - // Set pgpass as the password storage method for this test - originalStorage := viper.GetString("password_storage") - viper.Set("password_storage", "pgpass") - defer viper.Set("password_storage", originalStorage) - - // Create a test service - serviceID := "test-connection-config-pgpass" - projectID := "test-connection-config-project" - service := api.Service{ - ServiceId: &serviceID, - ProjectId: &projectID, - } - - connectionString := "postgresql://testuser@testhost:5432/testdb?sslmode=require" - - // Call the actual production function that builds the config - config, err := buildConnectionConfig(connectionString, service) - - if err != nil { - t.Fatalf("buildConnectionConfig failed: %v", err) - } - - if config == nil { - t.Fatal("buildConnectionConfig returned nil config") - } - - // Verify that no password was set in the config (pgx will check ~/.pgpass automatically) - if config.Password != "" { - t.Errorf("Expected no password to be set in config for pgpass storage, but got '%s'", config.Password) - } -} - func TestSeparateServiceAndPsqlArgs(t *testing.T) { testCases := []struct { name string @@ -652,8 +577,8 @@ func TestTestDatabaseConnection_InvalidConnectionString(t *testing.T) { // Test with malformed connection string (should return ExitInvalidParameters) invalidConnectionString := "this is not a valid connection string at all" - service := api.Service{} // Dummy service for test - err := testDatabaseConnection(invalidConnectionString, 1, service, cmd) + ctx := context.Background() + err := testDatabaseConnection(ctx, invalidConnectionString, 1*time.Second, cmd) if err == nil { t.Error("Expected error for invalid connection string") @@ -681,9 +606,9 @@ func TestTestDatabaseConnection_Timeout(t *testing.T) { // Use a connection string to a non-routable IP to test timeout timeoutConnectionString := "postgresql://user:pass@192.0.2.1:5432/db?sslmode=disable&connect_timeout=1" - service := api.Service{} // Dummy service for test + ctx := context.Background() start := time.Now() - err := testDatabaseConnection(timeoutConnectionString, 1, service, cmd) // 1 second timeout + err := testDatabaseConnection(ctx, timeoutConnectionString, 1*time.Second, cmd) // 1 second timeout duration := time.Since(start) if err == nil { @@ -880,7 +805,12 @@ func TestBuildConnectionString(t *testing.T) { errBuf := new(bytes.Buffer) cmd.SetErr(errBuf) - result, err := buildConnectionString(tc.service, tc.pooled, tc.role, false, cmd.ErrOrStderr()) + result, err := password.BuildConnectionString(tc.service, password.ConnectionStringOptions{ + Pooled: tc.pooled, + Role: tc.role, + PasswordMode: password.PasswordExclude, + WarnWriter: cmd.ErrOrStderr(), + }) if tc.expectError { if err == nil { @@ -1016,182 +946,6 @@ func TestDBTestConnection_TimeoutParsing(t *testing.T) { } } -func TestBuildConnectionString_WithPassword_KeyringStorage(t *testing.T) { - // Set keyring as the password storage method for this test - originalStorage := viper.GetString("password_storage") - viper.Set("password_storage", "keyring") - defer viper.Set("password_storage", originalStorage) - - // Create a test service - serviceID := "test-password-service" - projectID := "test-password-project" - host := "test-host.com" - port := 5432 - service := api.Service{ - ServiceId: &serviceID, - ProjectId: &projectID, - Endpoint: &api.Endpoint{ - Host: &host, - Port: &port, - }, - } - - // Store a test password in keyring - testPassword := "test-password-keyring-123" - storage := password.GetPasswordStorage() - err := storage.Save(service, testPassword) - if err != nil { - t.Fatalf("Failed to save test password: %v", err) - } - defer storage.Remove(service) // Clean up after test - - // Create a test command - cmd := &cobra.Command{} - - // Call buildConnectionString with withPassword=true - result, err := buildConnectionString(service, false, "tsdbadmin", true, cmd.ErrOrStderr()) - - if err != nil { - t.Fatalf("buildConnectionString failed: %v", err) - } - - // Verify that the password is included in the result - expectedResult := fmt.Sprintf("postgresql://tsdbadmin:%s@%s:%d/tsdb?sslmode=require", testPassword, host, port) - if result != expectedResult { - t.Errorf("Expected connection string with password '%s', got '%s'", expectedResult, result) - } - - // Verify the password is actually in the connection string - if !strings.Contains(result, testPassword) { - t.Errorf("Password '%s' not found in connection string: %s", testPassword, result) - } -} - -func TestBuildConnectionString_WithPassword_PgpassStorage(t *testing.T) { - // Set pgpass as the password storage method for this test - originalStorage := viper.GetString("password_storage") - viper.Set("password_storage", "pgpass") - defer viper.Set("password_storage", originalStorage) - - // Create a test service with endpoint information (required for pgpass) - serviceID := "test-pgpass-service" - projectID := "test-pgpass-project" - host := "test-pgpass-host.com" - port := 5432 - service := api.Service{ - ServiceId: &serviceID, - ProjectId: &projectID, - Endpoint: &api.Endpoint{ - Host: &host, - Port: &port, - }, - } - - // Store a test password in pgpass - testPassword := "test-password-pgpass-456" - storage := password.GetPasswordStorage() - err := storage.Save(service, testPassword) - if err != nil { - t.Fatalf("Failed to save test password: %v", err) - } - defer storage.Remove(service) // Clean up after test - - // Create a test command - cmd := &cobra.Command{} - - // Call buildConnectionString with withPassword=true - result, err := buildConnectionString(service, false, "tsdbadmin", true, cmd.ErrOrStderr()) - - if err != nil { - t.Fatalf("buildConnectionString failed: %v", err) - } - - // Verify that the password is included in the result - expectedResult := fmt.Sprintf("postgresql://tsdbadmin:%s@%s:%d/tsdb?sslmode=require", testPassword, host, port) - if result != expectedResult { - t.Errorf("Expected connection string with password '%s', got '%s'", expectedResult, result) - } - - // Verify the password is actually in the connection string - if !strings.Contains(result, testPassword) { - t.Errorf("Password '%s' not found in connection string: %s", testPassword, result) - } -} - -func TestBuildConnectionString_WithPassword_NoStorage(t *testing.T) { - // Set no storage as the password storage method for this test - originalStorage := viper.GetString("password_storage") - viper.Set("password_storage", "none") - defer viper.Set("password_storage", originalStorage) - - // Create a test service - serviceID := "test-nostorage-service" - projectID := "test-nostorage-project" - host := "test-host.com" - port := 5432 - service := api.Service{ - ServiceId: &serviceID, - ProjectId: &projectID, - Endpoint: &api.Endpoint{ - Host: &host, - Port: &port, - }, - } - - // Create a test command - cmd := &cobra.Command{} - - // Call buildConnectionString with withPassword=true - should fail - _, err := buildConnectionString(service, false, "tsdbadmin", true, cmd.ErrOrStderr()) - - if err == nil { - t.Fatal("Expected error when password storage is disabled, but got none") - } - - // Verify we get the expected error message - expectedError := "password storage is disabled (--password-storage=none)" - if !strings.Contains(err.Error(), expectedError) { - t.Errorf("Expected error message to contain '%s', got: %v", expectedError, err) - } -} - -func TestBuildConnectionString_WithPassword_NoPasswordAvailable(t *testing.T) { - // Set keyring as the password storage method for this test - originalStorage := viper.GetString("password_storage") - viper.Set("password_storage", "keyring") - defer viper.Set("password_storage", originalStorage) - - // Create a test service (but don't store any password for it) - serviceID := "test-nopassword-service" - projectID := "test-nopassword-project" - host := "test-host.com" - port := 5432 - service := api.Service{ - ServiceId: &serviceID, - ProjectId: &projectID, - Endpoint: &api.Endpoint{ - Host: &host, - Port: &port, - }, - } - - // Create a test command - cmd := &cobra.Command{} - - // Call buildConnectionString with withPassword=true - should fail - _, err := buildConnectionString(service, false, "tsdbadmin", true, cmd.ErrOrStderr()) - - if err == nil { - t.Fatal("Expected error when no password is available, but got none") - } - - // Verify we get the expected error message - expectedError := "no password found in keyring for this service" - if !strings.Contains(err.Error(), expectedError) { - t.Errorf("Expected error message to contain '%s', got: %v", expectedError, err) - } -} - func TestDBConnectionString_WithPassword(t *testing.T) { // This test verifies the end-to-end --with-password flag functionality // using direct function testing since full integration would require a real service @@ -1224,11 +978,16 @@ func TestDBConnectionString_WithPassword(t *testing.T) { } defer storage.Remove(service) // Clean up after test - // Test buildConnectionString without password (default behavior) + // Test password.BuildConnectionString without password (default behavior) cmd := &cobra.Command{} - baseConnectionString, err := buildConnectionString(service, false, "tsdbadmin", false, cmd.ErrOrStderr()) + baseConnectionString, err := password.BuildConnectionString(service, password.ConnectionStringOptions{ + Pooled: false, + Role: "tsdbadmin", + PasswordMode: password.PasswordExclude, + WarnWriter: cmd.ErrOrStderr(), + }) if err != nil { - t.Fatalf("buildConnectionString failed: %v", err) + t.Fatalf("BuildConnectionString failed: %v", err) } expectedBase := fmt.Sprintf("postgresql://tsdbadmin@%s:%d/tsdb?sslmode=require", host, port) @@ -1241,10 +1000,15 @@ func TestDBConnectionString_WithPassword(t *testing.T) { t.Errorf("Base connection string should not contain password, but it does: %s", baseConnectionString) } - // Test buildConnectionString with password (simulating --with-password flag) - connectionStringWithPassword, err := buildConnectionString(service, false, "tsdbadmin", true, cmd.ErrOrStderr()) + // Test password.BuildConnectionString with password (simulating --with-password flag) + connectionStringWithPassword, err := password.BuildConnectionString(service, password.ConnectionStringOptions{ + Pooled: false, + Role: "tsdbadmin", + PasswordMode: password.PasswordRequired, + WarnWriter: cmd.ErrOrStderr(), + }) if err != nil { - t.Fatalf("buildConnectionString with password failed: %v", err) + t.Fatalf("BuildConnectionString with password failed: %v", err) } expectedWithPassword := fmt.Sprintf("postgresql://tsdbadmin:%s@%s:%d/tsdb?sslmode=require", testPassword, host, port) @@ -1257,35 +1021,3 @@ func TestDBConnectionString_WithPassword(t *testing.T) { t.Errorf("Connection string with password should contain '%s', but it doesn't: %s", testPassword, connectionStringWithPassword) } } - -func TestBuildConnectionString_WithPassword_InvalidServiceEndpoint(t *testing.T) { - // Set keyring as the password storage method for this test - originalStorage := viper.GetString("password_storage") - viper.Set("password_storage", "keyring") - defer viper.Set("password_storage", originalStorage) - - // Create a test service without endpoint (invalid) - serviceID := "test-invalid-service" - projectID := "test-invalid-project" - service := api.Service{ - ServiceId: &serviceID, - ProjectId: &projectID, - Endpoint: nil, // Invalid - no endpoint - } - - // Create a test command - cmd := &cobra.Command{} - - // Call buildConnectionString with withPassword=true - should fail - _, err := buildConnectionString(service, false, "tsdbadmin", true, cmd.ErrOrStderr()) - - if err == nil { - t.Fatal("Expected error for invalid service endpoint, but got none") - } - - // Verify we get an endpoint error - expectedError := "service endpoint not available" - if !strings.Contains(err.Error(), expectedError) { - t.Errorf("Expected error message to contain '%s', got: %v", expectedError, err) - } -} diff --git a/internal/tiger/cmd/service.go b/internal/tiger/cmd/service.go index 182ff19e..d17a6634 100644 --- a/internal/tiger/cmd/service.go +++ b/internal/tiger/cmd/service.go @@ -809,7 +809,16 @@ func prepareServiceForOutput(service api.Service, withPassword bool, output io.W } // Build connection string - connectionString, err := buildConnectionString(service, false, "tsdbadmin", withPassword, output) + passwordMode := password.PasswordExclude + if withPassword { + passwordMode = password.PasswordRequired + } + connectionString, err := password.BuildConnectionString(service, password.ConnectionStringOptions{ + Pooled: false, + Role: "tsdbadmin", + PasswordMode: passwordMode, + WarnWriter: output, + }) if err == nil { outputSvc.ConnectionString = &connectionString } diff --git a/internal/tiger/mcp/db_tools.go b/internal/tiger/mcp/db_tools.go new file mode 100644 index 00000000..c71d92f8 --- /dev/null +++ b/internal/tiger/mcp/db_tools.go @@ -0,0 +1,229 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/jackc/pgx/v5" + "github.com/modelcontextprotocol/go-sdk/mcp" + "go.uber.org/zap" + + "github.com/timescale/tiger-cli/internal/tiger/logging" + "github.com/timescale/tiger-cli/internal/tiger/password" + "github.com/timescale/tiger-cli/internal/tiger/util" +) + +// DBExecuteQueryInput represents input for tiger_db_execute_query +type DBExecuteQueryInput struct { + ServiceID string `json:"service_id"` + Query string `json:"query"` + Parameters []any `json:"parameters,omitempty"` + TimeoutSeconds int `json:"timeout_seconds,omitempty"` + Role string `json:"role,omitempty"` + Pooled bool `json:"pooled,omitempty"` +} + +func (DBExecuteQueryInput) Schema() *jsonschema.Schema { + schema := util.Must(jsonschema.For[DBExecuteQueryInput](nil)) + + schema.Properties["service_id"].Description = "The unique identifier of the service (10-character alphanumeric string). Use service_list to find service IDs." + schema.Properties["service_id"].Examples = []any{"e6ue9697jf", "u8me885b93"} + schema.Properties["service_id"].Pattern = "^[a-z0-9]{10}$" + + schema.Properties["query"].Description = "PostgreSQL query to execute" + + schema.Properties["parameters"].Description = "Query parameters for parameterized queries. Values are substituted for $1, $2, etc. placeholders in the query." + schema.Properties["parameters"].Examples = []any{[]any{1, "alice"}, []any{"2024-01-01", 100}} + + schema.Properties["timeout_seconds"].Description = "Query timeout in seconds" + schema.Properties["timeout_seconds"].Minimum = util.Ptr(0.0) + schema.Properties["timeout_seconds"].Default = util.Must(json.Marshal(30)) + schema.Properties["timeout_seconds"].Examples = []any{10, 30, 60} + + schema.Properties["role"].Description = "Database role/username to connect as" + schema.Properties["role"].Default = util.Must(json.Marshal("tsdbadmin")) + schema.Properties["role"].Examples = []any{"tsdbadmin", "readonly", "postgres"} + + schema.Properties["pooled"].Description = "Use connection pooling (if available for the service)" + schema.Properties["pooled"].Default = util.Must(json.Marshal(false)) + schema.Properties["pooled"].Examples = []any{false, true} + + return schema +} + +// DBExecuteQueryColumn represents a column in the query result +type DBExecuteQueryColumn struct { + Name string `json:"name"` + Type string `json:"type"` +} + +// DBExecuteQueryOutput represents output for tiger_db_execute_query +type DBExecuteQueryOutput struct { + Columns []DBExecuteQueryColumn `json:"columns,omitempty"` + Rows [][]any `json:"rows,omitempty"` + RowsAffected int64 `json:"rows_affected"` + ExecutionTime string `json:"execution_time"` +} + +func (DBExecuteQueryOutput) Schema() *jsonschema.Schema { + schema := util.Must(jsonschema.For[DBExecuteQueryOutput](nil)) + + schema.Properties["columns"].Description = "Column metadata from the query result including name and PostgreSQL type" + schema.Properties["columns"].Examples = []any{[]DBExecuteQueryColumn{ + {Name: "id", Type: "int4"}, + {Name: "name", Type: "text"}, + {Name: "created_at", Type: "timestamptz"}, + }} + + schema.Properties["rows"].Description = "Result rows as arrays of values. Empty for commands that don't return rows (INSERT, UPDATE, DELETE, etc.)" + schema.Properties["rows"].Examples = []any{[][]any{{1, "alice", "2024-01-01"}, {2, "bob", "2024-01-02"}}} + + schema.Properties["rows_affected"].Description = "Number of rows affected by the query. For SELECT, this is the number of rows returned. For INSERT/UPDATE/DELETE, this is the number of rows modified. Returns 0 for statements that don't return or modify rows (e.g. CREATE TABLE)." + schema.Properties["rows_affected"].Examples = []any{5, 42, 1000} + + schema.Properties["execution_time"].Description = "Query execution time as a human-readable duration string" + schema.Properties["execution_time"].Examples = []any{"123ms", "1.5s", "45.2µs"} + + return schema +} + +// registerDatabaseTools registers database operation tools with comprehensive schemas and descriptions +func (s *Server) registerDatabaseTools() { + mcp.AddTool(s.mcpServer, &mcp.Tool{ + Name: "db_execute_query", + Title: "Execute SQL Query", + Description: `Execute a single SQL query against a service database. + +This tool connects to a PostgreSQL database service in TigerData Cloud and executes the provided SQL query, returning the results with column names, row data, and execution metadata. Multi-statement queries are not supported. + +WARNING: Use with caution - this tool can execute any SQL statement including INSERT, UPDATE, DELETE, and DDL commands. Always review queries before execution.`, + InputSchema: DBExecuteQueryInput{}.Schema(), + OutputSchema: DBExecuteQueryOutput{}.Schema(), + Annotations: &mcp.ToolAnnotations{ + DestructiveHint: util.Ptr(true), // Can execute destructive SQL + Title: "Execute SQL Query", + }, + }, s.handleDBExecuteQuery) +} + +// handleDBExecuteQuery handles the tiger_db_execute_query MCP tool +func (s *Server) handleDBExecuteQuery(ctx context.Context, req *mcp.CallToolRequest, input DBExecuteQueryInput) (*mcp.CallToolResult, DBExecuteQueryOutput, error) { + // Load config and validate project ID + cfg, err := s.loadConfigWithProjectID() + if err != nil { + return nil, DBExecuteQueryOutput{}, err + } + + // Create fresh API client with current credentials + apiClient, err := s.createAPIClient() + if err != nil { + return nil, DBExecuteQueryOutput{}, err + } + + // Convert timeout in seconds to time.Duration + timeout := time.Duration(input.TimeoutSeconds) * time.Second + + logging.Debug("MCP: Executing database query", + zap.String("project_id", cfg.ProjectID), + zap.String("service_id", input.ServiceID), + zap.Duration("timeout", timeout), + zap.String("role", input.Role), + zap.Bool("pooled", input.Pooled), + ) + + // Get service details to construct connection string + serviceResp, err := apiClient.GetProjectsProjectIdServicesServiceIdWithResponse(ctx, cfg.ProjectID, input.ServiceID) + if err != nil { + return nil, DBExecuteQueryOutput{}, fmt.Errorf("failed to get service details: %w", err) + } + + switch serviceResp.StatusCode() { + case 200: + if serviceResp.JSON200 == nil { + return nil, DBExecuteQueryOutput{}, fmt.Errorf("empty response from API") + } + case 401: + return nil, DBExecuteQueryOutput{}, fmt.Errorf("authentication failed: invalid API key") + case 403: + return nil, DBExecuteQueryOutput{}, fmt.Errorf("permission denied: insufficient access to service") + case 404: + return nil, DBExecuteQueryOutput{}, fmt.Errorf("service '%s' not found in project '%s'", input.ServiceID, cfg.ProjectID) + default: + return nil, DBExecuteQueryOutput{}, fmt.Errorf("API request failed with status %d", serviceResp.StatusCode()) + } + + service := *serviceResp.JSON200 + + // Build connection string with password + connString, err := password.BuildConnectionString(service, password.ConnectionStringOptions{ + Pooled: input.Pooled, + Role: input.Role, + PasswordMode: password.PasswordRequired, // MCP always requires password + }) + if err != nil { + return nil, DBExecuteQueryOutput{}, fmt.Errorf("failed to build connection string: %w", err) + } + + // Create query context with timeout + queryCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + // Connect to database + conn, err := pgx.Connect(queryCtx, connString) + if err != nil { + return nil, DBExecuteQueryOutput{}, fmt.Errorf("failed to connect to database: %w", err) + } + defer conn.Close(context.Background()) + + // Execute query and measure time + startTime := time.Now() + rows, err := conn.Query(queryCtx, input.Query, input.Parameters...) + if err != nil { + return nil, DBExecuteQueryOutput{}, fmt.Errorf("query execution failed: %w", err) + } + defer rows.Close() + + // Get column metadata from field descriptions + fieldDescriptions := rows.FieldDescriptions() + var columns []DBExecuteQueryColumn + for _, fd := range fieldDescriptions { + // Get the type name from the connection's type map + dataType, ok := conn.TypeMap().TypeForOID(fd.DataTypeOID) + typeName := "unknown" + if ok && dataType != nil { + typeName = dataType.Name + } + columns = append(columns, DBExecuteQueryColumn{ + Name: fd.Name, + Type: typeName, + }) + } + + // Collect all rows + var resultRows [][]any + for rows.Next() { + // Scan values into generic interface slice + values, err := rows.Values() + if err != nil { + return nil, DBExecuteQueryOutput{}, fmt.Errorf("failed to scan row: %w", err) + } + resultRows = append(resultRows, values) + } + + // Check for errors during iteration + if rows.Err() != nil { + return nil, DBExecuteQueryOutput{}, fmt.Errorf("error during row iteration: %w", rows.Err()) + } + + output := DBExecuteQueryOutput{ + Columns: columns, + Rows: resultRows, + RowsAffected: rows.CommandTag().RowsAffected(), + ExecutionTime: time.Since(startTime).String(), + } + + return nil, output, nil +} diff --git a/internal/tiger/mcp/server.go b/internal/tiger/mcp/server.go index 2d90287a..d4143d04 100644 --- a/internal/tiger/mcp/server.go +++ b/internal/tiger/mcp/server.go @@ -59,6 +59,9 @@ func (s *Server) registerTools(ctx context.Context) { // Service management tools s.registerServiceTools() + // Database operation tools + s.registerDatabaseTools() + // TODO: Register more tool groups // Register remote docs MCP server proxy @@ -93,7 +96,7 @@ func (s *Server) loadConfigWithProjectID() (*config.Config, error) { } if cfg.ProjectID == "" { - return nil, fmt.Errorf("project ID is required. Please run 'tiger auth login' with --project-id") + return nil, fmt.Errorf("project ID is required. Please run 'tiger auth login'") } return cfg, nil } diff --git a/internal/tiger/password/connection.go b/internal/tiger/password/connection.go new file mode 100644 index 00000000..38aaf4e8 --- /dev/null +++ b/internal/tiger/password/connection.go @@ -0,0 +1,156 @@ +package password + +import ( + "fmt" + "io" + + "github.com/timescale/tiger-cli/internal/tiger/api" +) + +// PasswordMode determines how passwords are handled in connection strings +type PasswordMode int + +const ( + // PasswordExclude means don't include password in connection string (default) + // Connection will rely on PGPASSWORD env var or ~/.pgpass file + PasswordExclude PasswordMode = iota + + // PasswordRequired means include password in connection string, return error if unavailable + // Used when user explicitly requests --with-password flag + PasswordRequired + + // PasswordOptional means include password if available, but don't error if unavailable + // Used for connection testing and psql launching where we want best-effort password inclusion + PasswordOptional +) + +// ConnectionStringOptions configures how the connection string is built +type ConnectionStringOptions struct { + // Pooled determines whether to use the pooler endpoint (if available) + Pooled bool + + // Role is the database role/username to use (e.g., "tsdbadmin") + Role string + + // PasswordMode determines how passwords are handled + PasswordMode PasswordMode + + // WarnWriter is an optional writer for warning messages (e.g., when pooler is requested but not available) + // If nil, warnings are suppressed + WarnWriter io.Writer +} + +// BuildConnectionString creates a PostgreSQL connection string from service details +// +// The function supports various configuration options through ConnectionStringOptions: +// - Pooled connections (if available on the service) +// - With or without password embedded in the URI +// - Custom database role/username +// - Optional warning output when pooler is unavailable +// +// Examples: +// +// // Simple connection string without password (for use with PGPASSWORD or ~/.pgpass) +// connStr, err := BuildConnectionString(service, ConnectionStringOptions{ +// Role: "tsdbadmin", +// WithPassword: false, +// }) +// +// // Connection string with password embedded +// connStr, err := BuildConnectionString(service, ConnectionStringOptions{ +// Role: "tsdbadmin", +// WithPassword: true, +// }) +// +// // Pooled connection with warnings +// connStr, err := BuildConnectionString(service, ConnectionStringOptions{ +// Pooled: true, +// Role: "tsdbadmin", +// WithPassword: true, +// WarnWriter: os.Stderr, +// }) +func BuildConnectionString(service api.Service, opts ConnectionStringOptions) (string, error) { + if service.Endpoint == nil { + return "", fmt.Errorf("service endpoint not available") + } + + var endpoint *api.Endpoint + var host string + var port int + + // Use pooler endpoint if requested and available, otherwise use direct endpoint + if opts.Pooled && service.ConnectionPooler != nil && service.ConnectionPooler.Endpoint != nil { + endpoint = service.ConnectionPooler.Endpoint + } else { + // If pooled was requested but no pooler is available, warn if writer is provided + if opts.Pooled && opts.WarnWriter != nil { + fmt.Fprintf(opts.WarnWriter, "⚠️ Warning: Connection pooler not available for this service, using direct connection\n") + } + endpoint = service.Endpoint + } + + if endpoint.Host == nil { + return "", fmt.Errorf("endpoint host not available") + } + host = *endpoint.Host + + if endpoint.Port != nil { + port = *endpoint.Port + } else { + port = 5432 // Default PostgreSQL port + } + + // Database is always "tsdb" for TimescaleDB/PostgreSQL services + database := "tsdb" + + // Build connection string in PostgreSQL URI format + var connectionString string + + switch opts.PasswordMode { + case PasswordRequired: + // Password is required - error if unavailable + storage := GetPasswordStorage() + password, err := storage.Get(service) + if err != nil { + // Provide specific error messages based on storage type + switch storage.(type) { + case *NoStorage: + return "", fmt.Errorf("password storage is disabled (--password-storage=none)") + case *KeyringStorage: + return "", fmt.Errorf("no password found in keyring for this service") + case *PgpassStorage: + return "", fmt.Errorf("no password found in ~/.pgpass for this service") + default: + return "", fmt.Errorf("failed to retrieve password: %w", err) + } + } + + if password == "" { + return "", fmt.Errorf("no password available for service") + } + + // Include password in connection string + connectionString = fmt.Sprintf("postgresql://%s:%s@%s:%d/%s?sslmode=require", opts.Role, password, host, port, database) + + case PasswordOptional: + // Try to include password, but don't error if unavailable + storage := GetPasswordStorage() + password, err := storage.Get(service) + + // Only include password if we successfully retrieved it + if err == nil && password != "" { + connectionString = fmt.Sprintf("postgresql://%s:%s@%s:%d/%s?sslmode=require", opts.Role, password, host, port, database) + } else { + // Fall back to connection string without password + connectionString = fmt.Sprintf("postgresql://%s@%s:%d/%s?sslmode=require", opts.Role, host, port, database) + } + + default: // PasswordExclude + // Build connection string without password (default behavior) + // Password is handled separately via PGPASSWORD env var or ~/.pgpass file + // This ensures credentials are never visible in process arguments + connectionString = fmt.Sprintf("postgresql://%s@%s:%d/%s?sslmode=require", opts.Role, host, port, database) + } + + return connectionString, nil +} diff --git a/internal/tiger/password/connection_test.go b/internal/tiger/password/connection_test.go new file mode 100644 index 00000000..e34f3dd7 --- /dev/null +++ b/internal/tiger/password/connection_test.go @@ -0,0 +1,442 @@ +package password + +import ( + "bytes" + "fmt" + "strings" + "testing" + + "github.com/spf13/viper" + + "github.com/timescale/tiger-cli/internal/tiger/api" + "github.com/timescale/tiger-cli/internal/tiger/util" +) + +func TestBuildConnectionString_Basic(t *testing.T) { + testCases := []struct { + name string + service api.Service + opts ConnectionStringOptions + expectedString string + expectError bool + expectWarning bool + }{ + { + name: "Basic connection string without password", + service: api.Service{ + Endpoint: &api.Endpoint{ + Host: util.Ptr("test-host.tigerdata.com"), + Port: util.Ptr(5432), + }, + }, + opts: ConnectionStringOptions{ + Pooled: false, + Role: "tsdbadmin", + PasswordMode: PasswordExclude, + }, + expectedString: "postgresql://tsdbadmin@test-host.tigerdata.com:5432/tsdb?sslmode=require", + expectError: false, + }, + { + name: "Connection string with custom role", + service: api.Service{ + Endpoint: &api.Endpoint{ + Host: util.Ptr("test-host.tigerdata.com"), + Port: util.Ptr(5432), + }, + }, + opts: ConnectionStringOptions{ + Pooled: false, + Role: "readonly", + PasswordMode: PasswordExclude, + }, + expectedString: "postgresql://readonly@test-host.tigerdata.com:5432/tsdb?sslmode=require", + expectError: false, + }, + { + name: "Connection string with default port", + service: api.Service{ + Endpoint: &api.Endpoint{ + Host: util.Ptr("test-host.tigerdata.com"), + Port: nil, // Should use default 5432 + }, + }, + opts: ConnectionStringOptions{ + Pooled: false, + Role: "tsdbadmin", + PasswordMode: PasswordExclude, + }, + expectedString: "postgresql://tsdbadmin@test-host.tigerdata.com:5432/tsdb?sslmode=require", + expectError: false, + }, + { + name: "Pooled connection string", + service: api.Service{ + Endpoint: &api.Endpoint{ + Host: util.Ptr("direct-host.tigerdata.com"), + Port: util.Ptr(5432), + }, + ConnectionPooler: &api.ConnectionPooler{ + Endpoint: &api.Endpoint{ + Host: util.Ptr("pooler-host.tigerdata.com"), + Port: util.Ptr(6432), + }, + }, + }, + opts: ConnectionStringOptions{ + Pooled: true, + Role: "tsdbadmin", + PasswordMode: PasswordExclude, + }, + expectedString: "postgresql://tsdbadmin@pooler-host.tigerdata.com:6432/tsdb?sslmode=require", + expectError: false, + }, + { + name: "Pooled connection fallback to direct when pooler unavailable", + service: api.Service{ + Endpoint: &api.Endpoint{ + Host: util.Ptr("direct-host.tigerdata.com"), + Port: util.Ptr(5432), + }, + ConnectionPooler: nil, // No pooler available + }, + opts: ConnectionStringOptions{ + Pooled: true, + Role: "tsdbadmin", + PasswordMode: PasswordExclude, + WarnWriter: new(bytes.Buffer), // Enable warnings + }, + expectedString: "postgresql://tsdbadmin@direct-host.tigerdata.com:5432/tsdb?sslmode=require", + expectError: false, + expectWarning: true, // Should warn about pooler not available + }, + { + name: "Error when no endpoint available", + service: api.Service{ + Endpoint: nil, + }, + opts: ConnectionStringOptions{ + Pooled: false, + Role: "tsdbadmin", + PasswordMode: PasswordExclude, + }, + expectError: true, + }, + { + name: "Error when no host available", + service: api.Service{ + Endpoint: &api.Endpoint{ + Host: nil, + Port: util.Ptr(5432), + }, + }, + opts: ConnectionStringOptions{ + Pooled: false, + Role: "tsdbadmin", + PasswordMode: PasswordExclude, + }, + expectError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // If expecting a warning, create a buffer for WarnWriter + var warnBuf *bytes.Buffer + if tc.expectWarning && tc.opts.WarnWriter == nil { + warnBuf = new(bytes.Buffer) + tc.opts.WarnWriter = warnBuf + } else if !tc.expectWarning && tc.opts.WarnWriter != nil { + warnBuf = tc.opts.WarnWriter.(*bytes.Buffer) + } + + result, err := BuildConnectionString(tc.service, tc.opts) + + if tc.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if result != tc.expectedString { + t.Errorf("Expected connection string %q, got %q", tc.expectedString, result) + } + + // Check for warning message + if warnBuf != nil { + stderrOutput := warnBuf.String() + if tc.expectWarning { + if !strings.Contains(stderrOutput, "Warning: Connection pooler not available") { + t.Errorf("Expected warning about pooler not available, but got: %q", stderrOutput) + } + } else { + if stderrOutput != "" { + t.Errorf("Expected no warning, but got: %q", stderrOutput) + } + } + } + }) + } +} + +func TestBuildConnectionString_WithPassword_KeyringStorage(t *testing.T) { + // Set keyring as the password storage method for this test + originalStorage := viper.GetString("password_storage") + viper.Set("password_storage", "keyring") + defer viper.Set("password_storage", originalStorage) + + // Create a test service + serviceID := "test-password-service" + projectID := "test-password-project" + host := "test-host.com" + port := 5432 + service := api.Service{ + ServiceId: &serviceID, + ProjectId: &projectID, + Endpoint: &api.Endpoint{ + Host: &host, + Port: &port, + }, + } + + // Store a test password in keyring + testPassword := "test-password-keyring-123" + storage := GetPasswordStorage() + err := storage.Save(service, testPassword) + if err != nil { + t.Fatalf("Failed to save test password: %v", err) + } + defer storage.Remove(service) // Clean up after test + + // Call BuildConnectionString with withPassword=true + result, err := BuildConnectionString(service, ConnectionStringOptions{ + Pooled: false, + Role: "tsdbadmin", + PasswordMode: PasswordRequired, + }) + + if err != nil { + t.Fatalf("BuildConnectionString failed: %v", err) + } + + // Verify that the password is included in the result + expectedResult := fmt.Sprintf("postgresql://tsdbadmin:%s@%s:%d/tsdb?sslmode=require", testPassword, host, port) + if result != expectedResult { + t.Errorf("Expected connection string with password '%s', got '%s'", expectedResult, result) + } + + // Verify the password is actually in the connection string + if !strings.Contains(result, testPassword) { + t.Errorf("Password '%s' not found in connection string: %s", testPassword, result) + } +} + +func TestBuildConnectionString_WithPassword_PgpassStorage(t *testing.T) { + // Set pgpass as the password storage method for this test + originalStorage := viper.GetString("password_storage") + viper.Set("password_storage", "pgpass") + defer viper.Set("password_storage", originalStorage) + + // Create a test service with endpoint information (required for pgpass) + serviceID := "test-pgpass-service" + projectID := "test-pgpass-project" + host := "test-pgpass-host.com" + port := 5432 + service := api.Service{ + ServiceId: &serviceID, + ProjectId: &projectID, + Endpoint: &api.Endpoint{ + Host: &host, + Port: &port, + }, + } + + // Store a test password in pgpass + testPassword := "test-password-pgpass-456" + storage := GetPasswordStorage() + err := storage.Save(service, testPassword) + if err != nil { + t.Fatalf("Failed to save test password: %v", err) + } + defer storage.Remove(service) // Clean up after test + + // Call BuildConnectionString with withPassword=true + result, err := BuildConnectionString(service, ConnectionStringOptions{ + Pooled: false, + Role: "tsdbadmin", + PasswordMode: PasswordRequired, + }) + + if err != nil { + t.Fatalf("BuildConnectionString failed: %v", err) + } + + // Verify that the password is included in the result + expectedResult := fmt.Sprintf("postgresql://tsdbadmin:%s@%s:%d/tsdb?sslmode=require", testPassword, host, port) + if result != expectedResult { + t.Errorf("Expected connection string with password '%s', got '%s'", expectedResult, result) + } + + // Verify the password is actually in the connection string + if !strings.Contains(result, testPassword) { + t.Errorf("Password '%s' not found in connection string: %s", testPassword, result) + } +} + +func TestBuildConnectionString_WithPassword_NoStorage(t *testing.T) { + // Set no storage as the password storage method for this test + originalStorage := viper.GetString("password_storage") + viper.Set("password_storage", "none") + defer viper.Set("password_storage", originalStorage) + + // Create a test service + serviceID := "test-nostorage-service" + projectID := "test-nostorage-project" + host := "test-host.com" + port := 5432 + service := api.Service{ + ServiceId: &serviceID, + ProjectId: &projectID, + Endpoint: &api.Endpoint{ + Host: &host, + Port: &port, + }, + } + + // Call BuildConnectionString with withPassword=true - should fail + _, err := BuildConnectionString(service, ConnectionStringOptions{ + Pooled: false, + Role: "tsdbadmin", + PasswordMode: PasswordRequired, + }) + + if err == nil { + t.Fatal("Expected error when password storage is disabled, but got none") + } + + // Verify we get the expected error message + expectedError := "password storage is disabled (--password-storage=none)" + if !strings.Contains(err.Error(), expectedError) { + t.Errorf("Expected error message to contain '%s', got: %v", expectedError, err) + } +} + +func TestBuildConnectionString_WithPassword_NoPasswordAvailable(t *testing.T) { + // Set keyring as the password storage method for this test + originalStorage := viper.GetString("password_storage") + viper.Set("password_storage", "keyring") + defer viper.Set("password_storage", originalStorage) + + // Create a test service (but don't store any password for it) + serviceID := "test-nopassword-service" + projectID := "test-nopassword-project" + host := "test-host.com" + port := 5432 + service := api.Service{ + ServiceId: &serviceID, + ProjectId: &projectID, + Endpoint: &api.Endpoint{ + Host: &host, + Port: &port, + }, + } + + // Call BuildConnectionString with withPassword=true - should fail + _, err := BuildConnectionString(service, ConnectionStringOptions{ + Pooled: false, + Role: "tsdbadmin", + PasswordMode: PasswordRequired, + }) + + if err == nil { + t.Fatal("Expected error when no password is available, but got none") + } + + // Verify we get the expected error message + expectedError := "no password found in keyring for this service" + if !strings.Contains(err.Error(), expectedError) { + t.Errorf("Expected error message to contain '%s', got: %v", expectedError, err) + } +} + +func TestBuildConnectionString_WithPassword_InvalidServiceEndpoint(t *testing.T) { + // Set keyring as the password storage method for this test + originalStorage := viper.GetString("password_storage") + viper.Set("password_storage", "keyring") + defer viper.Set("password_storage", originalStorage) + + // Create a test service without endpoint (invalid) + serviceID := "test-invalid-service" + projectID := "test-invalid-project" + service := api.Service{ + ServiceId: &serviceID, + ProjectId: &projectID, + Endpoint: nil, // Invalid - no endpoint + } + + // Call BuildConnectionString with withPassword=true - should fail + _, err := BuildConnectionString(service, ConnectionStringOptions{ + Pooled: false, + Role: "tsdbadmin", + PasswordMode: PasswordRequired, + }) + + if err == nil { + t.Fatal("Expected error for invalid service endpoint, but got none") + } + + // Verify we get an endpoint error + expectedError := "service endpoint not available" + if !strings.Contains(err.Error(), expectedError) { + t.Errorf("Expected error message to contain '%s', got: %v", expectedError, err) + } +} + +func TestBuildConnectionString_PoolerWarning(t *testing.T) { + // Service without connection pooler + service := api.Service{ + Endpoint: &api.Endpoint{ + Host: util.Ptr("test-host.tigerdata.com"), + Port: util.Ptr(5432), + }, + ConnectionPooler: nil, // No pooler available + } + + // Create a buffer to capture warnings + warnBuf := new(bytes.Buffer) + + // Request pooled connection when pooler is not available + connectionString, err := BuildConnectionString(service, ConnectionStringOptions{ + Pooled: true, + Role: "tsdbadmin", + PasswordMode: PasswordExclude, + WarnWriter: warnBuf, + }) + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // Should return direct connection string + expectedString := "postgresql://tsdbadmin@test-host.tigerdata.com:5432/tsdb?sslmode=require" + if connectionString != expectedString { + t.Errorf("Expected connection string %q, got %q", expectedString, connectionString) + } + + // Should have warning message + stderrOutput := warnBuf.String() + if !strings.Contains(stderrOutput, "Warning: Connection pooler not available") { + t.Errorf("Expected warning about pooler not available, but got: %q", stderrOutput) + } + + // Verify the warning mentions using direct connection + if !strings.Contains(stderrOutput, "using direct connection") { + t.Errorf("Expected warning to mention direct connection fallback, but got: %q", stderrOutput) + } +} diff --git a/specs/spec_mcp.md b/specs/spec_mcp.md index cd24328e..993bf2ff 100644 --- a/specs/spec_mcp.md +++ b/specs/spec_mcp.md @@ -292,25 +292,38 @@ Test database connectivity. Execute a SQL query on a service database. **Parameters:** -- `service_id` (string, optional): Service ID (uses default if not provided) +- `service_id` (string, required): Service ID - `query` (string, required): SQL query to execute -- `timeout` (number, optional): Query timeout in seconds (default: 30) +- `parameters` (array, optional): Query parameters for parameterized queries. Values are substituted for $1, $2, etc. placeholders in the query. +- `timeout_seconds` (number, optional): Query timeout in seconds (default: 30) +- `role` (string, optional): Database role/username to connect as (default: tsdbadmin) +- `pooled` (boolean, optional): Use connection pooling (default: false) -**Returns:** Query results with rows, columns, and execution metadata. +**Returns:** Query results with rows, columns (including types), rows affected count, and execution metadata. **Example Response:** ```json { - "columns": ["id", "name", "created_at"], + "columns": [ + {"name": "id", "type": "int4"}, + {"name": "name", "type": "text"}, + {"name": "created_at", "type": "timestamptz"} + ], "rows": [ [1, "example", "2024-01-01T00:00:00Z"], [2, "test", "2024-01-02T00:00:00Z"] ], - "row_count": 2, - "execution_time_ms": 15 + "rows_affected": 2, + "execution_time": "15.2ms" } ``` +**Note:** +- `rows_affected` returns the number of rows returned for SELECT queries, and the number of rows modified for INSERT/UPDATE/DELETE queries +- `columns` includes both the column name and PostgreSQL data type for each column +- Empty `rows` array for commands that don't return rows (INSERT, UPDATE, DELETE, DDL commands) +- For parity with `tiger db connect` command, supports custom roles and connection pooling + ### High-Availability Management #### `tiger_ha_show`