Skip to content

Commit 932fa51

Browse files
Update service_create and service_show to optionally return password (#50)
1 parent 3088cb5 commit 932fa51

File tree

5 files changed

+121
-111
lines changed

5 files changed

+121
-111
lines changed

internal/tiger/cmd/db.go

Lines changed: 1 addition & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -60,15 +60,10 @@ Examples:
6060
return err
6161
}
6262

63-
passwordMode := password.PasswordExclude
64-
if dbConnectionStringWithPassword {
65-
passwordMode = password.PasswordRequired
66-
}
67-
6863
details, err := password.GetConnectionDetails(service, password.ConnectionDetailsOptions{
6964
Pooled: dbConnectionStringPooled,
7065
Role: dbConnectionStringRole,
71-
PasswordMode: passwordMode,
66+
PasswordMode: password.GetPasswordMode(dbConnectionStringWithPassword),
7267
WarnWriter: cmd.ErrOrStderr(),
7368
})
7469
if err != nil {
@@ -248,30 +243,6 @@ func buildDbCmd() *cobra.Command {
248243
return cmd
249244
}
250245

251-
func getServicePassword(service api.Service) (string, error) {
252-
// Get password from storage if requested
253-
storage := password.GetPasswordStorage()
254-
passwd, err := storage.Get(service)
255-
if err != nil {
256-
// Provide specific error messages based on storage type
257-
switch storage.(type) {
258-
case *password.NoStorage:
259-
return "", fmt.Errorf("password storage is disabled (--password-storage=none)")
260-
case *password.KeyringStorage:
261-
return "", fmt.Errorf("no password found in keyring for this service")
262-
case *password.PgpassStorage:
263-
return "", fmt.Errorf("no password found in ~/.pgpass for this service")
264-
default:
265-
return "", fmt.Errorf("failed to retrieve password: %w", err)
266-
}
267-
}
268-
269-
if passwd == "" {
270-
return "", fmt.Errorf("no password available for service")
271-
}
272-
return passwd, nil
273-
}
274-
275246
// getServiceDetails is a helper that handles common service lookup logic and returns the service details
276247
func getServiceDetails(cmd *cobra.Command, args []string) (api.Service, error) {
277248
// Get config

internal/tiger/cmd/service.go

Lines changed: 8 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -423,15 +423,9 @@ Note: You can specify both CPU and memory together, or specify only one (the oth
423423
fmt.Fprintf(statusOutput, "✅ Service creation request accepted!\n")
424424
fmt.Fprintf(statusOutput, "📋 Service ID: %s\n", serviceID)
425425

426-
// Capture initial password from creation response and save it immediately
427-
var initialPassword string
428-
if service.InitialPassword != nil {
429-
initialPassword = *service.InitialPassword
430-
}
431-
432426
// Save password immediately after service creation, before any waiting
433427
// This ensures users have access even if they interrupt the wait or it fails
434-
passwordSaved := handlePasswordSaving(service, initialPassword, statusOutput)
428+
passwordSaved := handlePasswordSaving(service, util.Deref(service.InitialPassword), statusOutput)
435429

436430
// Set as default service unless --no-set-default is specified
437431
if !createNoSetDefault {
@@ -804,16 +798,11 @@ func prepareServiceForOutput(service api.Service, withPassword bool, output io.W
804798
outputSvc.InitialPassword = nil
805799

806800
opts := password.ConnectionDetailsOptions{
807-
Pooled: false,
808-
Role: "tsdbadmin",
809-
PasswordMode: password.PasswordExclude,
810-
WarnWriter: output,
811-
}
812-
if service.InitialPassword != nil {
813-
opts.InitialPassword = *service.InitialPassword
814-
}
815-
if withPassword {
816-
opts.PasswordMode = password.PasswordRequired
801+
Pooled: false,
802+
Role: "tsdbadmin",
803+
PasswordMode: password.GetPasswordMode(withPassword),
804+
InitialPassword: util.Deref(service.InitialPassword),
805+
WarnWriter: output,
817806
}
818807

819808
if connectionDetails, err := password.GetConnectionDetails(service, opts); err != nil {
@@ -826,8 +815,7 @@ func prepareServiceForOutput(service api.Service, withPassword bool, output io.W
826815
}
827816

828817
// Build console URL
829-
cfg, err := config.Load()
830-
if err == nil {
818+
if cfg, err := config.Load(); err == nil {
831819
url := fmt.Sprintf("%s/dashboard/services/%s", cfg.ConsoleURL, *service.ServiceId)
832820
outputSvc.ConsoleURL = url
833821
}
@@ -1306,14 +1294,8 @@ Examples:
13061294
fmt.Fprintf(statusOutput, "✅ Fork request accepted!\n")
13071295
fmt.Fprintf(statusOutput, "📋 New Service ID: %s\n", forkedServiceID)
13081296

1309-
// Capture initial password from fork response and save it immediately
1310-
var initialPassword string
1311-
if forkedService.InitialPassword != nil {
1312-
initialPassword = *forkedService.InitialPassword
1313-
}
1314-
13151297
// Save password immediately after service fork
1316-
passwordSaved := handlePasswordSaving(forkedService, initialPassword, statusOutput)
1298+
passwordSaved := handlePasswordSaving(forkedService, util.Deref(forkedService.InitialPassword), statusOutput)
13171299

13181300
// Set as default service unless --no-set-default is used
13191301
if !forkNoSetDefault {

internal/tiger/mcp/service_tools.go

Lines changed: 60 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -79,14 +79,24 @@ func setServiceIDSchemaProperties(schema *jsonschema.Schema) {
7979
schema.Properties["service_id"].Pattern = "^[a-z0-9]{10}$"
8080
}
8181

82+
// setWithPasswordSchemaProperties sets common with_password schema properties
83+
func setWithPasswordSchemaProperties(schema *jsonschema.Schema) {
84+
schema.Properties["with_password"].Description = "Whether to include the password in the response and connection string."
85+
schema.Properties["with_password"].Default = util.Must(json.Marshal(false))
86+
schema.Properties["with_password"].Examples = []any{false, true}
87+
}
88+
8289
// ServiceGetInput represents input for service_get
8390
type ServiceGetInput struct {
84-
ServiceID string `json:"service_id"`
91+
ServiceID string `json:"service_id"`
92+
WithPassword bool `json:"with_password,omitempty"`
8593
}
8694

8795
func (ServiceGetInput) Schema() *jsonschema.Schema {
8896
schema := util.Must(jsonschema.For[ServiceGetInput](nil))
8997
setServiceIDSchemaProperties(schema)
98+
setWithPasswordSchemaProperties(schema)
99+
90100
return schema
91101
}
92102

@@ -101,17 +111,19 @@ func (ServiceGetOutput) Schema() *jsonschema.Schema {
101111

102112
// ServiceDetail represents detailed service information
103113
type ServiceDetail struct {
104-
ServiceID string `json:"id" jsonschema:"Service identifier (10-character alphanumeric string)"`
105-
Name string `json:"name"`
106-
Status string `json:"status" jsonschema:"Service status (e.g., READY, PAUSED, CONFIGURING, UPGRADING)"`
107-
Type string `json:"type"`
108-
Region string `json:"region"`
109-
Created string `json:"created,omitempty"`
110-
Resources *ResourceInfo `json:"resources,omitempty"`
111-
Replicas int `json:"replicas,omitempty" jsonschema:"Number of HA replicas (0=single node/no HA, 1+=HA enabled)"`
112-
DirectEndpoint string `json:"direct_endpoint,omitempty" jsonschema:"Direct database connection endpoint"`
113-
PoolerEndpoint string `json:"pooler_endpoint,omitempty" jsonschema:"Connection pooler endpoint"`
114-
Paused bool `json:"paused"`
114+
ServiceID string `json:"id" jsonschema:"Service identifier (10-character alphanumeric string)"`
115+
Name string `json:"name"`
116+
Status string `json:"status" jsonschema:"Service status (e.g., READY, PAUSED, CONFIGURING, UPGRADING)"`
117+
Type string `json:"type"`
118+
Region string `json:"region"`
119+
Created string `json:"created,omitempty"`
120+
Resources *ResourceInfo `json:"resources,omitempty"`
121+
Replicas int `json:"replicas" jsonschema:"Number of HA replicas (0=single node/no HA, 1+=HA enabled)"`
122+
DirectEndpoint string `json:"direct_endpoint,omitempty" jsonschema:"Direct database connection endpoint"`
123+
PoolerEndpoint string `json:"pooler_endpoint,omitempty" jsonschema:"Connection pooler endpoint"`
124+
Paused bool `json:"paused"`
125+
Password string `json:"password,omitempty" jsonschema:"Password for tsdbadmin user (only included if with_password=true)"`
126+
ConnectionString string `json:"connection_string" jsonschema:"PostgreSQL connection string (password embedded only if with_password=true)"`
115127
}
116128

117129
func (ServiceDetail) Schema() *jsonschema.Schema {
@@ -131,6 +143,7 @@ type ServiceCreateInput struct {
131143
Wait bool `json:"wait,omitempty"`
132144
TimeoutMinutes *int `json:"timeout_minutes,omitempty"`
133145
SetDefault bool `json:"set_default,omitempty"`
146+
WithPassword bool `json:"with_password,omitempty"`
134147
}
135148

136149
func (ServiceCreateInput) Schema() *jsonschema.Schema {
@@ -174,6 +187,8 @@ func (ServiceCreateInput) Schema() *jsonschema.Schema {
174187
schema.Properties["set_default"].Default = util.Must(json.Marshal(true))
175188
schema.Properties["set_default"].Examples = []any{true, false}
176189

190+
setWithPasswordSchemaProperties(schema)
191+
177192
return schema
178193
}
179194

@@ -364,6 +379,19 @@ func (s *Server) handleServiceGet(ctx context.Context, req *mcp.CallToolRequest,
364379
Service: s.convertToServiceDetail(service),
365380
}
366381

382+
// Always include connection string in ServiceDetail
383+
// Password is embedded in connection string only if with_password=true
384+
if details, err := password.GetConnectionDetails(service, password.ConnectionDetailsOptions{
385+
Pooled: false,
386+
Role: "tsdbadmin",
387+
PasswordMode: password.GetPasswordMode(input.WithPassword),
388+
}); err != nil {
389+
logging.Debug("MCP: Failed to build connection string", zap.Error(err))
390+
} else {
391+
output.Service.Password = details.Password
392+
output.Service.ConnectionString = details.String()
393+
}
394+
367395
return nil, output, nil
368396
}
369397

@@ -481,21 +509,15 @@ func (s *Server) handleServiceCreate(ctx context.Context, req *mcp.CallToolReque
481509
serviceID := util.Deref(service.ServiceId)
482510
serviceStatus := util.DerefStr(service.Status)
483511

484-
// Capture initial password from creation response and save it immediately
485-
var initialPassword string
486-
if service.InitialPassword != nil {
487-
initialPassword = *service.InitialPassword
488-
}
489-
490512
output := ServiceCreateOutput{
491513
Service: s.convertToServiceDetail(service),
492514
Message: "Service creation request accepted. The service may still be provisioning.",
493515
}
494516

495517
// Save password immediately after service creation, before any waiting
496518
// This ensures the password is stored even if the wait fails or is interrupted
497-
if initialPassword != "" {
498-
result, err := password.SavePasswordWithResult(api.Service(service), initialPassword)
519+
if service.InitialPassword != nil {
520+
result, err := password.SavePasswordWithResult(api.Service(service), *service.InitialPassword)
499521
output.PasswordStorage = &result
500522
if err != nil {
501523
logging.Debug("MCP: Password storage failed", zap.Error(err))
@@ -504,6 +526,24 @@ func (s *Server) handleServiceCreate(ctx context.Context, req *mcp.CallToolReque
504526
}
505527
}
506528

529+
// Include password in ServiceDetail if requested
530+
if input.WithPassword {
531+
output.Service.Password = util.Deref(service.InitialPassword)
532+
}
533+
534+
// Always include connection string in ServiceDetail
535+
// Password is embedded in connection string only if with_password=true
536+
if details, err := password.GetConnectionDetails(api.Service(service), password.ConnectionDetailsOptions{
537+
Pooled: false,
538+
Role: "tsdbadmin",
539+
PasswordMode: password.GetPasswordMode(input.WithPassword),
540+
InitialPassword: util.Deref(service.InitialPassword),
541+
}); err != nil {
542+
logging.Debug("MCP: Failed to build connection string", zap.Error(err))
543+
} else {
544+
output.Service.ConnectionString = details.String()
545+
}
546+
507547
// Set as default service if requested (defaults to true)
508548
if input.SetDefault {
509549
if err := cfg.Set("service_id", serviceID); err != nil {

internal/tiger/password/connection.go

Lines changed: 47 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,16 @@ const (
2424
PasswordOptional
2525
)
2626

27+
// GetPasswordMode is a helper function for getting a [PasswordMode] from a
28+
// boolean. It only ever returns [PasswordRequired]/[PasswordExcluded]. If you
29+
// need [PasswordOptional], do not use this function.
30+
func GetPasswordMode(required bool) PasswordMode {
31+
if required {
32+
return PasswordRequired
33+
}
34+
return PasswordExclude
35+
}
36+
2737
// ConnectionDetailsOptions configures how the connection string is built
2838
type ConnectionDetailsOptions struct {
2939
// Pooled determines whether to use the pooler endpoint (if available)
@@ -35,12 +45,14 @@ type ConnectionDetailsOptions struct {
3545
// PasswordMode determines how passwords are handled
3646
PasswordMode PasswordMode
3747

48+
// InitialPassword is an optional password to use directly (e.g., from service creation response)
49+
// If provided and PasswordMode is PasswordRequired or PasswordOptional, this password will be used
50+
// instead of fetching from password storage. This is useful when password_storage=none.
51+
InitialPassword string
52+
3853
// WarnWriter is an optional writer for warning messages (e.g., when pooler is requested but not available)
3954
// If nil, warnings are suppressed
4055
WarnWriter io.Writer
41-
42-
// If passed, we skip fetching from the password storage
43-
InitialPassword string
4456
}
4557

4658
type ConnectionDetails struct {
@@ -56,9 +68,8 @@ func GetConnectionDetails(service api.Service, opts ConnectionDetailsOptions) (*
5668
return nil, fmt.Errorf("service endpoint not available")
5769
}
5870

59-
var endpoint *api.Endpoint
60-
6171
// Use pooler endpoint if requested and available, otherwise use direct endpoint
72+
var endpoint *api.Endpoint
6273
if opts.Pooled && service.ConnectionPooler != nil && service.ConnectionPooler.Endpoint != nil {
6374
endpoint = service.ConnectionPooler.Endpoint
6475
} else {
@@ -79,40 +90,18 @@ func GetConnectionDetails(service api.Service, opts ConnectionDetailsOptions) (*
7990
Port: 5432, // Default PostgreSQL port
8091
Database: "tsdb", // Database is always "tsdb" for TimescaleDB/PostgreSQL services
8192
}
82-
8393
if endpoint.Port != nil {
8494
details.Port = *endpoint.Port
8595
}
8696

8797
if opts.PasswordMode == PasswordRequired || opts.PasswordMode == PasswordOptional {
88-
var password string
8998
if opts.InitialPassword != "" {
90-
password = opts.InitialPassword
99+
details.Password = opts.InitialPassword
100+
} else if password, err := GetPassword(service); err != nil && opts.PasswordMode == PasswordRequired {
101+
return nil, err
102+
} else {
103+
details.Password = password
91104
}
92-
if password == "" {
93-
storage := GetPasswordStorage()
94-
if storedPassword, err := storage.Get(service); err != nil && opts.PasswordMode == PasswordRequired {
95-
// Provide specific error messages based on storage type
96-
switch storage.(type) {
97-
case *NoStorage:
98-
return nil, fmt.Errorf("password storage is disabled (--password-storage=none)")
99-
case *KeyringStorage:
100-
return nil, fmt.Errorf("no password found in keyring for this service")
101-
case *PgpassStorage:
102-
return nil, fmt.Errorf("no password found in ~/.pgpass for this service")
103-
default:
104-
return nil, fmt.Errorf("failed to retrieve password: %w", err)
105-
}
106-
} else {
107-
password = storedPassword
108-
}
109-
}
110-
111-
if password == "" && opts.PasswordMode == PasswordRequired {
112-
return nil, fmt.Errorf("no password available for service")
113-
}
114-
115-
details.Password = password
116105
}
117106

118107
return details, nil
@@ -127,3 +116,29 @@ func (d *ConnectionDetails) String() string {
127116
// Include password in connection string
128117
return fmt.Sprintf("postgresql://%s:%s@%s:%d/%s?sslmode=require", d.Role, d.Password, d.Host, d.Port, d.Database)
129118
}
119+
120+
// GetPassword fetches the password for the specified service from the
121+
// configured password storage mechanism. It returns an error if it fails to
122+
// find the password.
123+
func GetPassword(service api.Service) (string, error) {
124+
storage := GetPasswordStorage()
125+
password, err := storage.Get(service)
126+
if err != nil {
127+
// Provide specific error messages based on storage type
128+
switch storage.(type) {
129+
case *NoStorage:
130+
return "", fmt.Errorf("password storage is disabled (--password-storage=none)")
131+
case *KeyringStorage:
132+
return "", fmt.Errorf("no password found in keyring for this service")
133+
case *PgpassStorage:
134+
return "", fmt.Errorf("no password found in ~/.pgpass for this service")
135+
default:
136+
return "", fmt.Errorf("failed to retrieve password: %w", err)
137+
}
138+
}
139+
140+
if password == "" {
141+
return "", fmt.Errorf("no password available for service")
142+
}
143+
return password, nil
144+
}

0 commit comments

Comments
 (0)