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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 12 additions & 3 deletions internal/cmd/mcp_list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 │
Expand Down Expand Up @@ -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")),
Expand Down
166 changes: 166 additions & 0 deletions internal/mcp/branch_create.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading