@@ -137,7 +137,7 @@ type ServiceCreateInput struct {
137137 Name string `json:"name,omitempty"`
138138 Addons []string `json:"addons,omitempty"`
139139 Region * string `json:"region,omitempty"`
140- CPUMemory * string `json:"cpu_memory,omitempty"`
140+ CPUMemory string `json:"cpu_memory,omitempty"`
141141 Replicas int `json:"replicas,omitempty"`
142142 Wait bool `json:"wait,omitempty"`
143143 TimeoutMinutes int `json:"timeout_minutes,omitempty"`
@@ -159,7 +159,7 @@ func (ServiceCreateInput) Schema() *jsonschema.Schema {
159159 schema .Properties ["region" ].Description = "AWS region where the service will be deployed. Choose the region closest to your users for optimal performance."
160160 schema .Properties ["region" ].Examples = []any {"us-east-1" , "us-west-2" , "eu-west-1" , "eu-central-1" , "ap-southeast-1" }
161161
162- schema .Properties ["cpu_memory" ].Description = fmt . Sprintf ( "CPU and memory allocation combination. Choose from the available configurations." )
162+ schema .Properties ["cpu_memory" ].Description = "CPU and memory allocation combination. Choose from the available configurations."
163163 schema .Properties ["cpu_memory" ].Enum = util .AnySlice (util .GetAllowedCPUMemoryConfigs ().Strings ())
164164
165165 schema .Properties ["replicas" ].Description = "Number of high-availability replicas for fault tolerance. Higher replica counts increase cost but improve availability."
@@ -197,6 +197,67 @@ func (ServiceCreateOutput) Schema() *jsonschema.Schema {
197197 return util .Must (jsonschema.For [ServiceCreateOutput ](nil ))
198198}
199199
200+ // ServiceForkInput represents input for service_fork
201+ type ServiceForkInput struct {
202+ ServiceID string `json:"service_id"`
203+ Name string `json:"name,omitempty"`
204+ ForkStrategy api.ForkStrategy `json:"fork_strategy"`
205+ TargetTime * time.Time `json:"target_time,omitempty"`
206+ CPUMemory string `json:"cpu_memory,omitempty"`
207+ Wait bool `json:"wait,omitempty"`
208+ TimeoutMinutes int `json:"timeout_minutes,omitempty"`
209+ SetDefault bool `json:"set_default,omitempty"`
210+ WithPassword bool `json:"with_password,omitempty"`
211+ }
212+
213+ func (ServiceForkInput ) Schema () * jsonschema.Schema {
214+ schema := util .Must (jsonschema.For [ServiceForkInput ](nil ))
215+
216+ setServiceIDSchemaProperties (schema )
217+
218+ schema .Properties ["name" ].Description = "Human-readable name for the forked service (auto-generated if not provided)"
219+ schema .Properties ["name" ].MaxLength = util .Ptr (128 ) // Matches backend validation
220+ schema .Properties ["name" ].Examples = []any {"my-forked-db" , "prod-fork-test" , "backup-db" }
221+
222+ schema .Properties ["fork_strategy" ].Description = "Fork strategy: 'NOW' creates fork at current state, 'LAST_SNAPSHOT' uses last existing snapshot (faster), 'PITR' allows point-in-time recovery to specific timestamp (requires target_time parameter)"
223+ schema .Properties ["fork_strategy" ].Enum = []any {api .NOW , api .LASTSNAPSHOT , api .PITR }
224+ schema .Properties ["fork_strategy" ].Examples = []any {api .NOW , api .LASTSNAPSHOT }
225+
226+ schema .Properties ["target_time" ].Description = "Target timestamp for point-in-time recovery (RFC3339 format, e.g., '2025-01-15T10:30:00Z'). Only used when fork_strategy is 'PITR'."
227+ schema .Properties ["target_time" ].Examples = []any {"2025-01-15T10:30:00Z" , "2024-12-01T00:00:00Z" }
228+
229+ schema .Properties ["cpu_memory" ].Description = "CPU and memory allocation combination. Choose from the available configurations. If not specified, inherits from source service."
230+ schema .Properties ["cpu_memory" ].Enum = util .AnySlice (util .GetAllowedCPUMemoryConfigs ().Strings ())
231+
232+ schema .Properties ["wait" ].Description = "Whether to wait for the forked service to be fully ready before returning. Default is false and is recommended because forking can take several minutes. ONLY set to true if the user explicitly needs to use the forked service immediately to continue the same conversation."
233+ schema .Properties ["wait" ].Default = util .Must (json .Marshal (false ))
234+ schema .Properties ["wait" ].Examples = []any {false , true }
235+
236+ schema .Properties ["timeout_minutes" ].Description = "Timeout in minutes when waiting for forked service to be ready. Only used when 'wait' is true."
237+ schema .Properties ["timeout_minutes" ].Minimum = util .Ptr (0.0 )
238+ schema .Properties ["timeout_minutes" ].Default = util .Must (json .Marshal (30 ))
239+ schema .Properties ["timeout_minutes" ].Examples = []any {15 , 30 , 60 }
240+
241+ schema .Properties ["set_default" ].Description = "Whether to set the newly forked service as the default service. When true, the forked service will be set as the default for future commands."
242+ schema .Properties ["set_default" ].Default = util .Must (json .Marshal (true ))
243+ schema .Properties ["set_default" ].Examples = []any {true , false }
244+
245+ setWithPasswordSchemaProperties (schema )
246+
247+ return schema
248+ }
249+
250+ // ServiceForkOutput represents output for service_fork
251+ type ServiceForkOutput struct {
252+ Service ServiceDetail `json:"service"`
253+ Message string `json:"message"`
254+ PasswordStorage * password.PasswordStorageResult `json:"password_storage,omitempty"`
255+ }
256+
257+ func (ServiceForkOutput ) Schema () * jsonschema.Schema {
258+ return util .Must (jsonschema.For [ServiceForkOutput ](nil ))
259+ }
260+
200261// ServiceUpdatePasswordInput represents input for service_update_password
201262type ServiceUpdatePasswordInput struct {
202263 ServiceID string `json:"service_id"`
@@ -273,11 +334,41 @@ WARNING: Creates billable resources.`,
273334 OutputSchema : ServiceCreateOutput {}.Schema (),
274335 Annotations : & mcp.ToolAnnotations {
275336 DestructiveHint : util .Ptr (false ), // Creates resources but doesn't modify existing
276- IdempotentHint : false , // Creating with same name would fail
337+ IdempotentHint : false , // Creating with same name creates multiple services (name is not unique)
277338 Title : "Create Database Service" ,
278339 },
279340 }, s .handleServiceCreate )
280341
342+ // service_fork
343+ mcp .AddTool (s .mcpServer , & mcp.Tool {
344+ Name : "service_fork" ,
345+ Title : "Fork Database Service" ,
346+ Description : `Fork an existing database service to create a new independent copy.
347+
348+ You must specify a fork strategy:
349+ - 'NOW': Fork at the current database state (creates new snapshot or uses WAL replay)
350+ - 'LAST_SNAPSHOT': Fork at the last existing snapshot (faster fork)
351+ - 'PITR': Fork at a specific point in time (point-in-time recovery)
352+
353+ By default:
354+ - Name will be auto-generated as '{source-service-name}-fork'
355+ - CPU and memory will be inherited from the source service
356+ - The forked service will be set as the default service
357+
358+ Default behavior: Returns immediately while service provisions in background (recommended).
359+ Setting wait=true will block for several minutes until ready - only use if user explicitly needs immediate access.
360+ timeout_minutes: Wait duration in minutes (only relevant with wait=true).
361+
362+ WARNING: Creates billable resources.` ,
363+ InputSchema : ServiceForkInput {}.Schema (),
364+ OutputSchema : ServiceForkOutput {}.Schema (),
365+ Annotations : & mcp.ToolAnnotations {
366+ DestructiveHint : util .Ptr (false ), // Creates resources but doesn't modify existing
367+ IdempotentHint : false , // Forking same service multiple times creates multiple forks
368+ Title : "Fork Database Service" ,
369+ },
370+ }, s .handleServiceFork )
371+
281372 // service_update_password
282373 mcp .AddTool (s .mcpServer , & mcp.Tool {
283374 Name : "service_update_password" ,
@@ -402,8 +493,8 @@ func (s *Server) handleServiceCreate(ctx context.Context, req *mcp.CallToolReque
402493 }
403494
404495 var cpuMillis , memoryGBs * string
405- if input .CPUMemory != nil {
406- cpuMillisStr , memoryGBsStr , err := util .ParseCPUMemory (* input .CPUMemory )
496+ if input .CPUMemory != "" {
497+ cpuMillisStr , memoryGBsStr , err := util .ParseCPUMemory (input .CPUMemory )
407498 if err != nil {
408499 return nil , ServiceCreateOutput {}, fmt .Errorf ("invalid CPU/Memory specification: %w" , err )
409500 }
@@ -510,6 +601,144 @@ func (s *Server) handleServiceCreate(ctx context.Context, req *mcp.CallToolReque
510601 return nil , output , nil
511602}
512603
604+ // handleServiceFork handles the service_fork MCP tool
605+ func (s * Server ) handleServiceFork (ctx context.Context , req * mcp.CallToolRequest , input ServiceForkInput ) (* mcp.CallToolResult , ServiceForkOutput , error ) {
606+ // Load config
607+ cfg , err := config .Load ()
608+ if err != nil {
609+ return nil , ServiceForkOutput {}, fmt .Errorf ("failed to load config: %w" , err )
610+ }
611+
612+ // Create fresh API client and get project ID
613+ apiClient , projectID , err := s .createAPIClient ()
614+ if err != nil {
615+ return nil , ServiceForkOutput {}, err
616+ }
617+
618+ // Validate fork strategy and target_time relationship
619+ switch input .ForkStrategy {
620+ case api .PITR :
621+ if input .TargetTime == nil {
622+ return nil , ServiceForkOutput {}, fmt .Errorf ("target_time is required when fork_strategy is 'PITR'" )
623+ }
624+ default :
625+ if input .TargetTime != nil {
626+ return nil , ServiceForkOutput {}, fmt .Errorf ("target_time cannot be specified when fork_strategy is not 'PITR'" )
627+ }
628+ }
629+
630+ // Parse CPU/Memory configuration if provided
631+ var cpuMillis , memoryGBs * string
632+ if input .CPUMemory != "" {
633+ cpuMillisStr , memoryGBsStr , err := util .ParseCPUMemory (input .CPUMemory )
634+ if err != nil {
635+ return nil , ServiceForkOutput {}, fmt .Errorf ("invalid CPU/Memory specification: %w" , err )
636+ }
637+ cpuMillis , memoryGBs = & cpuMillisStr , & memoryGBsStr
638+ }
639+
640+ logging .Debug ("MCP: Forking service" ,
641+ zap .String ("project_id" , projectID ),
642+ zap .String ("service_id" , input .ServiceID ),
643+ zap .String ("name" , input .Name ),
644+ zap .String ("fork_strategy" , string (input .ForkStrategy )),
645+ zap .Stringp ("cpu" , cpuMillis ),
646+ zap .Stringp ("memory" , memoryGBs ),
647+ )
648+
649+ // Prepare service fork request
650+ forkReq := api.ForkServiceCreate {
651+ ForkStrategy : input .ForkStrategy ,
652+ TargetTime : input .TargetTime ,
653+ CpuMillis : cpuMillis ,
654+ MemoryGbs : memoryGBs ,
655+ }
656+
657+ // Only set name if provided
658+ if input .Name != "" {
659+ forkReq .Name = & input .Name
660+ }
661+
662+ // Make API call to fork service
663+ ctx , cancel := context .WithTimeout (ctx , 30 * time .Second )
664+ defer cancel ()
665+
666+ resp , err := apiClient .PostProjectsProjectIdServicesServiceIdForkServiceWithResponse (ctx , projectID , input .ServiceID , forkReq )
667+ if err != nil {
668+ return nil , ServiceForkOutput {}, fmt .Errorf ("failed to fork service: %w" , err )
669+ }
670+
671+ // Handle API response
672+ if resp .StatusCode () != 202 {
673+ return nil , ServiceForkOutput {}, resp .JSON4XX
674+ }
675+
676+ service := * resp .JSON202
677+ serviceID := util .Deref (service .ServiceId )
678+
679+ output := ServiceForkOutput {
680+ Service : s .convertToServiceDetail (service ),
681+ Message : "Service fork request accepted. The forked service may still be provisioning." ,
682+ }
683+
684+ // Save password immediately after service fork, before any waiting
685+ // This ensures the password is stored even if the wait fails or is interrupted
686+ if service .InitialPassword != nil {
687+ result , err := password .SavePasswordWithResult (api .Service (service ), * service .InitialPassword , "tsdbadmin" )
688+ output .PasswordStorage = & result
689+ if err != nil {
690+ logging .Debug ("MCP: Password storage failed" , zap .Error (err ))
691+ } else {
692+ logging .Debug ("MCP: Password saved successfully" , zap .String ("method" , result .Method ))
693+ }
694+ }
695+
696+ // Include password in ServiceDetail if requested
697+ if input .WithPassword {
698+ output .Service .Password = util .Deref (service .InitialPassword )
699+ }
700+
701+ // Always include connection string in ServiceDetail
702+ // Password is embedded in connection string only if with_password=true
703+ if details , err := password .GetConnectionDetails (api .Service (service ), password.ConnectionDetailsOptions {
704+ Role : "tsdbadmin" ,
705+ WithPassword : input .WithPassword ,
706+ InitialPassword : util .Deref (service .InitialPassword ),
707+ }); err != nil {
708+ logging .Debug ("MCP: Failed to build connection string" , zap .Error (err ))
709+ } else {
710+ if input .WithPassword && details .Password == "" {
711+ // This should not happen since we have InitialPassword, but check just in case
712+ logging .Error ("MCP: Requested password but password not available" )
713+ }
714+ output .Service .ConnectionString = details .String ()
715+ }
716+
717+ // Set as default service if requested (defaults to true)
718+ if input .SetDefault {
719+ if err := cfg .Set ("service_id" , serviceID ); err != nil {
720+ // Log warning but don't fail the service fork
721+ logging .Debug ("MCP: Failed to set service as default" , zap .Error (err ))
722+ } else {
723+ logging .Debug ("MCP: Set service as default" , zap .String ("service_id" , serviceID ))
724+ }
725+ }
726+
727+ // If wait is explicitly requested, wait for service to be ready
728+ if input .Wait {
729+ timeout := time .Duration (input .TimeoutMinutes ) * time .Minute
730+
731+ if status , err := s .waitForServiceReady (apiClient , projectID , serviceID , timeout , service .Status ); err != nil {
732+ output .Message = fmt .Sprintf ("Error: %s" , err .Error ())
733+ } else {
734+ output .Service .Status = util .DerefStr (status )
735+ output .Message = "Service forked successfully and is ready!"
736+ }
737+ }
738+
739+ return nil , output , nil
740+ }
741+
513742// handleServiceUpdatePassword handles the service_update_password MCP tool
514743func (s * Server ) handleServiceUpdatePassword (ctx context.Context , req * mcp.CallToolRequest , input ServiceUpdatePasswordInput ) (* mcp.CallToolResult , ServiceUpdatePasswordOutput , error ) {
515744 // Create fresh API client and get project ID
0 commit comments