Skip to content
31 changes: 1 addition & 30 deletions internal/tiger/cmd/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
34 changes: 8 additions & 26 deletions internal/tiger/cmd/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
80 changes: 60 additions & 20 deletions internal/tiger/mcp/service_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does omitempty do for us on an input type? Does it make it optional?

Copy link
Member Author

@nathanjcochran nathanjcochran Oct 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. But this is really just a convention of the github.com/google/jsonschema-go library that github.com/modelcontextprotocol/go-sdk/mcp uses under the hood (for normal JSON unmarshalling, omitempty doesn't have any effect).

See these docs for more info on how jsonschema-go generates JSON schemas from structs. In particular:

Structs have schema type "object", and disallow additionalProperties. Their properties are derived from exported struct fields, using the struct field JSON name. Fields that are marked "omitempty" are considered optional; all other fields become required properties.

}

func (ServiceGetInput) Schema() *jsonschema.Schema {
schema := util.Must(jsonschema.For[ServiceGetInput](nil))
setServiceIDSchemaProperties(schema)
setWithPasswordSchemaProperties(schema)

return schema
}

Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -481,21 +509,15 @@ 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.",
}

// 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))
Expand All @@ -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 {
Expand Down
79 changes: 47 additions & 32 deletions internal/tiger/password/connection.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -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
}
Loading