diff --git a/internal/tiger/cmd/db.go b/internal/tiger/cmd/db.go index ccbb97a2..b7d8fc4f 100644 --- a/internal/tiger/cmd/db.go +++ b/internal/tiger/cmd/db.go @@ -60,15 +60,10 @@ Examples: return err } - passwordMode := password.PasswordExclude - if dbConnectionStringWithPassword { - passwordMode = password.PasswordRequired - } - details, err := password.GetConnectionDetails(service, password.ConnectionDetailsOptions{ Pooled: dbConnectionStringPooled, Role: dbConnectionStringRole, - PasswordMode: passwordMode, + PasswordMode: password.GetPasswordMode(dbConnectionStringWithPassword), WarnWriter: cmd.ErrOrStderr(), }) if err != nil { @@ -248,30 +243,6 @@ func buildDbCmd() *cobra.Command { return cmd } -func getServicePassword(service api.Service) (string, error) { - // Get password from storage if requested - storage := password.GetPasswordStorage() - passwd, err := storage.Get(service) - if err != nil { - // Provide specific error messages based on storage type - switch storage.(type) { - case *password.NoStorage: - return "", fmt.Errorf("password storage is disabled (--password-storage=none)") - case *password.KeyringStorage: - return "", fmt.Errorf("no password found in keyring for this service") - case *password.PgpassStorage: - return "", fmt.Errorf("no password found in ~/.pgpass for this service") - default: - return "", fmt.Errorf("failed to retrieve password: %w", err) - } - } - - if passwd == "" { - return "", fmt.Errorf("no password available for service") - } - return passwd, 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 diff --git a/internal/tiger/cmd/service.go b/internal/tiger/cmd/service.go index 7e90b51a..1a45f76c 100644 --- a/internal/tiger/cmd/service.go +++ b/internal/tiger/cmd/service.go @@ -423,15 +423,9 @@ Note: You can specify both CPU and memory together, or specify only one (the oth fmt.Fprintf(statusOutput, "✅ Service creation request accepted!\n") fmt.Fprintf(statusOutput, "📋 Service ID: %s\n", serviceID) - // Capture initial password from creation response and save it immediately - var initialPassword string - if service.InitialPassword != nil { - initialPassword = *service.InitialPassword - } - // Save password immediately after service creation, before any waiting // This ensures users have access even if they interrupt the wait or it fails - passwordSaved := handlePasswordSaving(service, initialPassword, statusOutput) + passwordSaved := handlePasswordSaving(service, util.Deref(service.InitialPassword), statusOutput) // Set as default service unless --no-set-default is specified if !createNoSetDefault { @@ -804,16 +798,11 @@ func prepareServiceForOutput(service api.Service, withPassword bool, output io.W outputSvc.InitialPassword = nil opts := password.ConnectionDetailsOptions{ - Pooled: false, - Role: "tsdbadmin", - PasswordMode: password.PasswordExclude, - WarnWriter: output, - } - if service.InitialPassword != nil { - opts.InitialPassword = *service.InitialPassword - } - if withPassword { - opts.PasswordMode = password.PasswordRequired + Pooled: false, + Role: "tsdbadmin", + PasswordMode: password.GetPasswordMode(withPassword), + InitialPassword: util.Deref(service.InitialPassword), + WarnWriter: output, } if connectionDetails, err := password.GetConnectionDetails(service, opts); err != nil { @@ -826,8 +815,7 @@ func prepareServiceForOutput(service api.Service, withPassword bool, output io.W } // Build console URL - cfg, err := config.Load() - if err == nil { + if cfg, err := config.Load(); err == nil { url := fmt.Sprintf("%s/dashboard/services/%s", cfg.ConsoleURL, *service.ServiceId) outputSvc.ConsoleURL = url } @@ -1306,14 +1294,8 @@ Examples: fmt.Fprintf(statusOutput, "✅ Fork request accepted!\n") fmt.Fprintf(statusOutput, "📋 New Service ID: %s\n", forkedServiceID) - // Capture initial password from fork response and save it immediately - var initialPassword string - if forkedService.InitialPassword != nil { - initialPassword = *forkedService.InitialPassword - } - // Save password immediately after service fork - passwordSaved := handlePasswordSaving(forkedService, initialPassword, statusOutput) + passwordSaved := handlePasswordSaving(forkedService, util.Deref(forkedService.InitialPassword), statusOutput) // Set as default service unless --no-set-default is used if !forkNoSetDefault { diff --git a/internal/tiger/mcp/service_tools.go b/internal/tiger/mcp/service_tools.go index b702f5e6..68e870f3 100644 --- a/internal/tiger/mcp/service_tools.go +++ b/internal/tiger/mcp/service_tools.go @@ -79,14 +79,24 @@ func setServiceIDSchemaProperties(schema *jsonschema.Schema) { schema.Properties["service_id"].Pattern = "^[a-z0-9]{10}$" } +// setWithPasswordSchemaProperties sets common with_password schema properties +func setWithPasswordSchemaProperties(schema *jsonschema.Schema) { + schema.Properties["with_password"].Description = "Whether to include the password in the response and connection string." + schema.Properties["with_password"].Default = util.Must(json.Marshal(false)) + schema.Properties["with_password"].Examples = []any{false, true} +} + // ServiceGetInput represents input for service_get type ServiceGetInput struct { - ServiceID string `json:"service_id"` + ServiceID string `json:"service_id"` + WithPassword bool `json:"with_password,omitempty"` } func (ServiceGetInput) Schema() *jsonschema.Schema { schema := util.Must(jsonschema.For[ServiceGetInput](nil)) setServiceIDSchemaProperties(schema) + setWithPasswordSchemaProperties(schema) + return schema } @@ -101,17 +111,19 @@ func (ServiceGetOutput) Schema() *jsonschema.Schema { // ServiceDetail represents detailed service information type ServiceDetail struct { - ServiceID string `json:"id" jsonschema:"Service identifier (10-character alphanumeric string)"` - Name string `json:"name"` - Status string `json:"status" jsonschema:"Service status (e.g., READY, PAUSED, CONFIGURING, UPGRADING)"` - Type string `json:"type"` - Region string `json:"region"` - Created string `json:"created,omitempty"` - Resources *ResourceInfo `json:"resources,omitempty"` - Replicas int `json:"replicas,omitempty" jsonschema:"Number of HA replicas (0=single node/no HA, 1+=HA enabled)"` - DirectEndpoint string `json:"direct_endpoint,omitempty" jsonschema:"Direct database connection endpoint"` - PoolerEndpoint string `json:"pooler_endpoint,omitempty" jsonschema:"Connection pooler endpoint"` - Paused bool `json:"paused"` + ServiceID string `json:"id" jsonschema:"Service identifier (10-character alphanumeric string)"` + Name string `json:"name"` + Status string `json:"status" jsonschema:"Service status (e.g., READY, PAUSED, CONFIGURING, UPGRADING)"` + Type string `json:"type"` + Region string `json:"region"` + Created string `json:"created,omitempty"` + Resources *ResourceInfo `json:"resources,omitempty"` + Replicas int `json:"replicas" jsonschema:"Number of HA replicas (0=single node/no HA, 1+=HA enabled)"` + DirectEndpoint string `json:"direct_endpoint,omitempty" jsonschema:"Direct database connection endpoint"` + PoolerEndpoint string `json:"pooler_endpoint,omitempty" jsonschema:"Connection pooler endpoint"` + Paused bool `json:"paused"` + Password string `json:"password,omitempty" jsonschema:"Password for tsdbadmin user (only included if with_password=true)"` + ConnectionString string `json:"connection_string" jsonschema:"PostgreSQL connection string (password embedded only if with_password=true)"` } func (ServiceDetail) Schema() *jsonschema.Schema { @@ -131,6 +143,7 @@ type ServiceCreateInput struct { Wait bool `json:"wait,omitempty"` TimeoutMinutes *int `json:"timeout_minutes,omitempty"` SetDefault bool `json:"set_default,omitempty"` + WithPassword bool `json:"with_password,omitempty"` } func (ServiceCreateInput) Schema() *jsonschema.Schema { @@ -174,6 +187,8 @@ func (ServiceCreateInput) Schema() *jsonschema.Schema { schema.Properties["set_default"].Default = util.Must(json.Marshal(true)) schema.Properties["set_default"].Examples = []any{true, false} + setWithPasswordSchemaProperties(schema) + return schema } @@ -364,6 +379,19 @@ func (s *Server) handleServiceGet(ctx context.Context, req *mcp.CallToolRequest, Service: s.convertToServiceDetail(service), } + // Always include connection string in ServiceDetail + // Password is embedded in connection string only if with_password=true + if details, err := password.GetConnectionDetails(service, password.ConnectionDetailsOptions{ + Pooled: false, + Role: "tsdbadmin", + PasswordMode: password.GetPasswordMode(input.WithPassword), + }); err != nil { + logging.Debug("MCP: Failed to build connection string", zap.Error(err)) + } else { + output.Service.Password = details.Password + output.Service.ConnectionString = details.String() + } + return nil, output, nil } @@ -481,12 +509,6 @@ func (s *Server) handleServiceCreate(ctx context.Context, req *mcp.CallToolReque serviceID := util.Deref(service.ServiceId) serviceStatus := util.DerefStr(service.Status) - // Capture initial password from creation response and save it immediately - var initialPassword string - if service.InitialPassword != nil { - initialPassword = *service.InitialPassword - } - output := ServiceCreateOutput{ Service: s.convertToServiceDetail(service), Message: "Service creation request accepted. The service may still be provisioning.", @@ -494,8 +516,8 @@ func (s *Server) handleServiceCreate(ctx context.Context, req *mcp.CallToolReque // Save password immediately after service creation, before any waiting // This ensures the password is stored even if the wait fails or is interrupted - if initialPassword != "" { - result, err := password.SavePasswordWithResult(api.Service(service), initialPassword) + if service.InitialPassword != nil { + result, err := password.SavePasswordWithResult(api.Service(service), *service.InitialPassword) output.PasswordStorage = &result if err != nil { logging.Debug("MCP: Password storage failed", zap.Error(err)) @@ -504,6 +526,24 @@ func (s *Server) handleServiceCreate(ctx context.Context, req *mcp.CallToolReque } } + // Include password in ServiceDetail if requested + if input.WithPassword { + output.Service.Password = util.Deref(service.InitialPassword) + } + + // Always include connection string in ServiceDetail + // Password is embedded in connection string only if with_password=true + if details, err := password.GetConnectionDetails(api.Service(service), password.ConnectionDetailsOptions{ + Pooled: false, + Role: "tsdbadmin", + PasswordMode: password.GetPasswordMode(input.WithPassword), + InitialPassword: util.Deref(service.InitialPassword), + }); err != nil { + logging.Debug("MCP: Failed to build connection string", zap.Error(err)) + } else { + output.Service.ConnectionString = details.String() + } + // Set as default service if requested (defaults to true) if input.SetDefault { if err := cfg.Set("service_id", serviceID); err != nil { diff --git a/internal/tiger/password/connection.go b/internal/tiger/password/connection.go index 614ad38a..071c68b5 100644 --- a/internal/tiger/password/connection.go +++ b/internal/tiger/password/connection.go @@ -24,6 +24,16 @@ const ( PasswordOptional ) +// GetPasswordMode is a helper function for getting a [PasswordMode] from a +// boolean. It only ever returns [PasswordRequired]/[PasswordExcluded]. If you +// need [PasswordOptional], do not use this function. +func GetPasswordMode(required bool) PasswordMode { + if required { + return PasswordRequired + } + return PasswordExclude +} + // ConnectionDetailsOptions configures how the connection string is built type ConnectionDetailsOptions struct { // Pooled determines whether to use the pooler endpoint (if available) @@ -35,12 +45,14 @@ type ConnectionDetailsOptions struct { // PasswordMode determines how passwords are handled PasswordMode PasswordMode + // InitialPassword is an optional password to use directly (e.g., from service creation response) + // If provided and PasswordMode is PasswordRequired or PasswordOptional, this password will be used + // instead of fetching from password storage. This is useful when password_storage=none. + InitialPassword string + // 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 - - // If passed, we skip fetching from the password storage - InitialPassword string } type ConnectionDetails struct { @@ -56,9 +68,8 @@ func GetConnectionDetails(service api.Service, opts ConnectionDetailsOptions) (* return nil, fmt.Errorf("service endpoint not available") } - var endpoint *api.Endpoint - // Use pooler endpoint if requested and available, otherwise use direct endpoint + var endpoint *api.Endpoint if opts.Pooled && service.ConnectionPooler != nil && service.ConnectionPooler.Endpoint != nil { endpoint = service.ConnectionPooler.Endpoint } else { @@ -79,40 +90,18 @@ func GetConnectionDetails(service api.Service, opts ConnectionDetailsOptions) (* Port: 5432, // Default PostgreSQL port Database: "tsdb", // Database is always "tsdb" for TimescaleDB/PostgreSQL services } - if endpoint.Port != nil { details.Port = *endpoint.Port } if opts.PasswordMode == PasswordRequired || opts.PasswordMode == PasswordOptional { - var password string if opts.InitialPassword != "" { - password = opts.InitialPassword + details.Password = opts.InitialPassword + } else if password, err := GetPassword(service); err != nil && opts.PasswordMode == PasswordRequired { + return nil, err + } else { + details.Password = password } - if password == "" { - storage := GetPasswordStorage() - if storedPassword, err := storage.Get(service); err != nil && opts.PasswordMode == PasswordRequired { - // Provide specific error messages based on storage type - switch storage.(type) { - case *NoStorage: - return nil, fmt.Errorf("password storage is disabled (--password-storage=none)") - case *KeyringStorage: - return nil, fmt.Errorf("no password found in keyring for this service") - case *PgpassStorage: - return nil, fmt.Errorf("no password found in ~/.pgpass for this service") - default: - return nil, fmt.Errorf("failed to retrieve password: %w", err) - } - } else { - password = storedPassword - } - } - - if password == "" && opts.PasswordMode == PasswordRequired { - return nil, fmt.Errorf("no password available for service") - } - - details.Password = password } return details, nil @@ -127,3 +116,29 @@ func (d *ConnectionDetails) String() string { // Include password in connection string return fmt.Sprintf("postgresql://%s:%s@%s:%d/%s?sslmode=require", d.Role, d.Password, d.Host, d.Port, d.Database) } + +// GetPassword fetches the password for the specified service from the +// configured password storage mechanism. It returns an error if it fails to +// find the password. +func GetPassword(service api.Service) (string, error) { + 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") + } + return password, nil +} diff --git a/specs/spec_mcp.md b/specs/spec_mcp.md index 8c8b673c..976925f5 100644 --- a/specs/spec_mcp.md +++ b/specs/spec_mcp.md @@ -160,8 +160,9 @@ Get details of a specific service. **Parameters:** - `service_id` (string, required): Service ID to get +- `with_password` (boolean, optional): Include password in response and connection string - default: false -**Returns:** Detailed service object with configuration, endpoints, and status. +**Returns:** Detailed service object with configuration, endpoints, status, and connection string. When `with_password=true`, the response includes the password field and the password is embedded in the connection string. When `with_password=false` (default), the connection string is still included but without the password embedded. #### `tiger_service_create` Create a new database service. @@ -175,10 +176,11 @@ Create a new database service. - `wait` (boolean, optional): Wait for service to be ready - default: false - `timeout` (number, optional): Timeout for waiting in minutes - default: 30 - `set_default` (boolean, optional): Set the newly created service as the default service for future commands - default: true +- `with_password` (boolean, optional): Include password in response and connection string - default: false -**Returns:** Service object with creation status and details. +**Returns:** Service object with creation status, details, and connection string. When `with_password=true`, the response includes the initial password field and the password is embedded in the connection string. When `with_password=false` (default), the connection string is still included but without the password embedded. -**Note:** This tool automatically stores the database password using the same method as the CLI (keyring, pgpass file, etc.). +**Note:** This tool automatically stores the database password using the same method as the CLI (keyring, pgpass file, etc.), regardless of the `with_password` parameter value. #### `tiger_service_delete` Delete a database service.