diff --git a/CLAUDE.md b/CLAUDE.md index 3b216156..7c09c08e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -143,7 +143,7 @@ A command that identifies a service takes a **ref** — its ID, a read replica s - Help text shows `[name-or-id]` and says the service "can be given by ID or name". Shell completion offers names, falling back to IDs when what is typed matches an ID instead. - A name is accepted only as an argument. A configured default must be an ID, and one that resolves by name is refused. The `--service-id` flag completion only offers IDs. - Destructive commands accept a name, but their confirmation prompt takes only the ID. -- MCP tools stay IDs-only — an intentional divergence, documented at `setServiceIDSchemaProperties`. +- MCP tools stay IDs-only — an intentional divergence, documented at `setServiceIDSchemaProperties`. A tool that goes through the resolve endpoint (the branch tools) refuses a match by name. ## Output diff --git a/internal/cmd/mcp_list_test.go b/internal/cmd/mcp_list_test.go index e8d9716e..439a80cf 100644 --- a/internal/cmd/mcp_list_test.go +++ b/internal/cmd/mcp_list_test.go @@ -69,11 +69,20 @@ func TestMCPListCmd(t *testing.T) { └──────┴───────────────────────────┘ ` - // TIGER_EXPERIMENTAL registers the preview backup tools on top of the - // (always-registered) default set, which already includes metrics. + // TIGER_EXPERIMENTAL registers the preview backup and branch tools on top + // of the (always-registered) default set, which already includes metrics. wantTextExperimental := `┌──────┬──────────────────────────────┐ │ TYPE │ NAME │ ├──────┼──────────────────────────────┤ +│ tool │ branch_create │ +│ tool │ branch_delete │ +│ tool │ branch_get │ +│ tool │ branch_list │ +│ tool │ branch_rename │ +│ tool │ branch_resize │ +│ tool │ branch_start │ +│ tool │ branch_stop │ +│ tool │ branch_update_password │ │ tool │ db_query │ │ tool │ db_schema │ │ tool │ feedback │ @@ -178,7 +187,7 @@ func TestMCPListCmd(t *testing.T) { wantStdout: wantTextReadOnly, }, { - name: "experimental adds backups tool", + name: "experimental adds backup and branch tools", args: []string{"mcp", "list"}, opts: append(noDocsProxy(nil), withEnv("TIGER_EXPERIMENTAL", "true")), diff --git a/internal/mcp/branch_create.go b/internal/mcp/branch_create.go new file mode 100644 index 00000000..92da2037 --- /dev/null +++ b/internal/mcp/branch_create.go @@ -0,0 +1,166 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/timescale/tiger-cli/internal/api" + "github.com/timescale/tiger-cli/internal/common" + "github.com/timescale/tiger-cli/internal/util" +) + +// BranchCreateInput represents input for branch_create +type BranchCreateInput struct { + ServiceID string `json:"service_id"` + Name string `json:"name,omitempty"` + CPUMemory string `json:"cpu_memory,omitempty"` + IOPS *int `json:"iops,omitempty"` + Wait bool `json:"wait,omitempty"` +} + +func (BranchCreateInput) Schema() *jsonschema.Schema { + schema := util.Must(jsonschema.For[BranchCreateInput](nil)) + + setServiceIDSchemaProperties(schema) + schema.Properties["service_id"].Description = "Unique identifier of the Fluid Service to branch (10-character alphanumeric string). Use service_list to find service IDs. Branching from a branch isn't supported." + + schema.Properties["name"].Description = "Name for the branch, unique within the parent service (auto-generated if not provided)" + schema.Properties["name"].Examples = []any{"ci-4821", "feature/user-auth"} + + setBranchCPUMemorySchemaProperties(schema, "CPU and memory allocation combination. If not specified, the branch gets 0.25 CPU/2 GB, whatever the parent service's size.") + setBranchIOPSSchemaProperties(schema, "If not specified, the branch gets the baseline IOPS for the user's plan and its CPU allocation.") + + schema.Properties["wait"].Description = "Whether to wait for the branch to be fully ready before returning. Default is false (recommended). Only set to true if your next steps require connecting to or querying the branch. When true, waits up to 10 minutes." + schema.Properties["wait"].Default = util.Must(json.Marshal(false)) + schema.Properties["wait"].Examples = []any{false, true} + + return schema +} + +// BranchCreateOutput represents output for branch_create +type BranchCreateOutput struct { + Branch BranchDetail `json:"branch"` + Message string `json:"message"` + PasswordStorage *common.PasswordStorageResult `json:"password_storage,omitempty"` +} + +func (BranchCreateOutput) Schema() *jsonschema.Schema { + schema := util.Must(jsonschema.For[BranchCreateOutput](nil)) + schema.Properties["branch"] = BranchDetail{}.Schema() + schema.Properties["message"].Description = "Human-readable outcome of the operation" + schema.Properties["password_storage"].Description = "Where the branch's new tsdbadmin password was saved locally, and whether saving it succeeded" + return schema +} + +func newBranchCreateTool() *mcp.Tool { + return &mcp.Tool{ + Name: toolBranchCreate, + Title: "Create Branch", + Description: `Create a branch of a Fluid Service. + +A branch is a lightweight, ephemeral copy of a service for development, testing, CI/CD, and agent workflows. It shares the parent's storage as of the moment it is created and gets its own compute. The tsdbadmin user gets a new password, which is saved to the configured password storage. Branches are always treated as DEV, so under read-only mode for production services a PROD service can still be branched. + +By default: +- The name is auto-generated +- The branch gets 0.25 CPU and 2 GB of memory, and the baseline IOPS for that size + +WARNING: Creates billable resources.`, + InputSchema: BranchCreateInput{}.Schema(), + OutputSchema: BranchCreateOutput{}.Schema(), + Annotations: &mcp.ToolAnnotations{ + ReadOnlyHint: false, + DestructiveHint: new(false), // Creates resources but doesn't modify existing + IdempotentHint: false, // Branching the same service multiple times creates multiple branches + OpenWorldHint: new(false), + Title: "Create Branch", + }, + } +} + +// handleBranchCreate handles the branch_create MCP tool +func (s *Server) handleBranchCreate(ctx context.Context, req *mcp.CallToolRequest, input BranchCreateInput) (*mcp.CallToolResult, BranchCreateOutput, error) { + cfg, client, projectID, err := s.app.GetAll() + if err != nil { + return nil, BranchCreateOutput{}, err + } + + // Gate on the branch's own tag, which is always DEV: under prod mode, + // branching a PROD service is allowed. + if err := common.CheckReadOnly(cfg, common.BranchEnvironmentTag); err != nil { + return nil, BranchCreateOutput{}, err + } + + var createReq api.BranchCreate + if input.Name != "" { + createReq.Name = &input.Name + } + if input.CPUMemory != "" { + cpuMemoryCfg, err := parseBranchCPUMemory(input.CPUMemory) + if err != nil { + return nil, BranchCreateOutput{}, err + } + createReq.CPUMillis = &cpuMemoryCfg.CPUMillis + createReq.MemoryGbs = &cpuMemoryCfg.MemoryGBs + } + createReq.Iops = input.IOPS + + s.logger.Info("MCP: Creating branch", + slog.String("project_id", projectID), + slog.String("service_id", input.ServiceID), + slog.String("name", input.Name), + slog.String("cpu_memory", input.CPUMemory), + slog.Any("iops", input.IOPS), + ) + + resp, err := client.CreateBranchWithResponse(ctx, projectID, input.ServiceID, createReq) + if err != nil { + return nil, BranchCreateOutput{}, fmt.Errorf("failed to create branch: %w", err) + } + if resp.StatusCode() != http.StatusAccepted { + return nil, BranchCreateOutput{}, common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) + } + if resp.JSON202 == nil { + return nil, BranchCreateOutput{}, fmt.Errorf("empty response from API") + } + branch := *resp.JSON202 + + // Saved before any waiting, so the password is stored even if the wait + // fails or is interrupted. + var passwordStorage *common.PasswordStorageResult + if branch.InitialPassword != nil { + result, err := common.SavePasswordWithResult(cfg, common.BranchPasswordTarget(branch), *branch.InitialPassword, "tsdbadmin") + passwordStorage = &result + if err != nil { + s.logger.Warn("MCP: Password storage failed", slog.Any("error", err)) + } + } + + message := "Branch creation request accepted. The branch may still be provisioning." + if input.Wait { + if err := common.WaitForBranch(ctx, common.WaitForBranchArgs{ + Client: client, + ProjectID: projectID, + ServiceID: input.ServiceID, + BranchID: branch.ID, + Branch: &branch, + TargetStatus: api.DeployStatusREADY, + Timeout: waitTimeout, + TimeoutMsg: "branch may still be provisioning", + }); err != nil { + message = fmt.Sprintf("Error: %s", err.Error()) + } else { + message = "Branch is ready." + } + } + return nil, BranchCreateOutput{ + Branch: s.convertToBranchDetail(cfg, branch), + Message: message, + PasswordStorage: passwordStorage, + }, nil +} diff --git a/internal/mcp/branch_create_test.go b/internal/mcp/branch_create_test.go new file mode 100644 index 00000000..0459c015 --- /dev/null +++ b/internal/mcp/branch_create_test.go @@ -0,0 +1,221 @@ +package mcp + +import ( + "errors" + "net/http" + "testing" + + "github.com/timescale/tiger-cli/internal/api" + "github.com/timescale/tiger-cli/internal/api/mocks" +) + +func TestBranchCreateTool(t *testing.T) { + experimental := withExperimental() + args := map[string]any{"service_id": "e6ue9697jf"} + baseReq := api.BranchCreate{} + + // Without a name, the API names the branch after its parent. + autoNamed := func(b *api.Branch) { b.Name = "branch-test-service" } + namedDetail := func(name string, status api.DeployStatus) map[string]any { + detail := sampleBranchDetail(status) + detail["name"] = name + return detail + } + autoNamedDetail := func(status api.DeployStatus) map[string]any { + return namedDetail("branch-test-service", status) + } + + // The create response carries the branch's new password, once. + created := sampleBranch(autoNamed, branchStatus(api.DeployStatusQUEUED), func(b *api.Branch) { + b.InitialPassword = new("branch-pw") + }) + + // expectCreate registers creating a branch of the sample service with req, + // answered with branch. + expectCreate := func(req api.BranchCreate, branch api.Branch) func(m *mocks.MockClientWithResponsesInterface) { + return func(m *mocks.MockClientWithResponsesInterface) { + m.EXPECT().CreateBranchWithResponse(validCtx, testProjectID, "e6ue9697jf", req). + Return(&api.CreateBranchResponse{ + HTTPResponse: httpResponse(http.StatusAccepted), + JSON202: &branch, + }, nil) + } + } + + acceptedMsg := "Branch creation request accepted. The branch may still be provisioning." + storedPassword := map[string]any{ + "success": true, + "method": "keyring", + "message": "Password saved to system keyring", + } + + runToolTests(t, []toolTest{ + { + name: "not registered without the experimental gate", + tool: toolBranchCreate, + args: args, + wantCallErr: `calling "tools/call": unknown tool "branch_create"`, + }, + { + name: "not logged in", + tool: toolBranchCreate, + args: args, + opts: []runOption{experimental, withNotLoggedIn()}, + wantErr: notLoggedInMsg, + }, + { + name: "rejects a combination a branch can't have", + tool: toolBranchCreate, + args: map[string]any{"service_id": "e6ue9697jf", "cpu_memory": "1 CPU/4 GB"}, + opts: []runOption{experimental}, + wantErr: `validating "arguments": validating root: validating /properties/cpu_memory: enum: 1 CPU/4 GB does not equal any of: [0.25 CPU/2 GB 0.5 CPU/4 GB 1 CPU/8 GB 2 CPU/16 GB 4 CPU/32 GB 8 CPU/64 GB 16 CPU/128 GB 32 CPU/256 GB]`, + }, + { + name: "read-only all refuses without an API call", + tool: toolBranchCreate, + args: args, + opts: []runOption{experimental, withConfigAfterStart(map[string]any{"read_only": "all"})}, + wantErr: "this operation is not allowed in read-only mode", + }, + { + // A branch always counts as DEV, so prod allows branching even a + // PROD service, without looking up the service's tag. + name: "read-only prod allows branching", + tool: toolBranchCreate, + args: args, + opts: []runOption{experimental, withConfig(map[string]any{"read_only": "prod"})}, + mock: expectCreate(baseReq, created), + wantOutput: map[string]any{ + "branch": autoNamedDetail(api.DeployStatusQUEUED), + "message": acceptedMsg, + "password_storage": storedPassword, + }, + }, + { + name: "network error", + tool: toolBranchCreate, + args: args, + opts: []runOption{experimental}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + m.EXPECT().CreateBranchWithResponse(validCtx, testProjectID, "e6ue9697jf", baseReq). + Return(nil, errors.New("connection refused")) + }, + wantErr: "failed to create branch: connection refused", + }, + { + name: "API error", + tool: toolBranchCreate, + args: args, + opts: []runOption{experimental}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + m.EXPECT().CreateBranchWithResponse(validCtx, testProjectID, "e6ue9697jf", baseReq). + Return(&api.CreateBranchResponse{ + HTTPResponse: httpResponse(http.StatusBadRequest), + JSON4XX: &api.ClientError{Message: new("service is not a Fluid Service")}, + }, nil) + }, + wantErr: "service is not a Fluid Service", + }, + { + name: "nil response body", + tool: toolBranchCreate, + args: args, + opts: []runOption{experimental}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + m.EXPECT().CreateBranchWithResponse(validCtx, testProjectID, "e6ue9697jf", baseReq). + Return(&api.CreateBranchResponse{HTTPResponse: httpResponse(http.StatusAccepted)}, nil) + }, + wantErr: "empty response from API", + }, + { + // Only service IDs go in the default, so a branch never becomes it. + name: "saves the password and leaves the default alone", + tool: toolBranchCreate, + args: args, + opts: []runOption{experimental}, + mock: expectCreate(baseReq, created), + wantOutput: map[string]any{ + "branch": autoNamedDetail(api.DeployStatusQUEUED), + "message": acceptedMsg, + "password_storage": storedPassword, + }, + checks: []checkFunc{checkStoredPassword("br4nch1d9x", "branch-pw"), checkDefaultService("")}, + }, + { + // A storage failure isn't fatal: the branch exists either way, so + // the tool reports the failure rather than erroring. + name: "reports a password storage failure", + tool: toolBranchCreate, + args: args, + opts: []runOption{experimental, withKeyringError(errors.New("keyring is locked"))}, + mock: expectCreate(baseReq, created), + wantOutput: map[string]any{ + "branch": autoNamedDetail(api.DeployStatusQUEUED), + "message": acceptedMsg, + "password_storage": map[string]any{ + "success": false, + "method": "keyring", + "message": "Failed to save password to keyring: keyring is locked", + }, + }, + }, + { + name: "no initial password", + tool: toolBranchCreate, + args: args, + opts: []runOption{experimental}, + mock: expectCreate(baseReq, sampleBranch(autoNamed, branchStatus(api.DeployStatusQUEUED))), + wantOutput: map[string]any{ + "branch": autoNamedDetail(api.DeployStatusQUEUED), + "message": acceptedMsg, + }, + }, + { + name: "custom name and compute", + tool: toolBranchCreate, + args: map[string]any{"service_id": "e6ue9697jf", "name": "ci-4821", "cpu_memory": "2 CPU/16 GB"}, + opts: []runOption{experimental}, + mock: expectCreate(api.BranchCreate{ + Name: new("ci-4821"), + CPUMillis: new(2000), + MemoryGbs: new(16), + }, sampleBranch(branchStatus(api.DeployStatusQUEUED), func(b *api.Branch) { + b.Name = "ci-4821" + b.InitialPassword = new("branch-pw") + })), + wantOutput: map[string]any{ + "branch": namedDetail("ci-4821", api.DeployStatusQUEUED), + "message": acceptedMsg, + "password_storage": storedPassword, + }, + }, + { + name: "iops", + tool: toolBranchCreate, + args: map[string]any{"service_id": "e6ue9697jf", "iops": 16000}, + opts: []runOption{experimental}, + mock: expectCreate(api.BranchCreate{Iops: new(16000)}, created), + wantOutput: map[string]any{ + "branch": autoNamedDetail(api.DeployStatusQUEUED), + "message": acceptedMsg, + "password_storage": storedPassword, + }, + }, + { + name: "wait polls until ready", + synctest: true, + tool: toolBranchCreate, + args: map[string]any{"service_id": "e6ue9697jf", "wait": true}, + opts: []runOption{experimental}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectCreate(baseReq, created)(m) + expectGetBranch(m, sampleBranch(autoNamed)) + }, + wantOutput: map[string]any{ + "branch": autoNamedDetail(api.DeployStatusREADY), + "message": "Branch is ready.", + "password_storage": storedPassword, + }, + }, + }) +} diff --git a/internal/mcp/branch_delete.go b/internal/mcp/branch_delete.go new file mode 100644 index 00000000..fb63b8d3 --- /dev/null +++ b/internal/mcp/branch_delete.go @@ -0,0 +1,91 @@ +package mcp + +import ( + "context" + "fmt" + "log/slog" + "net/http" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/timescale/tiger-cli/internal/common" + "github.com/timescale/tiger-cli/internal/util" +) + +// BranchDeleteInput represents input for branch_delete +type BranchDeleteInput struct { + BranchID string `json:"branch_id"` +} + +func (BranchDeleteInput) Schema() *jsonschema.Schema { + schema := util.Must(jsonschema.For[BranchDeleteInput](nil)) + setBranchIDSchemaProperties(schema) + return schema +} + +// BranchDeleteOutput represents output for branch_delete +type BranchDeleteOutput struct { + BranchID string `json:"branch_id"` + Message string `json:"message"` +} + +func (BranchDeleteOutput) Schema() *jsonschema.Schema { + schema := util.Must(jsonschema.For[BranchDeleteOutput](nil)) + schema.Properties["branch_id"].Description = "Identifier of the deleted branch" + schema.Properties["message"].Description = "Human-readable outcome of the operation" + return schema +} + +func newBranchDeleteTool() *mcp.Tool { + return &mcp.Tool{ + Name: toolBranchDelete, + Title: "Delete Branch", + Description: `Delete a branch permanently, along with any changes made on it. The parent service is not affected. + +This operation is irreversible. Branches are always treated as DEV, so the tool does not prompt the user for confirmation the way deleting a PROD service does.`, + InputSchema: BranchDeleteInput{}.Schema(), + OutputSchema: BranchDeleteOutput{}.Schema(), + Annotations: &mcp.ToolAnnotations{ + ReadOnlyHint: false, + DestructiveHint: new(true), // Permanently destroys the branch and the changes made on it + IdempotentHint: true, // Deleting an already-deleted branch has no further effect (but returns an error) + OpenWorldHint: new(false), + Title: "Delete Branch", + }, + } +} + +// handleBranchDelete handles the branch_delete MCP tool +func (s *Server) handleBranchDelete(ctx context.Context, req *mcp.CallToolRequest, input BranchDeleteInput) (*mcp.CallToolResult, BranchDeleteOutput, error) { + cfg, client, projectID, err := s.app.GetAll() + if err != nil { + return nil, BranchDeleteOutput{}, err + } + + if err := common.CheckReadOnly(cfg, common.BranchEnvironmentTag); err != nil { + return nil, BranchDeleteOutput{}, err + } + + resolved, err := resolveBranchByID(ctx, client, projectID, input.BranchID) + if err != nil { + return nil, BranchDeleteOutput{}, err + } + + s.logger.Info("MCP: Deleting branch", + slog.String("project_id", projectID), + slog.String("branch_id", input.BranchID)) + + resp, err := client.DeleteBranchWithResponse(ctx, projectID, resolved.ServiceID, resolved.ID) + if err != nil { + return nil, BranchDeleteOutput{}, fmt.Errorf("failed to delete branch: %w", err) + } + if resp.StatusCode() != http.StatusAccepted { + return nil, BranchDeleteOutput{}, common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) + } + + return nil, BranchDeleteOutput{ + BranchID: input.BranchID, + Message: fmt.Sprintf("Branch %q has been deleted.", input.BranchID), + }, nil +} diff --git a/internal/mcp/branch_delete_test.go b/internal/mcp/branch_delete_test.go new file mode 100644 index 00000000..eae9f61e --- /dev/null +++ b/internal/mcp/branch_delete_test.go @@ -0,0 +1,92 @@ +package mcp + +import ( + "errors" + "net/http" + "testing" + + "github.com/timescale/tiger-cli/internal/api" + "github.com/timescale/tiger-cli/internal/api/mocks" +) + +func TestBranchDeleteTool(t *testing.T) { + experimental := withExperimental() + args := map[string]any{"branch_id": "br4nch1d9x"} + expectDelete := func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, sampleBranch()) + m.EXPECT().DeleteBranchWithResponse(validCtx, testProjectID, "e6ue9697jf", "br4nch1d9x"). + Return(&api.DeleteBranchResponse{HTTPResponse: httpResponse(http.StatusAccepted)}, nil) + } + deleted := map[string]any{ + "branch_id": "br4nch1d9x", + "message": `Branch "br4nch1d9x" has been deleted.`, + } + + runToolTests(t, []toolTest{ + { + name: "not registered without the experimental gate", + tool: toolBranchDelete, + args: args, + wantCallErr: `calling "tools/call": unknown tool "branch_delete"`, + }, + { + name: "not logged in", + tool: toolBranchDelete, + args: args, + opts: []runOption{experimental, withNotLoggedIn()}, + wantErr: notLoggedInMsg, + }, + { + name: "read-only all refuses without an API call", + tool: toolBranchDelete, + args: args, + opts: []runOption{experimental, withConfigAfterStart(map[string]any{"read_only": "all"})}, + wantErr: "this operation is not allowed in read-only mode", + }, + { + // A branch always counts as DEV, so prod neither refuses nor + // prompts. + name: "read-only prod deletes without a prompt", + tool: toolBranchDelete, + args: args, + opts: []runOption{experimental, withConfig(map[string]any{"read_only": "prod"})}, + mock: expectDelete, + wantOutput: deleted, + }, + { + name: "network error", + tool: toolBranchDelete, + args: args, + opts: []runOption{experimental}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, sampleBranch()) + m.EXPECT().DeleteBranchWithResponse(validCtx, testProjectID, "e6ue9697jf", "br4nch1d9x"). + Return(nil, errors.New("connection refused")) + }, + wantErr: "failed to delete branch: connection refused", + }, + { + name: "API error", + tool: toolBranchDelete, + args: args, + opts: []runOption{experimental}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, sampleBranch()) + m.EXPECT().DeleteBranchWithResponse(validCtx, testProjectID, "e6ue9697jf", "br4nch1d9x"). + Return(&api.DeleteBranchResponse{ + HTTPResponse: httpResponse(http.StatusNotFound), + JSON4XX: &api.ClientError{Message: new("branch not found")}, + }, nil) + }, + wantErr: "branch not found", + }, + { + name: "success", + tool: toolBranchDelete, + args: args, + opts: []runOption{experimental}, + mock: expectDelete, + wantOutput: deleted, + }, + }) +} diff --git a/internal/mcp/branch_get.go b/internal/mcp/branch_get.go new file mode 100644 index 00000000..f804580a --- /dev/null +++ b/internal/mcp/branch_get.go @@ -0,0 +1,79 @@ +package mcp + +import ( + "context" + "log/slog" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/timescale/tiger-cli/internal/common" + "github.com/timescale/tiger-cli/internal/util" +) + +// BranchGetInput represents input for branch_get +type BranchGetInput struct { + BranchID string `json:"branch_id"` +} + +func (BranchGetInput) Schema() *jsonschema.Schema { + schema := util.Must(jsonschema.For[BranchGetInput](nil)) + setBranchIDSchemaProperties(schema) + return schema +} + +// BranchGetOutput represents output for branch_get +type BranchGetOutput struct { + Branch BranchDetail `json:"branch"` +} + +func (BranchGetOutput) Schema() *jsonschema.Schema { + schema := util.Must(jsonschema.For[BranchGetOutput](nil)) + schema.Properties["branch"] = BranchDetail{}.Schema() + return schema +} + +func newBranchGetTool() *mcp.Tool { + return &mcp.Tool{ + Name: toolBranchGet, + Title: "Get Branch Details", + Description: "Get detailed information for a specific branch of a database service. " + + "Returns its parent service, status, resource allocation, creation time, and connection details.", + InputSchema: BranchGetInput{}.Schema(), + OutputSchema: BranchGetOutput{}.Schema(), + Annotations: &mcp.ToolAnnotations{ + ReadOnlyHint: true, + OpenWorldHint: new(false), + Title: "Get Branch Details", + }, + } +} + +// handleBranchGet handles the branch_get MCP tool +func (s *Server) handleBranchGet(ctx context.Context, req *mcp.CallToolRequest, input BranchGetInput) (*mcp.CallToolResult, BranchGetOutput, error) { + cfg, client, projectID, err := s.app.GetAll() + if err != nil { + return nil, BranchGetOutput{}, err + } + + s.logger.Info("MCP: Getting branch details", + slog.String("project_id", projectID), + slog.String("branch_id", input.BranchID)) + + branch, err := resolveBranchByID(ctx, client, projectID, input.BranchID) + if err != nil { + return nil, BranchGetOutput{}, err + } + + detail := s.convertToBranchDetail(cfg, *branch) + + // The branch carries only its parent's ID, so the name takes a second + // fetch. The rest of the output doesn't depend on it. + if parent, err := common.GetService(ctx, client, projectID, branch.ServiceID); err != nil { + s.logger.Error("MCP: Failed to get parent service", slog.Any("error", err)) + } else { + detail.ServiceName = parent.Name + } + + return nil, BranchGetOutput{Branch: detail}, nil +} diff --git a/internal/mcp/branch_get_test.go b/internal/mcp/branch_get_test.go new file mode 100644 index 00000000..575e2775 --- /dev/null +++ b/internal/mcp/branch_get_test.go @@ -0,0 +1,181 @@ +package mcp + +import ( + "errors" + "net/http" + "testing" + + "github.com/timescale/tiger-cli/internal/api" + "github.com/timescale/tiger-cli/internal/api/mocks" +) + +func TestBranchGetTool(t *testing.T) { + experimental := withExperimental() + args := map[string]any{"branch_id": "br4nch1d9x"} + + // getBranch registers resolving branch and then fetching its parent, for + // the parent's name. + getBranch := func(branch api.Branch) func(m *mocks.MockClientWithResponsesInterface) { + return func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, branch) + expectGetService(m, "e6ue9697jf", sampleService()) + } + } + withServiceName := func(detail map[string]any) map[string]any { + detail["service_name"] = "test-service" + return detail + } + + runToolTests(t, []toolTest{ + { + name: "not registered without the experimental gate", + tool: toolBranchGet, + args: args, + wantCallErr: `calling "tools/call": unknown tool "branch_get"`, + }, + { + name: "not logged in", + tool: toolBranchGet, + args: args, + opts: []runOption{experimental, withNotLoggedIn()}, + wantErr: notLoggedInMsg, + }, + { + name: "rejects a malformed branch ID", + tool: toolBranchGet, + args: map[string]any{"branch_id": "nope"}, + opts: []runOption{experimental}, + wantErr: `validating "arguments": validating root: validating /properties/branch_id: pattern: "nope" does not match regular expression "^[a-z0-9]{10}$"`, + }, + { + name: "network error", + tool: toolBranchGet, + args: args, + opts: []runOption{experimental}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + m.EXPECT().ResolveRefWithResponse(validCtx, testProjectID, &api.ResolveRefParams{Ref: "br4nch1d9x", Type: branchType}). + Return(nil, errors.New("connection refused")) + }, + wantErr: "failed to resolve branch 'br4nch1d9x': connection refused", + }, + { + name: "branch not found", + tool: toolBranchGet, + args: args, + opts: []runOption{experimental}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveStatus(m, "br4nch1d9x", branchType, http.StatusNotFound, &api.ClientError{Message: new("resource not found")}) + }, + wantErr: "branch 'br4nch1d9x' not found", + }, + { + name: "nil response body", + tool: toolBranchGet, + args: args, + opts: []runOption{experimental}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveStatus(m, "br4nch1d9x", branchType, http.StatusOK, nil) + }, + wantErr: "empty response from API", + }, + { + // The tools are IDs-only, but the resolve endpoint also matches + // names, so a name shaped like an ID resolves to some other + // branch. That match is refused. + name: "refuses a match by name", + tool: toolBranchGet, + args: args, + opts: []runOption{experimental}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolve(m, "br4nch1d9x", branchType, sampleBranch(func(b *api.Branch) { + b.ID = "0th3rbr4nc" + b.Name = "br4nch1d9x" + })) + }, + wantErr: "branch 'br4nch1d9x' not found", + }, + { + name: "success", + tool: toolBranchGet, + args: args, + opts: []runOption{experimental}, + mock: getBranch(sampleBranch()), + wantOutput: map[string]any{"branch": withServiceName(sampleBranchDetail(api.DeployStatusREADY))}, + }, + { + // The IOPS go in resources, as they do for services. + name: "returns the IOPS in resources", + tool: toolBranchGet, + args: args, + opts: []runOption{experimental}, + mock: getBranch(sampleBranch(func(b *api.Branch) { b.Iops = new(16000) })), + wantOutput: map[string]any{"branch": func() map[string]any { + detail := withServiceName(sampleBranchDetail(api.DeployStatusREADY)) + detail["resources"] = map[string]any{"cpu": "0.25 cores", "memory": "2 GB", "iops": float64(16000)} + return detail + }()}, + }, + { + // The parent's name is the only thing the second fetch adds, so + // without it the branch is still returned. + name: "parent fetch fails", + tool: toolBranchGet, + args: args, + opts: []runOption{experimental}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, sampleBranch()) + m.EXPECT().GetServiceWithResponse(validCtx, testProjectID, "e6ue9697jf"). + Return(nil, errors.New("connection refused")) + }, + wantOutput: map[string]any{"branch": sampleBranchDetail(api.DeployStatusREADY)}, + }, + { + // A branch counts as DEV, so only read_only=all makes its + // connection string read-only. + name: "read-only all makes the connection string read-only", + tool: toolBranchGet, + args: args, + opts: []runOption{experimental, withConfig(map[string]any{"read_only": "all"})}, + mock: getBranch(sampleBranch()), + wantOutput: map[string]any{"branch": func() map[string]any { + detail := withServiceName(sampleBranchDetail(api.DeployStatusREADY)) + detail["connection_string"] = "postgresql://tsdbadmin@br4nch1d9x.test-project-123.branch.tsdb.cloud.timescale.com:36956/tsdb?sslmode=require&options=-c%20tsdb_admin.read_only_connection%3Dtrue" + return detail + }()}, + }, + { + // No port is guessed, so without one there's no endpoint to show + // or connect to. + name: "endpoint without a port", + tool: toolBranchGet, + args: args, + opts: []runOption{experimental}, + mock: getBranch(sampleBranch(func(b *api.Branch) { b.Endpoint.Port = nil })), + wantOutput: map[string]any{"branch": map[string]any{ + "id": "br4nch1d9x", + "name": "test-branch", + "service_id": "e6ue9697jf", + "service_name": "test-service", + "status": "READY", + "created": "2025-01-16T09:00:00Z", + "resources": map[string]any{"cpu": "0.25 cores", "memory": "2 GB"}, + }}, + }, + { + name: "provisioning branch without an endpoint", + tool: toolBranchGet, + args: args, + opts: []runOption{experimental}, + mock: getBranch(sampleBranch(branchStatus(api.DeployStatusQUEUED), func(b *api.Branch) { b.Endpoint = nil })), + wantOutput: map[string]any{"branch": map[string]any{ + "id": "br4nch1d9x", + "name": "test-branch", + "service_id": "e6ue9697jf", + "service_name": "test-service", + "status": "QUEUED", + "created": "2025-01-16T09:00:00Z", + "resources": map[string]any{"cpu": "0.25 cores", "memory": "2 GB"}, + }}, + }, + }) +} diff --git a/internal/mcp/branch_list.go b/internal/mcp/branch_list.go new file mode 100644 index 00000000..0fca778b --- /dev/null +++ b/internal/mcp/branch_list.go @@ -0,0 +1,83 @@ +package mcp + +import ( + "context" + "fmt" + "log/slog" + "net/http" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/timescale/tiger-cli/internal/common" + "github.com/timescale/tiger-cli/internal/util" +) + +// BranchListInput represents input for branch_list +type BranchListInput struct { + ServiceID string `json:"service_id"` +} + +func (BranchListInput) Schema() *jsonschema.Schema { + schema := util.Must(jsonschema.For[BranchListInput](nil)) + setServiceIDSchemaProperties(schema) + schema.Properties["service_id"].Description = "Unique identifier of the service whose branches to list (10-character alphanumeric string). Use service_list to find service IDs." + return schema +} + +// BranchListOutput represents output for branch_list +type BranchListOutput struct { + Branches []BranchDetail `json:"branches"` +} + +func (BranchListOutput) Schema() *jsonschema.Schema { + schema := util.Must(jsonschema.For[BranchListOutput](nil)) + schema.Properties["branches"].Items = BranchDetail{}.Schema() + return schema +} + +func newBranchListTool() *mcp.Tool { + return &mcp.Tool{ + Name: toolBranchList, + Title: "List Branches", + Description: "List the branches of a database service. " + + "Returns each branch's status, resource allocation, and connection details.", + InputSchema: BranchListInput{}.Schema(), + OutputSchema: BranchListOutput{}.Schema(), + Annotations: &mcp.ToolAnnotations{ + ReadOnlyHint: true, + OpenWorldHint: new(false), + Title: "List Branches", + }, + } +} + +// handleBranchList handles the branch_list MCP tool +func (s *Server) handleBranchList(ctx context.Context, req *mcp.CallToolRequest, input BranchListInput) (*mcp.CallToolResult, BranchListOutput, error) { + cfg, client, projectID, err := s.app.GetAll() + if err != nil { + return nil, BranchListOutput{}, err + } + + s.logger.Info("MCP: Listing branches", + slog.String("project_id", projectID), + slog.String("service_id", input.ServiceID)) + + resp, err := client.GetBranchesWithResponse(ctx, projectID, input.ServiceID, nil) + if err != nil { + return nil, BranchListOutput{}, fmt.Errorf("failed to list branches: %w", err) + } + if resp.StatusCode() != http.StatusOK { + return nil, BranchListOutput{}, common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) + } + if resp.JSON200 == nil { + return nil, BranchListOutput{}, fmt.Errorf("empty response from API") + } + + branches := make([]BranchDetail, len(*resp.JSON200)) + for i, branch := range *resp.JSON200 { + branches[i] = s.convertToBranchDetail(cfg, branch) + } + + return nil, BranchListOutput{Branches: branches}, nil +} diff --git a/internal/mcp/branch_list_test.go b/internal/mcp/branch_list_test.go new file mode 100644 index 00000000..4e386ed0 --- /dev/null +++ b/internal/mcp/branch_list_test.go @@ -0,0 +1,99 @@ +package mcp + +import ( + "errors" + "net/http" + "testing" + + "github.com/timescale/tiger-cli/internal/api" + "github.com/timescale/tiger-cli/internal/api/mocks" +) + +func TestBranchListTool(t *testing.T) { + experimental := withExperimental() + args := map[string]any{"service_id": "e6ue9697jf"} + expectList := func(branches ...api.Branch) func(m *mocks.MockClientWithResponsesInterface) { + return func(m *mocks.MockClientWithResponsesInterface) { + m.EXPECT().GetBranchesWithResponse(validCtx, testProjectID, "e6ue9697jf", nil). + Return(&api.GetBranchesResponse{ + HTTPResponse: httpResponse(http.StatusOK), + JSON200: &branches, + }, nil) + } + } + + runToolTests(t, []toolTest{ + { + name: "not registered without the experimental gate", + tool: toolBranchList, + args: args, + wantCallErr: `calling "tools/call": unknown tool "branch_list"`, + }, + { + name: "not logged in", + tool: toolBranchList, + args: args, + opts: []runOption{experimental, withNotLoggedIn()}, + wantErr: notLoggedInMsg, + }, + { + name: "rejects a malformed service ID", + tool: toolBranchList, + args: map[string]any{"service_id": "nope"}, + opts: []runOption{experimental}, + wantErr: `validating "arguments": validating root: validating /properties/service_id: pattern: "nope" does not match regular expression "^[a-z0-9]{10}$"`, + }, + { + name: "network error", + tool: toolBranchList, + args: args, + opts: []runOption{experimental}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + m.EXPECT().GetBranchesWithResponse(validCtx, testProjectID, "e6ue9697jf", nil). + Return(nil, errors.New("connection refused")) + }, + wantErr: "failed to list branches: connection refused", + }, + { + name: "API error", + tool: toolBranchList, + args: args, + opts: []runOption{experimental}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + m.EXPECT().GetBranchesWithResponse(validCtx, testProjectID, "e6ue9697jf", nil). + Return(&api.GetBranchesResponse{ + HTTPResponse: httpResponse(http.StatusForbidden), + JSON4XX: &api.ClientError{Message: new("forbidden")}, + }, nil) + }, + wantErr: "forbidden", + }, + { + name: "nil response body", + tool: toolBranchList, + args: args, + opts: []runOption{experimental}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + m.EXPECT().GetBranchesWithResponse(validCtx, testProjectID, "e6ue9697jf", nil). + Return(&api.GetBranchesResponse{HTTPResponse: httpResponse(http.StatusOK)}, nil) + }, + wantErr: "empty response from API", + }, + { + name: "no branches", + tool: toolBranchList, + args: args, + opts: []runOption{experimental}, + mock: expectList(), + wantOutput: map[string]any{"branches": []any{}}, + }, + { + name: "lists the service's branches", + tool: toolBranchList, + args: args, + opts: []runOption{experimental}, + mock: expectList(sampleBranch()), + wantOutput: map[string]any{"branches": []any{sampleBranchDetail(api.DeployStatusREADY)}}, + }, + }) +} diff --git a/internal/mcp/branch_rename.go b/internal/mcp/branch_rename.go new file mode 100644 index 00000000..70236dd1 --- /dev/null +++ b/internal/mcp/branch_rename.go @@ -0,0 +1,111 @@ +package mcp + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "strings" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/timescale/tiger-cli/internal/api" + "github.com/timescale/tiger-cli/internal/common" + "github.com/timescale/tiger-cli/internal/util" +) + +// BranchRenameInput represents input for branch_rename +type BranchRenameInput struct { + BranchID string `json:"branch_id"` + Name string `json:"name"` +} + +func (BranchRenameInput) Schema() *jsonschema.Schema { + schema := util.Must(jsonschema.For[BranchRenameInput](nil)) + + setBranchIDSchemaProperties(schema) + + schema.Properties["name"].Description = "The new name for the branch, unique within its parent service." + schema.Properties["name"].Examples = []any{"feature/rbac", "ci-4821"} + + return schema +} + +// BranchRenameOutput represents output for branch_rename +type BranchRenameOutput struct { + Message string `json:"message"` + BranchID string `json:"branch_id"` + Name string `json:"name"` +} + +func (BranchRenameOutput) Schema() *jsonschema.Schema { + schema := util.Must(jsonschema.For[BranchRenameOutput](nil)) + schema.Properties["message"].Description = "Human-readable result of the rename operation" + schema.Properties["branch_id"].Description = "Unique identifier of the renamed branch, unchanged by the rename" + schema.Properties["name"].Description = "Name of the branch as stored after the rename" + return schema +} + +func newBranchRenameTool() *mcp.Tool { + return &mcp.Tool{ + Name: toolBranchRename, + Title: "Rename Branch", + Description: "Rename a branch. " + + "Only the name changes: the branch ID, endpoint, and data are untouched, so existing connections and connection strings keep working.", + InputSchema: BranchRenameInput{}.Schema(), + OutputSchema: BranchRenameOutput{}.Schema(), + Annotations: &mcp.ToolAnnotations{ + ReadOnlyHint: false, + DestructiveHint: new(false), // A rename loses nothing and can be reversed by renaming back + IdempotentHint: true, // Renaming to the name a branch already has changes nothing + OpenWorldHint: new(false), + Title: "Rename Branch", + }, + } +} + +// handleBranchRename handles the branch_rename MCP tool +func (s *Server) handleBranchRename(ctx context.Context, req *mcp.CallToolRequest, input BranchRenameInput) (*mcp.CallToolResult, BranchRenameOutput, error) { + cfg, client, projectID, err := s.app.GetAll() + if err != nil { + return nil, BranchRenameOutput{}, err + } + + if err := common.CheckReadOnly(cfg, common.BranchEnvironmentTag); err != nil { + return nil, BranchRenameOutput{}, err + } + + name := strings.TrimSpace(input.Name) + if name == "" { + return nil, BranchRenameOutput{}, errors.New("new name cannot be empty") + } + + resolved, err := resolveBranchByID(ctx, client, projectID, input.BranchID) + if err != nil { + return nil, BranchRenameOutput{}, err + } + + s.logger.Info("MCP: Renaming branch", + slog.String("project_id", projectID), + slog.String("branch_id", input.BranchID)) + + resp, err := client.RenameBranchWithResponse(ctx, projectID, resolved.ServiceID, resolved.ID, api.BranchRename{Name: name}) + if err != nil { + return nil, BranchRenameOutput{}, fmt.Errorf("failed to rename branch: %w", err) + } + if resp.StatusCode() != http.StatusOK { + return nil, BranchRenameOutput{}, common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) + } + if resp.JSON200 == nil { + return nil, BranchRenameOutput{}, errors.New("empty response from API") + } + renamed := *resp.JSON200 + + return nil, BranchRenameOutput{ + Message: fmt.Sprintf("Branch %q renamed to %q.", renamed.ID, renamed.Name), + BranchID: renamed.ID, + Name: renamed.Name, + }, nil +} diff --git a/internal/mcp/branch_rename_test.go b/internal/mcp/branch_rename_test.go new file mode 100644 index 00000000..fd283f7c --- /dev/null +++ b/internal/mcp/branch_rename_test.go @@ -0,0 +1,92 @@ +package mcp + +import ( + "errors" + "net/http" + "testing" + + "github.com/timescale/tiger-cli/internal/api" + "github.com/timescale/tiger-cli/internal/api/mocks" +) + +func TestBranchRenameTool(t *testing.T) { + experimental := withExperimental() + args := map[string]any{"branch_id": "br4nch1d9x", "name": "feature/rbac"} + expectRename := func(m *mocks.MockClientWithResponsesInterface) { + renamed := sampleBranch(func(b *api.Branch) { b.Name = "feature/rbac" }) + expectResolveBranch(m, sampleBranch()) + m.EXPECT().RenameBranchWithResponse(validCtx, testProjectID, "e6ue9697jf", "br4nch1d9x", api.BranchRename{Name: "feature/rbac"}). + Return(&api.RenameBranchResponse{ + HTTPResponse: httpResponse(http.StatusOK), + JSON200: &renamed, + }, nil) + } + renamed := map[string]any{ + "message": `Branch "br4nch1d9x" renamed to "feature/rbac".`, + "branch_id": "br4nch1d9x", + "name": "feature/rbac", + } + + runToolTests(t, []toolTest{ + { + name: "not registered without the experimental gate", + tool: toolBranchRename, + args: args, + wantCallErr: `calling "tools/call": unknown tool "branch_rename"`, + }, + { + name: "read-only all refuses without an API call", + tool: toolBranchRename, + args: args, + opts: []runOption{experimental, withConfigAfterStart(map[string]any{"read_only": "all"})}, + wantErr: "this operation is not allowed in read-only mode", + }, + { + name: "read-only prod allows", + tool: toolBranchRename, + args: args, + opts: []runOption{experimental, withConfig(map[string]any{"read_only": "prod"})}, + mock: expectRename, + wantOutput: renamed, + }, + { + name: "empty name", + tool: toolBranchRename, + args: map[string]any{"branch_id": "br4nch1d9x", "name": " "}, + opts: []runOption{experimental}, + wantErr: "new name cannot be empty", + }, + { + name: "network error", + tool: toolBranchRename, + args: args, + opts: []runOption{experimental}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, sampleBranch()) + m.EXPECT().RenameBranchWithResponse(validCtx, testProjectID, "e6ue9697jf", "br4nch1d9x", api.BranchRename{Name: "feature/rbac"}). + Return(nil, errors.New("connection refused")) + }, + wantErr: "failed to rename branch: connection refused", + }, + { + name: "nil response body", + tool: toolBranchRename, + args: args, + opts: []runOption{experimental}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, sampleBranch()) + m.EXPECT().RenameBranchWithResponse(validCtx, testProjectID, "e6ue9697jf", "br4nch1d9x", api.BranchRename{Name: "feature/rbac"}). + Return(&api.RenameBranchResponse{HTTPResponse: httpResponse(http.StatusOK)}, nil) + }, + wantErr: "empty response from API", + }, + { + name: "success", + tool: toolBranchRename, + args: args, + opts: []runOption{experimental}, + mock: expectRename, + wantOutput: renamed, + }, + }) +} diff --git a/internal/mcp/branch_resize.go b/internal/mcp/branch_resize.go new file mode 100644 index 00000000..17575812 --- /dev/null +++ b/internal/mcp/branch_resize.go @@ -0,0 +1,156 @@ +package mcp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/timescale/tiger-cli/internal/api" + "github.com/timescale/tiger-cli/internal/common" + "github.com/timescale/tiger-cli/internal/util" +) + +// BranchResizeInput represents input for branch_resize +type BranchResizeInput struct { + BranchID string `json:"branch_id"` + CPUMemory string `json:"cpu_memory,omitempty"` + IOPS *int `json:"iops,omitempty"` + Wait bool `json:"wait,omitempty"` +} + +// Schema leaves both cpu_memory and iops optional; the handler requires at +// least one of the two. +func (BranchResizeInput) Schema() *jsonschema.Schema { + schema := util.Must(jsonschema.For[BranchResizeInput](nil)) + + setBranchIDSchemaProperties(schema) + setBranchCPUMemorySchemaProperties(schema, "CPU and memory allocation combination. If not specified, the branch keeps its current CPU and memory, and at least one of cpu_memory or iops is required.") + setBranchIOPSSchemaProperties(schema, "") + + schema.Properties["wait"].Description = "Whether to wait for the branch to be done resizing before returning. Default is false (recommended). Only set to true if your next steps require connecting to or querying the branch. When true, waits up to 10 minutes." + schema.Properties["wait"].Default = util.Must(json.Marshal(false)) + schema.Properties["wait"].Examples = []any{false, true} + + return schema +} + +// BranchResizeOutput represents output for branch_resize +type BranchResizeOutput struct { + Status string `json:"status"` + Resources *ResourceInfo `json:"resources,omitempty"` + Message string `json:"message"` +} + +func (BranchResizeOutput) Schema() *jsonschema.Schema { + schema := util.Must(jsonschema.For[BranchResizeOutput](nil)) + schema.Properties["status"].Description = "Current branch status after resize operation" + // The branch tools only exist behind the experimental gate, so the IOPS + // are always included. + setResourceInfoSchemaProperties(schema.Properties["resources"], true) + schema.Properties["message"].Description = "Human-readable outcome of the operation" + return schema +} + +func newBranchResizeTool() *mcp.Tool { + return &mcp.Tool{ + Name: toolBranchResize, + Title: "Resize Branch", + Description: `Resize a branch by changing its CPU and memory allocation or its provisioned IOPS. + +A branch's storage is shared with its parent service and isn't resized. The branch may be temporarily unavailable during the resize operation. + +WARNING: Creates billable resource changes. Increasing resources will increase costs.`, + InputSchema: BranchResizeInput{}.Schema(), + OutputSchema: BranchResizeOutput{}.Schema(), + Annotations: &mcp.ToolAnnotations{ + ReadOnlyHint: false, + DestructiveHint: new(false), // Not destructive, just modifies resources + IdempotentHint: true, // Can resize to same size multiple times + OpenWorldHint: new(false), + Title: "Resize Branch", + }, + } +} + +// handleBranchResize handles the branch_resize MCP tool +func (s *Server) handleBranchResize(ctx context.Context, req *mcp.CallToolRequest, input BranchResizeInput) (*mcp.CallToolResult, BranchResizeOutput, error) { + cfg, client, projectID, err := s.app.GetAll() + if err != nil { + return nil, BranchResizeOutput{}, err + } + + if err := common.CheckReadOnly(cfg, common.BranchEnvironmentTag); err != nil { + return nil, BranchResizeOutput{}, err + } + + if input.CPUMemory == "" && input.IOPS == nil { + return nil, BranchResizeOutput{}, errors.New("at least one of cpu_memory or iops is required") + } + + resolved, err := resolveBranchByID(ctx, client, projectID, input.BranchID) + if err != nil { + return nil, BranchResizeOutput{}, err + } + + // An IOPS-only resize omits CPU and memory, which the API leaves + // unchanged. + resizeReq := api.BranchResizeInput{Iops: input.IOPS} + if input.CPUMemory != "" { + cpuMemoryCfg, err := parseBranchCPUMemory(input.CPUMemory) + if err != nil { + return nil, BranchResizeOutput{}, err + } + resizeReq.CPUMillis = cpuMemoryCfg.CPUMillisString() + resizeReq.MemoryGbs = cpuMemoryCfg.MemoryGBsString() + } + + s.logger.Info("MCP: Resizing branch", + slog.String("project_id", projectID), + slog.String("branch_id", input.BranchID), + slog.String("cpu_memory", input.CPUMemory), + slog.Any("iops", input.IOPS), + ) + + resp, err := client.ResizeBranchWithResponse(ctx, projectID, resolved.ServiceID, resolved.ID, resizeReq) + if err != nil { + return nil, BranchResizeOutput{}, fmt.Errorf("failed to resize branch: %w", err) + } + if resp.StatusCode() != http.StatusAccepted { + return nil, BranchResizeOutput{}, common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) + } + if resp.JSON202 == nil { + return nil, BranchResizeOutput{}, fmt.Errorf("empty response from API") + } + branch := *resp.JSON202 + + message := "Resize request accepted. The branch may still be resizing." + if input.Wait { + if err := common.WaitForBranch(ctx, common.WaitForBranchArgs{ + Client: client, + ProjectID: projectID, + ServiceID: resolved.ServiceID, + BranchID: resolved.ID, + Branch: &branch, + TargetStatus: api.DeployStatusREADY, + Timeout: waitTimeout, + TimeoutMsg: "branch may still be resizing", + }); err != nil { + message = fmt.Sprintf("Error: %s", err.Error()) + } else { + message = "Branch resized." + } + } + + detail := s.convertToBranchDetail(cfg, branch) + return nil, BranchResizeOutput{ + Status: detail.Status, + Resources: detail.Resources, + Message: message, + }, nil +} diff --git a/internal/mcp/branch_resize_test.go b/internal/mcp/branch_resize_test.go new file mode 100644 index 00000000..13206558 --- /dev/null +++ b/internal/mcp/branch_resize_test.go @@ -0,0 +1,129 @@ +package mcp + +import ( + "errors" + "net/http" + "testing" + + "github.com/timescale/tiger-cli/internal/api" + "github.com/timescale/tiger-cli/internal/api/mocks" +) + +func TestBranchResizeTool(t *testing.T) { + experimental := withExperimental() + args := map[string]any{"branch_id": "br4nch1d9x", "cpu_memory": "2 CPU/16 GB"} + req := api.BranchResizeInput{CPUMillis: new("2000"), MemoryGbs: new("16")} + expectResizeWith := func(req api.BranchResizeInput, status api.DeployStatus) func(*mocks.MockClientWithResponsesInterface) { + return func(m *mocks.MockClientWithResponsesInterface) { + branch := sampleBranch(branchStatus(status)) + expectResolveBranch(m, sampleBranch()) + m.EXPECT().ResizeBranchWithResponse(validCtx, testProjectID, "e6ue9697jf", "br4nch1d9x", req). + Return(&api.ResizeBranchResponse{ + HTTPResponse: httpResponse(http.StatusAccepted), + JSON202: &branch, + }, nil) + } + } + expectResize := func(status api.DeployStatus) func(*mocks.MockClientWithResponsesInterface) { + return expectResizeWith(req, status) + } + // The response's resources are the sample's, since the resize hasn't + // landed yet. + resources := map[string]any{"cpu": "0.25 cores", "memory": "2 GB"} + accepted := map[string]any{ + "status": "CONFIGURING", + "resources": resources, + "message": "Resize request accepted. The branch may still be resizing.", + } + + runToolTests(t, []toolTest{ + { + name: "not registered without the experimental gate", + tool: toolBranchResize, + args: args, + wantCallErr: `calling "tools/call": unknown tool "branch_resize"`, + }, + { + name: "missing cpu_memory and iops", + tool: toolBranchResize, + args: map[string]any{"branch_id": "br4nch1d9x"}, + opts: []runOption{experimental}, + wantErr: "at least one of cpu_memory or iops is required", + }, + { + name: "read-only all refuses without an API call", + tool: toolBranchResize, + args: args, + opts: []runOption{experimental, withConfigAfterStart(map[string]any{"read_only": "all"})}, + wantErr: "this operation is not allowed in read-only mode", + }, + { + name: "read-only prod allows", + tool: toolBranchResize, + args: args, + opts: []runOption{experimental, withConfig(map[string]any{"read_only": "prod"})}, + mock: expectResize(api.DeployStatusCONFIGURING), + wantOutput: accepted, + }, + { + name: "network error", + tool: toolBranchResize, + args: args, + opts: []runOption{experimental}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, sampleBranch()) + m.EXPECT().ResizeBranchWithResponse(validCtx, testProjectID, "e6ue9697jf", "br4nch1d9x", req). + Return(nil, errors.New("connection refused")) + }, + wantErr: "failed to resize branch: connection refused", + }, + { + name: "nil response body", + tool: toolBranchResize, + args: args, + opts: []runOption{experimental}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, sampleBranch()) + m.EXPECT().ResizeBranchWithResponse(validCtx, testProjectID, "e6ue9697jf", "br4nch1d9x", req). + Return(&api.ResizeBranchResponse{HTTPResponse: httpResponse(http.StatusAccepted)}, nil) + }, + wantErr: "empty response from API", + }, + { + // CPU and memory are left out, so the API keeps the current ones. + name: "iops only omits cpu and memory", + tool: toolBranchResize, + args: map[string]any{"branch_id": "br4nch1d9x", "iops": 16000}, + opts: []runOption{experimental}, + mock: expectResizeWith(api.BranchResizeInput{Iops: new(16000)}, api.DeployStatusCONFIGURING), + wantOutput: accepted, + }, + { + name: "resizes without waiting by default", + tool: toolBranchResize, + args: args, + opts: []runOption{experimental}, + mock: expectResize(api.DeployStatusCONFIGURING), + wantOutput: accepted, + }, + { + name: "wait polls until ready", + synctest: true, + tool: toolBranchResize, + args: map[string]any{"branch_id": "br4nch1d9x", "cpu_memory": "2 CPU/16 GB", "wait": true}, + opts: []runOption{experimental}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResize(api.DeployStatusCONFIGURING)(m) + expectGetBranch(m, sampleBranch(func(b *api.Branch) { + b.CPUMillis = 2000 + b.MemoryGbs = 16 + })) + }, + wantOutput: map[string]any{ + "status": "READY", + "resources": map[string]any{"cpu": "2 cores", "memory": "16 GB"}, + "message": "Branch resized.", + }, + }, + }) +} diff --git a/internal/mcp/branch_start.go b/internal/mcp/branch_start.go new file mode 100644 index 00000000..4afd91ff --- /dev/null +++ b/internal/mcp/branch_start.go @@ -0,0 +1,122 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/timescale/tiger-cli/internal/api" + "github.com/timescale/tiger-cli/internal/common" + "github.com/timescale/tiger-cli/internal/util" +) + +// BranchStartInput represents input for branch_start +type BranchStartInput struct { + BranchID string `json:"branch_id"` + Wait bool `json:"wait,omitempty"` +} + +func (BranchStartInput) Schema() *jsonschema.Schema { + schema := util.Must(jsonschema.For[BranchStartInput](nil)) + + setBranchIDSchemaProperties(schema) + + schema.Properties["wait"].Description = "Whether to wait for the branch to be fully started before returning. Default is false (recommended). Only set to true if your next steps require connecting to or querying the branch. When true, waits up to 10 minutes." + schema.Properties["wait"].Default = util.Must(json.Marshal(false)) + schema.Properties["wait"].Examples = []any{false, true} + + return schema +} + +// BranchStartOutput represents output for branch_start +type BranchStartOutput struct { + Status string `json:"status"` + Message string `json:"message"` +} + +func (BranchStartOutput) Schema() *jsonschema.Schema { + schema := util.Must(jsonschema.For[BranchStartOutput](nil)) + schema.Properties["status"].Description = "Current branch status after start operation" + schema.Properties["message"].Description = "Human-readable outcome of the operation" + return schema +} + +func newBranchStartTool() *mcp.Tool { + return &mcp.Tool{ + Name: toolBranchStart, + Title: "Start Branch", + Description: `Start a stopped branch. + +Starting a branch resumes charging for its compute.`, + InputSchema: BranchStartInput{}.Schema(), + OutputSchema: BranchStartOutput{}.Schema(), + Annotations: &mcp.ToolAnnotations{ + ReadOnlyHint: false, + DestructiveHint: new(false), // Starting a branch doesn't modify or destroy anything + IdempotentHint: true, // Starting an already-started branch is safe (but returns an error) + OpenWorldHint: new(false), + Title: "Start Branch", + }, + } +} + +// handleBranchStart handles the branch_start MCP tool +func (s *Server) handleBranchStart(ctx context.Context, req *mcp.CallToolRequest, input BranchStartInput) (*mcp.CallToolResult, BranchStartOutput, error) { + cfg, client, projectID, err := s.app.GetAll() + if err != nil { + return nil, BranchStartOutput{}, err + } + + if err := common.CheckReadOnly(cfg, common.BranchEnvironmentTag); err != nil { + return nil, BranchStartOutput{}, err + } + + resolved, err := resolveBranchByID(ctx, client, projectID, input.BranchID) + if err != nil { + return nil, BranchStartOutput{}, err + } + + s.logger.Info("MCP: Starting branch", + slog.String("project_id", projectID), + slog.String("branch_id", input.BranchID)) + + resp, err := client.StartBranchWithResponse(ctx, projectID, resolved.ServiceID, resolved.ID) + if err != nil { + return nil, BranchStartOutput{}, fmt.Errorf("failed to start branch: %w", err) + } + if resp.StatusCode() != http.StatusAccepted { + return nil, BranchStartOutput{}, common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) + } + if resp.JSON202 == nil { + return nil, BranchStartOutput{}, fmt.Errorf("empty response from API") + } + branch := *resp.JSON202 + + message := "Branch start request accepted. The branch may still be starting." + if input.Wait { + if err := common.WaitForBranch(ctx, common.WaitForBranchArgs{ + Client: client, + ProjectID: projectID, + ServiceID: resolved.ServiceID, + BranchID: resolved.ID, + Branch: &branch, + TargetStatus: api.DeployStatusREADY, + Timeout: waitTimeout, + TimeoutMsg: "branch may still be starting", + }); err != nil { + message = fmt.Sprintf("Error: %s", err.Error()) + } else { + message = "Branch started." + } + } + + return nil, BranchStartOutput{ + Status: string(branch.Status), + Message: message, + }, nil +} diff --git a/internal/mcp/branch_start_test.go b/internal/mcp/branch_start_test.go new file mode 100644 index 00000000..1dc3e676 --- /dev/null +++ b/internal/mcp/branch_start_test.go @@ -0,0 +1,86 @@ +package mcp + +import ( + "errors" + "net/http" + "testing" + + "github.com/timescale/tiger-cli/internal/api" + "github.com/timescale/tiger-cli/internal/api/mocks" +) + +func TestBranchStartTool(t *testing.T) { + experimental := withExperimental() + args := map[string]any{"branch_id": "br4nch1d9x"} + expectStart := func(status api.DeployStatus) func(*mocks.MockClientWithResponsesInterface) { + return func(m *mocks.MockClientWithResponsesInterface) { + branch := sampleBranch(branchStatus(status)) + expectResolveBranch(m, sampleBranch()) + m.EXPECT().StartBranchWithResponse(validCtx, testProjectID, "e6ue9697jf", "br4nch1d9x"). + Return(&api.StartBranchResponse{ + HTTPResponse: httpResponse(http.StatusAccepted), + JSON202: &branch, + }, nil) + } + } + accepted := map[string]any{ + "status": "RESUMING", + "message": "Branch start request accepted. The branch may still be starting.", + } + + runToolTests(t, []toolTest{ + { + name: "not registered without the experimental gate", + tool: toolBranchStart, + args: args, + wantCallErr: `calling "tools/call": unknown tool "branch_start"`, + }, + { + name: "read-only all refuses without an API call", + tool: toolBranchStart, + args: args, + opts: []runOption{experimental, withConfigAfterStart(map[string]any{"read_only": "all"})}, + wantErr: "this operation is not allowed in read-only mode", + }, + { + name: "read-only prod allows", + tool: toolBranchStart, + args: args, + opts: []runOption{experimental, withConfig(map[string]any{"read_only": "prod"})}, + mock: expectStart(api.DeployStatusRESUMING), + wantOutput: accepted, + }, + { + name: "network error", + tool: toolBranchStart, + args: args, + opts: []runOption{experimental}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, sampleBranch()) + m.EXPECT().StartBranchWithResponse(validCtx, testProjectID, "e6ue9697jf", "br4nch1d9x"). + Return(nil, errors.New("connection refused")) + }, + wantErr: "failed to start branch: connection refused", + }, + { + name: "starts without waiting by default", + tool: toolBranchStart, + args: args, + opts: []runOption{experimental}, + mock: expectStart(api.DeployStatusRESUMING), + wantOutput: accepted, + }, + { + name: "wait polls until ready", + synctest: true, + tool: toolBranchStart, + args: map[string]any{"branch_id": "br4nch1d9x", "wait": true}, + opts: []runOption{experimental}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectStart(api.DeployStatusRESUMING)(m) + expectGetBranch(m, sampleBranch()) + }, + wantOutput: map[string]any{"status": "READY", "message": "Branch started."}, + }, + }) +} diff --git a/internal/mcp/branch_stop.go b/internal/mcp/branch_stop.go new file mode 100644 index 00000000..b6b86214 --- /dev/null +++ b/internal/mcp/branch_stop.go @@ -0,0 +1,122 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/timescale/tiger-cli/internal/api" + "github.com/timescale/tiger-cli/internal/common" + "github.com/timescale/tiger-cli/internal/util" +) + +// BranchStopInput represents input for branch_stop +type BranchStopInput struct { + BranchID string `json:"branch_id"` + Wait bool `json:"wait,omitempty"` +} + +func (BranchStopInput) Schema() *jsonschema.Schema { + schema := util.Must(jsonschema.For[BranchStopInput](nil)) + + setBranchIDSchemaProperties(schema) + + schema.Properties["wait"].Description = "Whether to wait for the branch to be fully stopped before returning. Default is false (recommended). Only set to true if your next steps require confirmation that the branch is stopped. When true, waits up to 10 minutes." + schema.Properties["wait"].Default = util.Must(json.Marshal(false)) + schema.Properties["wait"].Examples = []any{false, true} + + return schema +} + +// BranchStopOutput represents output for branch_stop +type BranchStopOutput struct { + Status string `json:"status"` + Message string `json:"message"` +} + +func (BranchStopOutput) Schema() *jsonschema.Schema { + schema := util.Must(jsonschema.For[BranchStopOutput](nil)) + schema.Properties["status"].Description = "Current branch status after stop operation" + schema.Properties["message"].Description = "Human-readable outcome of the operation" + return schema +} + +func newBranchStopTool() *mcp.Tool { + return &mcp.Tool{ + Name: toolBranchStop, + Title: "Stop Branch", + Description: `Stop a running branch. + +A stopped branch no longer accepts connections and stops charging for compute, but keeps the changes made on it, whose storage is still billed.`, + InputSchema: BranchStopInput{}.Schema(), + OutputSchema: BranchStopOutput{}.Schema(), + Annotations: &mcp.ToolAnnotations{ + ReadOnlyHint: false, + DestructiveHint: new(true), // Stopping a branch breaks existing connections + IdempotentHint: true, // Stopping an already-stopped branch is safe (but returns an error) + OpenWorldHint: new(false), + Title: "Stop Branch", + }, + } +} + +// handleBranchStop handles the branch_stop MCP tool +func (s *Server) handleBranchStop(ctx context.Context, req *mcp.CallToolRequest, input BranchStopInput) (*mcp.CallToolResult, BranchStopOutput, error) { + cfg, client, projectID, err := s.app.GetAll() + if err != nil { + return nil, BranchStopOutput{}, err + } + + if err := common.CheckReadOnly(cfg, common.BranchEnvironmentTag); err != nil { + return nil, BranchStopOutput{}, err + } + + resolved, err := resolveBranchByID(ctx, client, projectID, input.BranchID) + if err != nil { + return nil, BranchStopOutput{}, err + } + + s.logger.Info("MCP: Stopping branch", + slog.String("project_id", projectID), + slog.String("branch_id", input.BranchID)) + + resp, err := client.StopBranchWithResponse(ctx, projectID, resolved.ServiceID, resolved.ID) + if err != nil { + return nil, BranchStopOutput{}, fmt.Errorf("failed to stop branch: %w", err) + } + if resp.StatusCode() != http.StatusAccepted { + return nil, BranchStopOutput{}, common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) + } + if resp.JSON202 == nil { + return nil, BranchStopOutput{}, fmt.Errorf("empty response from API") + } + branch := *resp.JSON202 + + message := "Branch stop request accepted. The branch may still be stopping." + if input.Wait { + if err := common.WaitForBranch(ctx, common.WaitForBranchArgs{ + Client: client, + ProjectID: projectID, + ServiceID: resolved.ServiceID, + BranchID: resolved.ID, + Branch: &branch, + TargetStatus: api.DeployStatusPAUSED, + Timeout: waitTimeout, + TimeoutMsg: "branch may still be stopping", + }); err != nil { + message = fmt.Sprintf("Error: %s", err.Error()) + } else { + message = "Branch stopped." + } + } + + return nil, BranchStopOutput{ + Status: string(branch.Status), + Message: message, + }, nil +} diff --git a/internal/mcp/branch_stop_test.go b/internal/mcp/branch_stop_test.go new file mode 100644 index 00000000..eb6f2351 --- /dev/null +++ b/internal/mcp/branch_stop_test.go @@ -0,0 +1,105 @@ +package mcp + +import ( + "errors" + "net/http" + "testing" + + "github.com/timescale/tiger-cli/internal/api" + "github.com/timescale/tiger-cli/internal/api/mocks" +) + +func TestBranchStopTool(t *testing.T) { + experimental := withExperimental() + args := map[string]any{"branch_id": "br4nch1d9x"} + expectStop := func(status api.DeployStatus) func(*mocks.MockClientWithResponsesInterface) { + return func(m *mocks.MockClientWithResponsesInterface) { + branch := sampleBranch(branchStatus(status)) + expectResolveBranch(m, sampleBranch()) + m.EXPECT().StopBranchWithResponse(validCtx, testProjectID, "e6ue9697jf", "br4nch1d9x"). + Return(&api.StopBranchResponse{ + HTTPResponse: httpResponse(http.StatusAccepted), + JSON202: &branch, + }, nil) + } + } + accepted := map[string]any{ + "status": "PAUSING", + "message": "Branch stop request accepted. The branch may still be stopping.", + } + + runToolTests(t, []toolTest{ + { + name: "not registered without the experimental gate", + tool: toolBranchStop, + args: args, + wantCallErr: `calling "tools/call": unknown tool "branch_stop"`, + }, + { + name: "not logged in", + tool: toolBranchStop, + args: args, + opts: []runOption{experimental, withNotLoggedIn()}, + wantErr: notLoggedInMsg, + }, + { + name: "read-only all refuses without an API call", + tool: toolBranchStop, + args: args, + opts: []runOption{experimental, withConfigAfterStart(map[string]any{"read_only": "all"})}, + wantErr: "this operation is not allowed in read-only mode", + }, + { + name: "read-only prod allows", + tool: toolBranchStop, + args: args, + opts: []runOption{experimental, withConfig(map[string]any{"read_only": "prod"})}, + mock: expectStop(api.DeployStatusPAUSING), + wantOutput: accepted, + }, + { + name: "network error", + tool: toolBranchStop, + args: args, + opts: []runOption{experimental}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, sampleBranch()) + m.EXPECT().StopBranchWithResponse(validCtx, testProjectID, "e6ue9697jf", "br4nch1d9x"). + Return(nil, errors.New("connection refused")) + }, + wantErr: "failed to stop branch: connection refused", + }, + { + name: "nil response body", + tool: toolBranchStop, + args: args, + opts: []runOption{experimental}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, sampleBranch()) + m.EXPECT().StopBranchWithResponse(validCtx, testProjectID, "e6ue9697jf", "br4nch1d9x"). + Return(&api.StopBranchResponse{HTTPResponse: httpResponse(http.StatusAccepted)}, nil) + }, + wantErr: "empty response from API", + }, + { + name: "stops without waiting by default", + tool: toolBranchStop, + args: args, + opts: []runOption{experimental}, + mock: expectStop(api.DeployStatusPAUSING), + wantOutput: accepted, + }, + { + name: "wait polls until paused", + synctest: true, + tool: toolBranchStop, + args: map[string]any{"branch_id": "br4nch1d9x", "wait": true}, + opts: []runOption{experimental}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectStop(api.DeployStatusPAUSING)(m) + expectGetBranch(m, sampleBranch(branchStatus(api.DeployStatusPAUSED))) + }, + wantOutput: map[string]any{"status": "PAUSED", "message": "Branch stopped."}, + }, + }) +} diff --git a/internal/mcp/branch_update_password.go b/internal/mcp/branch_update_password.go new file mode 100644 index 00000000..47f16314 --- /dev/null +++ b/internal/mcp/branch_update_password.go @@ -0,0 +1,124 @@ +package mcp + +import ( + "context" + "fmt" + "log/slog" + "net/http" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/timescale/tiger-cli/internal/api" + "github.com/timescale/tiger-cli/internal/common" + "github.com/timescale/tiger-cli/internal/util" +) + +// BranchUpdatePasswordInput represents input for branch_update_password. Like +// service_update_password, the tool takes no password and always generates +// one (see ServiceUpdatePasswordInput for why). +type BranchUpdatePasswordInput struct { + BranchID string `json:"branch_id"` + WithPassword bool `json:"with_password,omitempty"` +} + +func (BranchUpdatePasswordInput) Schema() *jsonschema.Schema { + schema := util.Must(jsonschema.For[BranchUpdatePasswordInput](nil)) + + setBranchIDSchemaProperties(schema) + setWithPasswordSchemaProperties(schema) + + return schema +} + +// BranchUpdatePasswordOutput represents output for branch_update_password +type BranchUpdatePasswordOutput struct { + Message string `json:"message"` + Password string `json:"password,omitempty"` + PasswordStorage *common.PasswordStorageResult `json:"password_storage,omitempty"` +} + +func (BranchUpdatePasswordOutput) Schema() *jsonschema.Schema { + schema := util.Must(jsonschema.For[BranchUpdatePasswordOutput](nil)) + + schema.Properties["message"].Description = "Human-readable outcome of the operation" + schema.Properties["password"].Description = "The new password for the tsdbadmin user (only included if with_password=true)" + schema.Properties["password_storage"].Description = "Where the new password was saved locally, and whether saving it succeeded" + + return schema +} + +func newBranchUpdatePasswordTool() *mcp.Tool { + return &mcp.Tool{ + Name: toolBranchUpdatePassword, + Title: "Update Branch Password", + Description: `Update the master password for the 'tsdbadmin' user of a branch. + +Changing it affects only the branch, not its parent service. The tool generates a secure random password itself; it does not accept one. The change takes effect immediately and may terminate existing connections to the branch. The new password is saved to the configured password storage and is included in the result only if with_password is true. + +If the user wants to set a specific password, tell them to run 'tiger branch update-password' from the CLI instead, which keeps the password out of this conversation.`, + InputSchema: BranchUpdatePasswordInput{}.Schema(), + OutputSchema: BranchUpdatePasswordOutput{}.Schema(), + Annotations: &mcp.ToolAnnotations{ + ReadOnlyHint: false, + DestructiveHint: new(true), // Replaces the credentials existing clients use + IdempotentHint: false, // Each call generates a different password + OpenWorldHint: new(false), + Title: "Update Branch Password", + }, + } +} + +// handleBranchUpdatePassword handles the branch_update_password MCP tool +func (s *Server) handleBranchUpdatePassword(ctx context.Context, req *mcp.CallToolRequest, input BranchUpdatePasswordInput) (*mcp.CallToolResult, BranchUpdatePasswordOutput, error) { + cfg, client, projectID, err := s.app.GetAll() + if err != nil { + return nil, BranchUpdatePasswordOutput{}, err + } + + if err := common.CheckReadOnly(cfg, common.BranchEnvironmentTag); err != nil { + return nil, BranchUpdatePasswordOutput{}, err + } + + // The branch is needed to save the new password under it, and its parent + // for the API call. + branch, err := resolveBranchByID(ctx, client, projectID, input.BranchID) + if err != nil { + return nil, BranchUpdatePasswordOutput{}, err + } + + password, err := common.GenerateSecurePassword(32) + if err != nil { + return nil, BranchUpdatePasswordOutput{}, err + } + + s.logger.Info("MCP: Updating branch password", + slog.String("project_id", projectID), + slog.String("branch_id", input.BranchID)) + + resp, err := client.UpdateBranchPasswordWithResponse(ctx, projectID, branch.ServiceID, branch.ID, api.UpdatePasswordInput{Password: password}) + if err != nil { + return nil, BranchUpdatePasswordOutput{}, fmt.Errorf("failed to update branch password: %w", err) + } + if resp.StatusCode() != http.StatusNoContent { + return nil, BranchUpdatePasswordOutput{}, common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) + } + + storage, err := common.SavePasswordWithResult(cfg, common.BranchPasswordTarget(*branch), password, "tsdbadmin") + if err != nil { + s.logger.Warn("MCP: Password storage failed", slog.Any("error", err)) + } + + message := "Password updated for tsdbadmin." + if !storage.Success && !input.WithPassword { + message += " Warning: the new password was not saved, and was not returned because with_password was false." + } + output := BranchUpdatePasswordOutput{ + Message: message, + PasswordStorage: &storage, + } + if input.WithPassword { + output.Password = password + } + return nil, output, nil +} diff --git a/internal/mcp/branch_update_password_test.go b/internal/mcp/branch_update_password_test.go new file mode 100644 index 00000000..226898e2 --- /dev/null +++ b/internal/mcp/branch_update_password_test.go @@ -0,0 +1,139 @@ +package mcp + +import ( + "errors" + "net/http" + "testing" + + "github.com/timescale/tiger-cli/internal/api" + "github.com/timescale/tiger-cli/internal/api/mocks" +) + +func TestBranchUpdatePasswordTool(t *testing.T) { + experimental := withExperimental() + args := map[string]any{"branch_id": "br4nch1d9x"} + generated := withGenerateSecurePassword(32, "generated-pw", nil) + expectUpdate := func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, sampleBranch()) + m.EXPECT().UpdateBranchPasswordWithResponse(validCtx, testProjectID, "e6ue9697jf", "br4nch1d9x", api.UpdatePasswordInput{Password: "generated-pw"}). + Return(&api.UpdateBranchPasswordResponse{HTTPResponse: httpResponse(http.StatusNoContent)}, nil) + } + storedPassword := map[string]any{ + "success": true, + "method": "keyring", + "message": "Password saved to system keyring", + } + + runToolTests(t, []toolTest{ + { + name: "not registered without the experimental gate", + tool: toolBranchUpdatePassword, + args: args, + wantCallErr: `calling "tools/call": unknown tool "branch_update_password"`, + }, + { + name: "read-only all refuses without an API call", + tool: toolBranchUpdatePassword, + args: args, + opts: []runOption{experimental, withConfigAfterStart(map[string]any{"read_only": "all"})}, + wantErr: "this operation is not allowed in read-only mode", + }, + { + // A branch always counts as DEV, so prod neither refuses nor + // prompts. + name: "read-only prod allows", + tool: toolBranchUpdatePassword, + args: args, + opts: []runOption{experimental, generated, withConfig(map[string]any{"read_only": "prod"})}, + mock: expectUpdate, + wantOutput: map[string]any{ + "message": "Password updated for tsdbadmin.", + "password_storage": storedPassword, + }, + }, + { + name: "branch not found", + tool: toolBranchUpdatePassword, + args: args, + opts: []runOption{experimental}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveStatus(m, "br4nch1d9x", branchType, http.StatusNotFound, &api.ClientError{Message: new("resource not found")}) + }, + wantErr: "branch 'br4nch1d9x' not found", + }, + { + name: "network error", + tool: toolBranchUpdatePassword, + args: args, + opts: []runOption{experimental, generated}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, sampleBranch()) + m.EXPECT().UpdateBranchPasswordWithResponse(validCtx, testProjectID, "e6ue9697jf", "br4nch1d9x", api.UpdatePasswordInput{Password: "generated-pw"}). + Return(nil, errors.New("connection refused")) + }, + wantErr: "failed to update branch password: connection refused", + }, + { + name: "password generation fails", + tool: toolBranchUpdatePassword, + args: args, + opts: []runOption{experimental, withGenerateSecurePassword(32, "", errors.New("failed to generate random password: entropy unavailable"))}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, sampleBranch()) + }, + wantErr: "failed to generate random password: entropy unavailable", + }, + { + // The endpoint answers success with a 204 and nothing else. + name: "unexpected 200 is an error", + tool: toolBranchUpdatePassword, + args: args, + opts: []runOption{experimental, generated}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, sampleBranch()) + m.EXPECT().UpdateBranchPasswordWithResponse(validCtx, testProjectID, "e6ue9697jf", "br4nch1d9x", api.UpdatePasswordInput{Password: "generated-pw"}). + Return(&api.UpdateBranchPasswordResponse{HTTPResponse: httpResponse(http.StatusOK)}, nil) + }, + wantErr: "unknown error", + }, + { + name: "saves the generated password", + tool: toolBranchUpdatePassword, + args: args, + opts: []runOption{experimental, generated}, + mock: expectUpdate, + wantOutput: map[string]any{ + "message": "Password updated for tsdbadmin.", + "password_storage": storedPassword, + }, + checks: []checkFunc{checkStoredPassword("br4nch1d9x", "generated-pw")}, + }, + { + name: "with_password returns the password", + tool: toolBranchUpdatePassword, + args: map[string]any{"branch_id": "br4nch1d9x", "with_password": true}, + opts: []runOption{experimental, generated}, + mock: expectUpdate, + wantOutput: map[string]any{ + "message": "Password updated for tsdbadmin.", + "password": "generated-pw", + "password_storage": storedPassword, + }, + }, + { + name: "warns when the password was neither saved nor returned", + tool: toolBranchUpdatePassword, + args: args, + opts: []runOption{experimental, generated, withConfig(map[string]any{"password_storage": "none"})}, + mock: expectUpdate, + wantOutput: map[string]any{ + "message": "Password updated for tsdbadmin. Warning: the new password was not saved, and was not returned because with_password was false.", + "password_storage": map[string]any{ + "success": false, + "method": "none", + "message": "Password not saved (--password-storage=none). Make sure to store it securely.", + }, + }, + }, + }) +} diff --git a/internal/mcp/main_test.go b/internal/mcp/main_test.go index ca1313f4..e5d814d2 100644 --- a/internal/mcp/main_test.go +++ b/internal/mcp/main_test.go @@ -2,6 +2,7 @@ package mcp import ( "context" + "encoding/json" "fmt" "maps" "net/http" @@ -11,6 +12,8 @@ import ( "testing/synctest" "time" + "github.com/timescale/tiger-cli/internal/util" + "github.com/google/go-cmp/cmp" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/zalando/go-keyring" @@ -515,6 +518,134 @@ func expectTaggedService(tag string, calls int) func(*mocks.MockClientWithRespon } } +// sampleBranch returns a branch of sampleService with reasonable defaults. +// Use overrides to customize specific fields. +func sampleBranch(overrides ...func(*api.Branch)) api.Branch { + branch := api.Branch{ + ID: "br4nch1d9x", + ProjectID: testProjectID, + ServiceID: "e6ue9697jf", + Name: "test-branch", + Status: api.DeployStatusREADY, + CPUMillis: 250, + MemoryGbs: 2, + Created: time.Date(2025, 1, 16, 9, 0, 0, 0, time.UTC), + Endpoint: &api.Endpoint{ + Host: new("br4nch1d9x.test-project-123.branch.tsdb.cloud.timescale.com"), + Port: new(36956), + }, + } + for _, o := range overrides { + o(&branch) + } + return branch +} + +// sampleBranchDetail is the BranchDetail output of sampleBranch, with the +// given status. +func sampleBranchDetail(status api.DeployStatus) map[string]any { + return map[string]any{ + "id": "br4nch1d9x", + "name": "test-branch", + "service_id": "e6ue9697jf", + "status": string(status), + "created": "2025-01-16T09:00:00Z", + "resources": map[string]any{"cpu": "0.25 cores", "memory": "2 GB"}, + "direct_endpoint": "br4nch1d9x.test-project-123.branch.tsdb.cloud.timescale.com:36956", + "connection_string": "postgresql://tsdbadmin@br4nch1d9x.test-project-123.branch.tsdb.cloud.timescale.com:36956/tsdb?sslmode=require", + } +} + +// branchStatus returns a sampleBranch override setting the branch's status. +func branchStatus(status api.DeployStatus) func(*api.Branch) { + return func(b *api.Branch) { b.Status = status } +} + +// expectResolve expects resolving ref through the resolve endpoint, +// restricted to resolveType (nil for none), answered with v: an api.Service, +// api.Branch, or api.ReadReplicaSet, whose Go type picks the discriminator. +// The call is returned so a caller can adjust its cardinality. +func expectResolve(m *mocks.MockClientWithResponsesInterface, ref string, resolveType *api.ResolveType, v any) *gomock.Call { + return m.EXPECT().ResolveRefWithResponse(validCtx, testProjectID, &api.ResolveRefParams{Ref: ref, Type: resolveType}). + Return(&api.ResolveRefResponse{ + HTTPResponse: httpResponse(http.StatusOK), + JSON200: resolveResult(v), + }, nil) +} + +// expectResolveStatus expects a resolve answered with status. A nil apiErr +// stands in for a response with no body. +func expectResolveStatus(m *mocks.MockClientWithResponsesInterface, ref string, resolveType *api.ResolveType, status int, apiErr *api.ClientError) { + m.EXPECT().ResolveRefWithResponse(validCtx, testProjectID, &api.ResolveRefParams{Ref: ref, Type: resolveType}). + Return(&api.ResolveRefResponse{ + HTTPResponse: httpResponse(status), + JSON4XX: apiErr, + }, nil) +} + +// resolveResult encodes v as the resolve endpoint's union, discriminated by +// v's Go type. Each member is its plain type plus the discriminator. +func resolveResult(v any) *api.ResolveResult { + var resolveType api.ResolveType + switch v.(type) { + case api.Service: + resolveType = api.ResolveTypeSERVICE + case api.Branch: + resolveType = api.ResolveTypeBRANCH + case api.ReadReplicaSet: + resolveType = api.ResolveTypeREADREPLICASET + default: + panic(fmt.Sprintf("resolveResult: unsupported type %T", v)) + } + + var body map[string]any + if err := json.Unmarshal(util.Must(json.Marshal(v)), &body); err != nil { + panic(err) + } + body["type"] = resolveType + + var result api.ResolveResult + if err := result.UnmarshalJSON(util.Must(json.Marshal(body))); err != nil { + panic(err) + } + return &result +} + +// branchType is the resolve type every branch tool restricts its lookup to. +var branchType = new(api.ResolveTypeBRANCH) + +// expectResolveBranch expects resolving branch's ID as a branch, which every +// branch tool but branch_create and branch_list does first. +func expectResolveBranch(m *mocks.MockClientWithResponsesInterface, branch api.Branch) { + expectResolve(m, branch.ID, branchType, branch) +} + +// expectGetBranch expects one GetBranch call for branch, through its parent, +// returning branch. The call is returned so a caller can adjust its +// cardinality (a wait loop's poll). +func expectGetBranch(m *mocks.MockClientWithResponsesInterface, branch api.Branch) *gomock.Call { + return m.EXPECT().GetBranchWithResponse(validCtx, testProjectID, branch.ServiceID, branch.ID). + Return(&api.GetBranchResponse{ + HTTPResponse: httpResponse(http.StatusOK), + JSON200: &branch, + }, nil) +} + +// checkStoredPassword returns a check asserting the password stored in the +// mock keyring for the given service or branch ID. +func checkStoredPassword(id, want string) checkFunc { + return func(t *testing.T, result toolResult) { + t.Helper() + got, err := (&common.KeyringStorage{}).Get(common.PasswordTarget{ID: id, ProjectID: testProjectID}, "tsdbadmin") + if err != nil { + t.Fatalf("failed to read stored password: %v", err) + } + if got != want { + t.Errorf("stored password = %q, want %q", got, want) + } + } +} + // validCtx is a gomock matcher that verifies a context.Context parameter is // non-nil. Use this instead of gomock.Any() for context parameters. var validCtx = gomock.Cond(func(x any) bool { diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 532fb77c..9e3abbd1 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -44,6 +44,15 @@ const ( toolServiceBackupRegionList = "service_backup_region_list" toolServiceBackupRegionAdd = "service_backup_region_add" toolServiceBackupRegionRemove = "service_backup_region_remove" + toolBranchCreate = "branch_create" + toolBranchList = "branch_list" + toolBranchGet = "branch_get" + toolBranchDelete = "branch_delete" + toolBranchStart = "branch_start" + toolBranchStop = "branch_stop" + toolBranchResize = "branch_resize" + toolBranchRename = "branch_rename" + toolBranchUpdatePassword = "branch_update_password" toolDBQuery = "db_query" toolDBSchema = "db_schema" toolFeedback = "feedback" @@ -75,6 +84,13 @@ var readOnlyGatedTools = []string{ toolServiceBackupRegionAdd, toolServiceBackupRegionRemove, toolServiceDelete, + toolBranchCreate, + toolBranchDelete, + toolBranchStart, + toolBranchStop, + toolBranchResize, + toolBranchRename, + toolBranchUpdatePassword, } // addTool registers an MCP tool, skipping readOnlyGatedTools under read_only=all. @@ -189,6 +205,9 @@ func (s *Server) registerTools(ctx context.Context, mode config.ReadOnlyMode, ex // Service management tools s.registerServiceTools(mode, experimental) + // Branch management tools + s.registerBranchTools(mode, experimental) + // Database operation tools s.registerDatabaseTools(mode) @@ -229,6 +248,24 @@ func (s *Server) registerServiceTools(mode config.ReadOnlyMode, experimental boo } } +// registerBranchTools registers branch management tools. Branches target +// gateway endpoints marked `x-tigerdata-preview: true`, so the tools are +// registered only when the experimental gate is on at server startup. +func (s *Server) registerBranchTools(mode config.ReadOnlyMode, experimental bool) { + if !experimental { + return + } + addTool(s, mode, newBranchCreateTool(), s.handleBranchCreate) + addTool(s, mode, newBranchListTool(), s.handleBranchList) + addTool(s, mode, newBranchGetTool(), s.handleBranchGet) + addTool(s, mode, newBranchDeleteTool(), s.handleBranchDelete) + addTool(s, mode, newBranchStartTool(), s.handleBranchStart) + addTool(s, mode, newBranchStopTool(), s.handleBranchStop) + addTool(s, mode, newBranchResizeTool(), s.handleBranchResize) + addTool(s, mode, newBranchRenameTool(), s.handleBranchRename) + addTool(s, mode, newBranchUpdatePasswordTool(), s.handleBranchUpdatePassword) +} + // registerDatabaseTools registers database operation tools with comprehensive schemas and descriptions func (s *Server) registerDatabaseTools(mode config.ReadOnlyMode) { addTool(s, mode, newDBQueryTool(), s.handleDBQuery) diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 1095d4bd..eb157cba 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -39,6 +39,7 @@ func registeredToolNames(t *testing.T, readOnly config.ReadOnlyMode) []string { logger: ensureLogger(nil), } s.registerServiceTools(readOnly, true) + s.registerBranchTools(readOnly, true) s.registerDatabaseTools(readOnly) clientTransport, serverTransport := mcp.NewInMemoryTransports() diff --git a/internal/mcp/utils.go b/internal/mcp/utils.go index 0848a48e..bb10a324 100644 --- a/internal/mcp/utils.go +++ b/internal/mcp/utils.go @@ -1,6 +1,7 @@ package mcp import ( + "context" "encoding/json" "fmt" "log/slog" @@ -274,3 +275,127 @@ func (s *Server) convertToServiceDetail(cfg *config.Config, service api.Service, return detail } + +// setBranchIDSchemaProperties sets common branch_id schema properties. Like +// service_id, branch_id is IDs-only (see setServiceIDSchemaProperties), which +// resolveBranchByID enforces. +func setBranchIDSchemaProperties(schema *jsonschema.Schema) { + schema.Properties["branch_id"].Description = "Unique identifier of the branch (10-character alphanumeric string). Use branch_list to find branch IDs." + schema.Properties["branch_id"].Examples = []any{"br4nch1d9x", "k2m9x2sd3a"} + schema.Properties["branch_id"].Pattern = "^[a-z0-9]{10}$" +} + +// setBranchCPUMemorySchemaProperties sets a branch tool's cpu_memory property, +// offering only the combinations a branch can have. +func setBranchCPUMemorySchemaProperties(schema *jsonschema.Schema, description string) { + schema.Properties["cpu_memory"].Description = description + schema.Properties["cpu_memory"].Enum = util.AnySlice(common.FluidCPUMemoryConfigs().Strings()) +} + +// resolveBranchByID looks up the branch with the given ID, through the API's +// resolve endpoint, which also matches names. A match by name is reported as +// not found, keeping the tools IDs-only. The branch carries its parent's ID, +// which every other branch endpoint needs. +func resolveBranchByID(ctx context.Context, client api.ClientWithResponsesInterface, projectID, id string) (*api.Branch, error) { + branch, err := common.ResolveBranch(ctx, client, projectID, id) + if err != nil { + return nil, err + } + if branch.ID != id { + return nil, common.ExitWithCode(common.ExitServiceNotFound, fmt.Errorf("branch '%s' not found", id)) + } + return branch, nil +} + +// setBranchIOPSSchemaProperties sets a branch tool's iops input property. +// defaultDescription, if any, says what happens when iops is omitted. As for +// services, the allowed values aren't listed: they depend on too much to +// describe, and would go stale. The API validates the value. +func setBranchIOPSSchemaProperties(schema *jsonschema.Schema, defaultDescription string) { + property := schema.Properties["iops"] + property.Description = strings.TrimSpace("Provisioned IOPS for the branch's storage. " + + "Check the Tiger Data documentation for the allowed values, which depend on the user's plan and the branch's CPU/memory allocation. " + + defaultDescription) + property.Minimum = new(0.0) + property.Examples = []any{16000, 32000} +} + +// BranchDetail represents detailed branch information +type BranchDetail struct { + BranchID string `json:"id"` + Name string `json:"name"` + ServiceID string `json:"service_id"` + ServiceName string `json:"service_name,omitempty"` + Status string `json:"status"` + Created string `json:"created,omitempty"` + Resources *ResourceInfo `json:"resources,omitempty"` + DirectEndpoint string `json:"direct_endpoint,omitempty"` + ConnectionString string `json:"connection_string,omitempty"` +} + +func (BranchDetail) Schema() *jsonschema.Schema { + schema := util.Must(jsonschema.For[BranchDetail](nil)) + + schema.Properties["id"].Description = "Branch identifier (10-character alphanumeric string)" + schema.Properties["name"].Description = "Branch name, unique within its parent service" + schema.Properties["service_id"].Description = "ID of the parent service the branch was created from" + schema.Properties["service_name"].Description = "Name of the parent service (only returned by branch_get)" + + schema.Properties["status"].Description = "Branch status" + schema.Properties["status"].Examples = []any{"READY", "PAUSED", "QUEUED", "CONFIGURING"} + + // The branch tools only exist behind the experimental gate, so the IOPS + // are always included. + setResourceInfoSchemaProperties(schema.Properties["resources"], true) + + schema.Properties["direct_endpoint"].Description = "Direct database connection endpoint (absent while the branch is still provisioning)" + schema.Properties["connection_string"].Description = "PostgreSQL connection string, without a password" + + return schema +} + +// convertToBranchDetail converts an API Branch to MCP BranchDetail +func (s *Server) convertToBranchDetail(cfg *config.Config, branch api.Branch) BranchDetail { + detail := BranchDetail{ + BranchID: branch.ID, + Name: branch.Name, + ServiceID: branch.ServiceID, + Status: string(branch.Status), + Created: branch.Created.Format("2006-01-02T15:04:05Z"), + Resources: &ResourceInfo{ + CPU: common.FormatCPUCores(branch.CPUMillis) + " cores", + Memory: fmt.Sprintf("%d GB", branch.MemoryGbs), + IOPS: util.Deref(branch.Iops), + }, + } + + // A branch that's still provisioning may have no endpoint yet. + if branch.Endpoint == nil { + return detail + } + if branch.Endpoint.Host != nil && branch.Endpoint.Port != nil { + detail.DirectEndpoint = fmt.Sprintf("%s:%d", *branch.Endpoint.Host, *branch.Endpoint.Port) + } + if details, err := common.GetConnectionDetails(cfg, common.NewBranchConnectionTarget(branch), common.ConnectionDetailsOptions{ + Role: "tsdbadmin", + ReadOnly: common.CheckReadOnly(cfg, common.BranchEnvironmentTag) != nil, + }); err != nil { + s.logger.Error("MCP: Failed to build connection string", slog.Any("error", err)) + } else { + detail.ConnectionString = details.String() + } + + return detail +} + +// parseBranchCPUMemory returns the branch configuration a cpu_memory value +// names. The schema's enum already rejects anything else, so an error here +// means the two have drifted apart. +func parseBranchCPUMemory(cpuMemory string) (common.CPUMemoryConfig, error) { + for _, c := range common.FluidCPUMemoryConfigs() { + if c.String() == cpuMemory { + return c, nil + } + } + return common.CPUMemoryConfig{}, fmt.Errorf("invalid CPU/Memory specification '%s'. Valid options: %s", cpuMemory, common.FluidCPUMemoryConfigs()) +}