Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 3 additions & 8 deletions internal/tiger/cmd/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,7 @@ Note: You can specify both CPU and memory together, or specify only one (the oth

// Set as default service unless --no-set-default is specified
if !createNoSetDefault {
if err := setDefaultService(serviceID, statusOutput); err != nil {
if err := setDefaultService(cfg, serviceID, statusOutput); err != nil {
// Log warning but don't fail the command
fmt.Fprintf(statusOutput, "⚠️ Warning: Failed to set service as default: %v\n", err)
}
Expand Down Expand Up @@ -902,12 +902,7 @@ func handlePasswordSaving(service api.Service, initialPassword string, output io
}

// setDefaultService sets the given service as the default service in the configuration
func setDefaultService(serviceID string, output io.Writer) error {
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
Comment on lines -906 to -909
Copy link
Member Author

Choose a reason for hiding this comment

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

Idk why we were re-loading the config here. I don't think it's necessary to reload it, so I updated the code to just pass in the config that was already loaded.

Copy link
Member

Choose a reason for hiding this comment

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

It doesn't "re-load" so much as grab a copy. Agreed that this is weird/unnecessary, and passing in is better. There are still things like this I'd like to improve about the config, but I was avoiding broader changes in #36


func setDefaultService(cfg *config.Config, serviceID string, output io.Writer) error {
if err := cfg.Set("service_id", serviceID); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
Expand Down Expand Up @@ -1313,7 +1308,7 @@ Examples:

// Set as default service unless --no-set-default is used
if !forkNoSetDefault {
if err := setDefaultService(forkedServiceID, statusOutput); err != nil {
if err := setDefaultService(cfg, forkedServiceID, statusOutput); err != nil {
// Log warning but don't fail the command
fmt.Fprintf(statusOutput, "⚠️ Warning: Failed to set service as default: %v\n", err)
}
Expand Down
10 changes: 5 additions & 5 deletions internal/tiger/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,18 +84,18 @@ func (s *Server) createAPIClient() (*api.ClientWithResponses, error) {
return apiClient, nil
}

// loadProjectID loads fresh config and returns the current project ID
func (s *Server) loadProjectID() (string, error) {
// loadConfigWithProjectID loads fresh config and validates that project ID is set
func (s *Server) loadConfigWithProjectID() (*config.Config, error) {
// Load fresh config
cfg, err := config.Load()
if err != nil {
return "", fmt.Errorf("failed to load config: %w", err)
return nil, fmt.Errorf("failed to load config: %w", err)
}

if cfg.ProjectID == "" {
return "", 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' with --project-id")
}
return cfg.ProjectID, nil
return cfg, nil
Comment on lines -87 to +98
Copy link
Member Author

Choose a reason for hiding this comment

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

Updated this to return the full config instead of just the project ID (while still validating that the project ID is set).

Copy link
Member

Choose a reason for hiding this comment

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

This function is weird, and probably unnecessary. Why is it attached to Server, when s isn't even used? More config weirdness to clean up later, I'm not worried about it now.

Copy link
Member Author

@nathanjcochran nathanjcochran Oct 4, 2025

Choose a reason for hiding this comment

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

This function is called at the start of every MCP tool call, and it does encapsulate the common projectID validation and error messages so they aren't repeated every time, which seems worthwhile to me. If anything, there's probably more shared logic that we could encapsulate in this function (though the name and return type would probably need to change).

Why is it attached to Server, when s isn't even used?

You're right that it currently doesn't need to have a receiver, but it also doesn't really bother me, fwiw. Lots of people do this as a way of organizing/namespacing functions within a package. I think long-term, it probably makes sense to be related to the Server type. For instance, if we weren't using the global viper instance (and I'd personally prefer if we weren't, as even viper itself discourages its use), we'd probably need to access the viper instance (or something encapsulating it) via the s *Server receiver in order to load the config.

}

// Close gracefully shuts down the MCP server and all proxy connections
Expand Down
87 changes: 51 additions & 36 deletions internal/tiger/mcp/service_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,15 @@ type ServiceDetail struct {

// ServiceCreateInput represents input for tiger_service_create
type ServiceCreateInput struct {
Name string `json:"name,omitempty"`
Addons []string `json:"addons,omitempty"`
Region string `json:"region,omitempty"`
CPUMemory string `json:"cpu_memory,omitempty"`
Replicas int `json:"replicas,omitempty"`
Free bool `json:"free,omitempty"`
Wait bool `json:"wait,omitempty"`
Timeout int `json:"timeout,omitempty"`
Name string `json:"name,omitempty"`
Addons []string `json:"addons,omitempty"`
Region string `json:"region,omitempty"`
CPUMemory string `json:"cpu_memory,omitempty"`
Replicas int `json:"replicas,omitempty"`
Free bool `json:"free,omitempty"`
Wait bool `json:"wait,omitempty"`
Timeout int `json:"timeout,omitempty"`
SetDefault bool `json:"set_default,omitempty"`
}

func (ServiceCreateInput) Schema() *jsonschema.Schema {
Expand Down Expand Up @@ -129,6 +130,10 @@ func (ServiceCreateInput) Schema() *jsonschema.Schema {
schema.Properties["timeout"].Default = util.Must(json.Marshal(30))
schema.Properties["timeout"].Examples = []any{15, 30, 60}

schema.Properties["set_default"].Description = "Whether to set the newly created service as the default service. When true, the service will be set as the default for future commands."
schema.Properties["set_default"].Default = util.Must(json.Marshal(true))
schema.Properties["set_default"].Examples = []any{true, false}

return schema
}

Expand Down Expand Up @@ -256,25 +261,25 @@ Perfect for:

// handleServiceList handles the tiger_service_list MCP tool
func (s *Server) handleServiceList(ctx context.Context, req *mcp.CallToolRequest, input ServiceListInput) (*mcp.CallToolResult, ServiceListOutput, error) {
// Create fresh API client with current credentials
apiClient, err := s.createAPIClient()
// Load config and validate project ID
cfg, err := s.loadConfigWithProjectID()
if err != nil {
return nil, ServiceListOutput{}, err
}

// Load fresh project ID from current config
projectID, err := s.loadProjectID()
// Create fresh API client with current credentials
apiClient, err := s.createAPIClient()
if err != nil {
return nil, ServiceListOutput{}, err
}

logging.Debug("MCP: Listing services", zap.String("project_id", projectID))
logging.Debug("MCP: Listing services", zap.String("project_id", cfg.ProjectID))

// Make API call to list services
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()

resp, err := apiClient.GetProjectsProjectIdServicesWithResponse(ctx, projectID)
resp, err := apiClient.GetProjectsProjectIdServicesWithResponse(ctx, cfg.ProjectID)
if err != nil {
return nil, ServiceListOutput{}, fmt.Errorf("failed to list services: %w", err)
}
Expand Down Expand Up @@ -308,27 +313,27 @@ func (s *Server) handleServiceList(ctx context.Context, req *mcp.CallToolRequest

// handleServiceShow handles the tiger_service_show MCP tool
func (s *Server) handleServiceShow(ctx context.Context, req *mcp.CallToolRequest, input ServiceShowInput) (*mcp.CallToolResult, ServiceShowOutput, error) {
// Create fresh API client with current credentials
apiClient, err := s.createAPIClient()
// Load config and validate project ID
cfg, err := s.loadConfigWithProjectID()
if err != nil {
return nil, ServiceShowOutput{}, err
}

// Load fresh project ID from current config
projectID, err := s.loadProjectID()
// Create fresh API client with current credentials
apiClient, err := s.createAPIClient()
if err != nil {
return nil, ServiceShowOutput{}, err
}

logging.Debug("MCP: Showing service details",
zap.String("project_id", projectID),
zap.String("project_id", cfg.ProjectID),
zap.String("service_id", input.ServiceID))

// Make API call to get service details
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()

resp, err := apiClient.GetProjectsProjectIdServicesServiceIdWithResponse(ctx, projectID, input.ServiceID)
resp, err := apiClient.GetProjectsProjectIdServicesServiceIdWithResponse(ctx, cfg.ProjectID, input.ServiceID)
if err != nil {
return nil, ServiceShowOutput{}, fmt.Errorf("failed to get service details: %w", err)
}
Expand All @@ -352,22 +357,22 @@ func (s *Server) handleServiceShow(ctx context.Context, req *mcp.CallToolRequest
case 403:
return nil, ServiceShowOutput{}, fmt.Errorf("permission denied: insufficient access to service")
case 404:
return nil, ServiceShowOutput{}, fmt.Errorf("service '%s' not found in project '%s'", input.ServiceID, projectID)
return nil, ServiceShowOutput{}, fmt.Errorf("service '%s' not found in project '%s'", input.ServiceID, cfg.ProjectID)
default:
return nil, ServiceShowOutput{}, fmt.Errorf("API request failed with status %d", resp.StatusCode())
}
}

// handleServiceCreate handles the tiger_service_create MCP tool
func (s *Server) handleServiceCreate(ctx context.Context, req *mcp.CallToolRequest, input ServiceCreateInput) (*mcp.CallToolResult, ServiceCreateOutput, error) {
// Create fresh API client with current credentials
apiClient, err := s.createAPIClient()
// Load config and validate project ID
cfg, err := s.loadConfigWithProjectID()
if err != nil {
return nil, ServiceCreateOutput{}, err
}

// Load fresh project ID from current config
projectID, err := s.loadProjectID()
// Create fresh API client with current credentials
apiClient, err := s.createAPIClient()
if err != nil {
return nil, ServiceCreateOutput{}, err
}
Expand Down Expand Up @@ -429,7 +434,7 @@ func (s *Server) handleServiceCreate(ctx context.Context, req *mcp.CallToolReque
}

logging.Debug("MCP: Creating service",
zap.String("project_id", projectID),
zap.String("project_id", cfg.ProjectID),
zap.String("name", input.Name),
zap.Strings("addons", input.Addons),
zap.String("region", input.Region),
Expand Down Expand Up @@ -458,7 +463,7 @@ func (s *Server) handleServiceCreate(ctx context.Context, req *mcp.CallToolReque
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()

resp, err := apiClient.PostProjectsProjectIdServicesWithResponse(ctx, projectID, serviceCreateReq)
resp, err := apiClient.PostProjectsProjectIdServicesWithResponse(ctx, cfg.ProjectID, serviceCreateReq)
if err != nil {
return nil, ServiceCreateOutput{}, fmt.Errorf("failed to create service: %w", err)
}
Expand Down Expand Up @@ -497,11 +502,21 @@ func (s *Server) handleServiceCreate(ctx context.Context, req *mcp.CallToolReque
}
}

// Set as default service if requested (defaults to true)
if input.SetDefault {
if err := cfg.Set("service_id", serviceID); err != nil {
// Log warning but don't fail the service creation
logging.Debug("MCP: Failed to set service as default", zap.Error(err))
} else {
logging.Debug("MCP: Set service as default", zap.String("service_id", serviceID))
}
}
Comment on lines +505 to +513
Copy link
Member Author

Choose a reason for hiding this comment

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

This is the main change.


// If wait is explicitly requested, wait for service to be ready
if input.Wait {
timeout := time.Duration(input.Timeout) * time.Minute

output.Service, err = s.waitForServiceReady(apiClient, projectID, serviceID, timeout, serviceStatus)
output.Service, err = s.waitForServiceReady(apiClient, cfg.ProjectID, serviceID, timeout, serviceStatus)
if err != nil {
output.Message = fmt.Sprintf("Error: %s", err.Error())
} else {
Expand All @@ -523,20 +538,20 @@ func (s *Server) handleServiceCreate(ctx context.Context, req *mcp.CallToolReque

// handleServiceUpdatePassword handles the tiger_service_update_password MCP tool
func (s *Server) handleServiceUpdatePassword(ctx context.Context, req *mcp.CallToolRequest, input ServiceUpdatePasswordInput) (*mcp.CallToolResult, ServiceUpdatePasswordOutput, error) {
// Create fresh API client with current credentials
apiClient, err := s.createAPIClient()
// Load config and validate project ID
cfg, err := s.loadConfigWithProjectID()
if err != nil {
return nil, ServiceUpdatePasswordOutput{}, err
}

// Load fresh project ID from current config
projectID, err := s.loadProjectID()
// Create fresh API client with current credentials
apiClient, err := s.createAPIClient()
if err != nil {
return nil, ServiceUpdatePasswordOutput{}, err
}

logging.Debug("MCP: Updating service password",
zap.String("project_id", projectID),
zap.String("project_id", cfg.ProjectID),
zap.String("service_id", input.ServiceID))

// Prepare password update request
Expand All @@ -548,7 +563,7 @@ func (s *Server) handleServiceUpdatePassword(ctx context.Context, req *mcp.CallT
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()

resp, err := apiClient.PostProjectsProjectIdServicesServiceIdUpdatePasswordWithResponse(ctx, projectID, input.ServiceID, updateReq)
resp, err := apiClient.PostProjectsProjectIdServicesServiceIdUpdatePasswordWithResponse(ctx, cfg.ProjectID, input.ServiceID, updateReq)
if err != nil {
return nil, ServiceUpdatePasswordOutput{}, fmt.Errorf("failed to update service password: %w", err)
}
Expand All @@ -561,7 +576,7 @@ func (s *Server) handleServiceUpdatePassword(ctx context.Context, req *mcp.CallT
}

// Get service details for password storage (similar to CLI implementation)
serviceResp, err := apiClient.GetProjectsProjectIdServicesServiceIdWithResponse(ctx, projectID, input.ServiceID)
serviceResp, err := apiClient.GetProjectsProjectIdServicesServiceIdWithResponse(ctx, cfg.ProjectID, input.ServiceID)
if err == nil && serviceResp.StatusCode() == 200 && serviceResp.JSON200 != nil {
// Save the new password using the shared util function
result, err := password.SavePasswordWithResult(api.Service(*serviceResp.JSON200), input.Password)
Expand All @@ -580,7 +595,7 @@ func (s *Server) handleServiceUpdatePassword(ctx context.Context, req *mcp.CallT
case 403:
return nil, ServiceUpdatePasswordOutput{}, fmt.Errorf("permission denied: insufficient access to update service password")
case 404:
return nil, ServiceUpdatePasswordOutput{}, fmt.Errorf("service '%s' not found in project '%s'", input.ServiceID, projectID)
return nil, ServiceUpdatePasswordOutput{}, fmt.Errorf("service '%s' not found in project '%s'", input.ServiceID, cfg.ProjectID)
case 400:
var errorMsg string
if resp.JSON400 != nil && resp.JSON400.Message != nil {
Expand Down
1 change: 1 addition & 0 deletions specs/spec_mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ Create a new database service.
- `replicas` (number, optional): Number of high-availability replicas - default: 0
- `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

**Returns:** Service object with creation status and details.

Expand Down