diff --git a/pkgs/defang/cli.nix b/pkgs/defang/cli.nix index 3bcc54b9f..c2e1fef8a 100644 --- a/pkgs/defang/cli.nix +++ b/pkgs/defang/cli.nix @@ -7,7 +7,7 @@ buildGoModule { pname = "defang-cli"; version = "git"; src = ../../src; - vendorHash = "sha256-0M0jtBaPE1jSx4nrOR4XMw1Im1tMYTKYCkcKiZ1bj8M="; # TODO: use fetchFromGitHub + vendorHash = "sha256-8caEfevVUKXsYA5YyVDuPZTqMnYXIZnC4kTTGfPmuqA="; # TODO: use fetchFromGitHub subPackages = [ "cmd/cli" ]; diff --git a/src/cmd/cli/command/mcp.go b/src/cmd/cli/command/mcp.go index f63cf8305..4e2150501 100644 --- a/src/cmd/cli/command/mcp.go +++ b/src/cmd/cli/command/mcp.go @@ -8,6 +8,7 @@ import ( cliClient "github.com/DefangLabs/defang/src/pkg/cli/client" "github.com/DefangLabs/defang/src/pkg/login" "github.com/DefangLabs/defang/src/pkg/mcp" + "github.com/DefangLabs/defang/src/pkg/mcp/prompts" "github.com/DefangLabs/defang/src/pkg/mcp/resources" "github.com/DefangLabs/defang/src/pkg/mcp/tools" "github.com/DefangLabs/defang/src/pkg/term" @@ -70,13 +71,19 @@ set_config - This tool sets or updates configuration variables for a deployed ap `), ) + cluster := getCluster() + // Setup resources term.Debug("Setting up resources") resources.SetupResources(s) + //setup prompts + term.Debug("Setting up prompts") + prompts.SetupPrompts(s, cluster, &providerID) + // Setup tools term.Debug("Setting up tools") - tools.SetupTools(s, getCluster(), authPort, providerID) + tools.SetupTools(s, cluster, authPort, &providerID) // Start auth server for docker login flow if authPort != 0 { @@ -84,7 +91,7 @@ set_config - This tool sets or updates configuration variables for a deployed ap term.Debug("Function invoked: cli.InteractiveLoginInsideDocker") go func() { - if err := login.InteractiveLoginInsideDocker(cmd.Context(), getCluster(), authPort); err != nil { + if err := login.InteractiveLoginInsideDocker(cmd.Context(), cluster, authPort); err != nil { term.Error("Failed to start auth server", "error", err) } }() diff --git a/src/go.mod b/src/go.mod index fcfe0df3b..07f92070b 100644 --- a/src/go.mod +++ b/src/go.mod @@ -42,7 +42,7 @@ require ( github.com/hashicorp/go-retryablehttp v0.7.7 github.com/hexops/gotextdiff v1.0.3 github.com/joho/godotenv v1.5.1 - github.com/mark3labs/mcp-go v0.29.0 + github.com/mark3labs/mcp-go v0.38.0 github.com/miekg/dns v1.1.59 github.com/moby/patternmatcher v0.6.0 github.com/muesli/termenv v0.15.2 @@ -77,6 +77,8 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.50.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.50.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect @@ -93,7 +95,9 @@ require ( github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf // indirect + github.com/invopop/jsonschema v0.13.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-runewidth v0.0.14 // indirect github.com/morikuni/aec v1.0.0 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect @@ -105,6 +109,7 @@ require ( github.com/spf13/cast v1.7.1 // indirect github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect github.com/stretchr/objx v0.5.2 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/xhit/go-str2duration/v2 v2.1.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/zeebo/errs v1.4.0 // indirect diff --git a/src/go.sum b/src/go.sum index 82f881015..8dec5999c 100644 --- a/src/go.sum +++ b/src/go.sum @@ -108,8 +108,12 @@ github.com/awslabs/goformation/v7 v7.13.1 h1:QlPn8qwNCqYhrb4GW8kLjT4j1J49n5Qh/an github.com/awslabs/goformation/v7 v7.13.1/go.mod h1:FTCFMNesubEX0LAd6kIR+YkDD1U+5UaMbXtgPUgsck0= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/bufbuild/connect-go v1.10.0 h1:QAJ3G9A1OYQW2Jbk3DeoJbkCxuKArrvZgDt47mjdTbg= github.com/bufbuild/connect-go v1.10.0/go.mod h1:CAIePUgkDR5pAFaylSMtNK45ANQjp9JvpluG20rhpV8= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -213,10 +217,13 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf h1:FtEj8sfIcaaBfAKrE1Cwb61YDtYq9JxChK1c7AKce7s= github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf/go.mod h1:yrqSXGoD/4EKfF26AOGzscPOgTTJcyAwM2rpixWT+t4= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= @@ -232,8 +239,10 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mark3labs/mcp-go v0.29.0 h1:sH1NBcumKskhxqYzhXfGc201D7P76TVXiT0fGVhabeI= -github.com/mark3labs/mcp-go v0.29.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mark3labs/mcp-go v0.38.0 h1:E5tmJiIXkhwlV0pLAwAT0O5ZjUZSISE/2Jxg+6vpq4I= +github.com/mark3labs/mcp-go v0.38.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= @@ -304,6 +313,8 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= diff --git a/src/pkg/mcp/prompts/awsBYOC.go b/src/pkg/mcp/prompts/awsBYOC.go new file mode 100644 index 000000000..35758bd4d --- /dev/null +++ b/src/pkg/mcp/prompts/awsBYOC.go @@ -0,0 +1,135 @@ +package prompts + +import ( + "context" + "errors" + "os" + + "github.com/DefangLabs/defang/src/pkg/cli" + "github.com/DefangLabs/defang/src/pkg/cli/client" + "github.com/DefangLabs/defang/src/pkg/mcp/tools" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +func setupAWSBYOPrompt(s *server.MCPServer, cluster string, providerId *client.ProviderID) { + awsBYOCPrompt := mcp.NewPrompt("AWS Setup", + mcp.WithPromptDescription("Setup for AWS"), + + mcp.WithArgument("AWS_Credential", + mcp.ArgumentDescription("Your AWS Access Key ID or AWS Profile Name"), + mcp.RequiredArgument(), + ), + + mcp.WithArgument("AWS_SECRET_ACCESS_KEY", + mcp.ArgumentDescription("Your AWS Secret Access Key"), + ), + + mcp.WithArgument("AWS_REGION", + mcp.ArgumentDescription("Your AWS Region"), + ), + ) + + s.AddPrompt(awsBYOCPrompt, func(ctx context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + // Can never be nil or empty due to RequiredArgument + awsID := req.Params.Arguments["AWS_Credential"] + if isValidAWSKey(awsID) { + err := os.Setenv("AWS_ACCESS_KEY_ID", awsID) + if err != nil { + return nil, err + } + + awsSecret := getStringArg(req.Params.Arguments, "AWS_SECRET_ACCESS_KEY", "") + region := getStringArg(req.Params.Arguments, "AWS_REGION", "") + + if awsSecret == "" { + return nil, errors.New("AWS_SECRET_ACCESS_KEY is required") + } + + err = os.Setenv("AWS_SECRET_ACCESS_KEY", awsSecret) + if err != nil { + return nil, err + } + + if region == "" { + return nil, errors.New("AWS_REGION is required") + } + + err = os.Setenv("AWS_REGION", region) + if err != nil { + return nil, err + } + } else { + err := os.Setenv("AWS_PROFILE", awsID) + if err != nil { + return nil, err + } + + region := getStringArg(req.Params.Arguments, "AWS_REGION", "") + err = os.Setenv("AWS_REGION", region) + if err != nil { + return nil, err + } + } + + fabric, err := cli.Connect(ctx, cluster) + if err != nil { + return nil, err + } + + _, err = tools.CheckProviderConfigured(ctx, fabric, client.ProviderAWS, "", 0) + if err != nil { + return nil, err + } + + *providerId = client.ProviderAWS + + //FIXME: Should not be setting both the global and env var + err = os.Setenv("DEFANG_PROVIDER", "aws") + if err != nil { + return nil, err + } + + return &mcp.GetPromptResult{ + Description: "AWS BYOC Setup Complete", + Messages: []mcp.PromptMessage{ + { + Role: mcp.RoleUser, + Content: mcp.NewTextContent(postPrompt), + }, + }, + }, nil + }) +} + +// Check if the provided AWS access key ID is valid +// https://medium.com/@TalBeerySec/a-short-note-on-aws-key-id-f88cc4317489 +func isValidAWSKey(key string) bool { + // Define accepted AWS access key prefixes + acceptedPrefixes := map[string]bool{ + "ABIA": true, + "ACCA": true, + "AGPA": true, + "AIDA": true, + "AKPA": true, + "AKIA": true, + "ANPA": true, + "ANVA": true, + "APKA": true, + "AROA": true, + "ASCA": true, + "ASIA": true, + } + + if len(key) < 16 { + return false + } + + prefix := key[:4] + _, ok := acceptedPrefixes[prefix] + if !ok { + return false + } + + return true +} diff --git a/src/pkg/mcp/prompts/common.go b/src/pkg/mcp/prompts/common.go new file mode 100644 index 000000000..2f1cc6bcb --- /dev/null +++ b/src/pkg/mcp/prompts/common.go @@ -0,0 +1,10 @@ +package prompts + +const postPrompt = "Please deploy my application with Defang now." + +func getStringArg(args map[string]string, key, defaultValue string) string { + if val, exists := args[key]; exists { + return val + } + return defaultValue +} diff --git a/src/pkg/mcp/prompts/gcpBYOC.go b/src/pkg/mcp/prompts/gcpBYOC.go new file mode 100644 index 000000000..e18b8683a --- /dev/null +++ b/src/pkg/mcp/prompts/gcpBYOC.go @@ -0,0 +1,61 @@ +package prompts + +import ( + "context" + "os" + + "github.com/DefangLabs/defang/src/pkg/cli" + "github.com/DefangLabs/defang/src/pkg/cli/client" + "github.com/DefangLabs/defang/src/pkg/mcp/tools" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +func setupGCPBYOPrompt(s *server.MCPServer, cluster string, providerId *client.ProviderID) { + gcpBYOPrompt := mcp.NewPrompt("GCP Setup", + mcp.WithPromptDescription("Setup for GCP"), + + mcp.WithArgument("GCP_PROJECT_ID", + mcp.ArgumentDescription("Your GCP Project ID"), + mcp.RequiredArgument(), + ), + ) + + s.AddPrompt(gcpBYOPrompt, func(ctx context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + // Can never be nil or empty due to RequiredArgument + projectID := req.Params.Arguments["GCP_PROJECT_ID"] + + err := os.Setenv("GCP_PROJECT_ID", projectID) + if err != nil { + return nil, err + } + + fabric, err := cli.Connect(ctx, cluster) + if err != nil { + return nil, err + } + + _, err = tools.CheckProviderConfigured(ctx, fabric, client.ProviderGCP, "", 0) + if err != nil { + return nil, err + } + + *providerId = client.ProviderGCP + + //FIXME: Should not be setting both the global var and env var + err = os.Setenv("DEFANG_PROVIDER", "gcp") + if err != nil { + return nil, err + } + + return &mcp.GetPromptResult{ + Description: "GCP BYOC Setup Complete", + Messages: []mcp.PromptMessage{ + { + Role: mcp.RoleUser, + Content: mcp.NewTextContent(postPrompt), + }, + }, + }, nil + }) +} diff --git a/src/pkg/mcp/prompts/playgroundSetup.go b/src/pkg/mcp/prompts/playgroundSetup.go new file mode 100644 index 000000000..2e9e2e313 --- /dev/null +++ b/src/pkg/mcp/prompts/playgroundSetup.go @@ -0,0 +1,36 @@ +package prompts + +import ( + "context" + "os" + + "github.com/DefangLabs/defang/src/pkg/cli/client" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +func setupPlaygroundPrompt(s *server.MCPServer, providerId *client.ProviderID) { + playgroundPrompt := mcp.NewPrompt("Playground Setup", + mcp.WithPromptDescription("Setup for Playground"), + ) + + s.AddPrompt(playgroundPrompt, func(ctx context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + *providerId = client.ProviderDefang + + //FIXME: Should not be setting both the global and env var + err := os.Setenv("DEFANG_PROVIDER", "defang") + if err != nil { + return nil, err + } + + return &mcp.GetPromptResult{ + Description: "Defang playground Setup Complete", + Messages: []mcp.PromptMessage{ + { + Role: mcp.RoleUser, + Content: mcp.NewTextContent(postPrompt), + }, + }, + }, nil + }) +} diff --git a/src/pkg/mcp/prompts/prompts.go b/src/pkg/mcp/prompts/prompts.go new file mode 100644 index 000000000..8ca7cbb3a --- /dev/null +++ b/src/pkg/mcp/prompts/prompts.go @@ -0,0 +1,18 @@ +package prompts + +import ( + "github.com/DefangLabs/defang/src/pkg/cli/client" + "github.com/mark3labs/mcp-go/server" +) + +// SetupPrompts configures and adds all prompts to the MCP server +func SetupPrompts(s *server.MCPServer, cluster string, providerId *client.ProviderID) { + //AWS BYOC + setupAWSBYOPrompt(s, cluster, providerId) + + //GCP BYOC + setupGCPBYOPrompt(s, cluster, providerId) + + //Playground + setupPlaygroundPrompt(s, providerId) +} diff --git a/src/pkg/mcp/resources/resources.go b/src/pkg/mcp/resources/resources.go index 348acac77..75b5a762d 100644 --- a/src/pkg/mcp/resources/resources.go +++ b/src/pkg/mcp/resources/resources.go @@ -19,9 +19,6 @@ func SetupResources(s *server.MCPServer) { // Create and add samples examples resource setupSamplesResource(s) - - // Create and add sample prompt - setupSamplePrompt(s) } var knowledgeBasePath = filepath.Join(client.StateDir, "knowledge_base.json") @@ -85,39 +82,3 @@ func setupSamplesResource(s *server.MCPServer) { }, nil }) } - -// setupSamplePrompt configures and adds the sample prompt to the MCP server -func setupSamplePrompt(s *server.MCPServer) { - samplePrompt := mcp.NewPrompt("Make Dockerfile and compose file", - mcp.WithPromptDescription("The user should give you a path to a project directory, and you should create a Dockerfile and compose file for that project. If there is an app folder, make the Dockerfile for that folder. Then make a compose file for original project directory or root of that project directory."), - mcp.WithArgument("project_path", - mcp.ArgumentDescription("Path to the project directory"), - mcp.RequiredArgument(), - ), - ) - - s.AddPrompt(samplePrompt, func(ctx context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { - projectPath, ok := request.Params.Arguments["project_path"] - if !ok || projectPath == "" { - projectPath = "." - term.Warn("Project path not provided, using current directory", "dir", projectPath) - } - - return mcp.NewGetPromptResult( - "Code assistance to make Dockerfile and compose file", - []mcp.PromptMessage{ - mcp.NewPromptMessage( - mcp.RoleUser, - mcp.NewTextContent(fmt.Sprintf("You are a helpful code writer. I will give you a path which is %s to a project directory, and you should create a Dockerfile and compose file for that project. If there is an app folder, make the Dockerfile for that folder. Then make a compose file for original project directory or root of that project directory. When creating these files, make sure to use the samples and examples resource for reference of defang. If you need more information, please use the defang documentation resource. When you are creating these files please make sure to scan carefully to expose any ports, start commands, and any other information needed for the project.", projectPath)), - ), - mcp.NewPromptMessage( - mcp.RoleAssistant, - mcp.NewEmbeddedResource(mcp.TextResourceContents{ - MIMEType: "application/json", - URI: "doc:///knowledge_base/knowledge_base.json", - }), - ), - }, - ), nil - }) -} diff --git a/src/pkg/mcp/tools/common.go b/src/pkg/mcp/tools/common.go index 30a8392e2..5079d064f 100644 --- a/src/pkg/mcp/tools/common.go +++ b/src/pkg/mcp/tools/common.go @@ -1,13 +1,16 @@ package tools import ( - "crypto/rand" - "encoding/base64" - "regexp" + "context" + "errors" "strings" + "github.com/DefangLabs/defang/src/pkg/cli" + "github.com/DefangLabs/defang/src/pkg/cli/client" + cliClient "github.com/DefangLabs/defang/src/pkg/cli/client" "github.com/DefangLabs/defang/src/pkg/cli/compose" "github.com/DefangLabs/defang/src/pkg/term" + defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" "github.com/bufbuild/connect-go" "github.com/mark3labs/mcp-go/mcp" ) @@ -61,12 +64,48 @@ func HandleConfigError(err error) *mcp.CallToolResult { return nil } -func CreateRandomConfigValue() string { - // Note that no error handling is necessary, as Read always succeeds. - key := make([]byte, 32) - rand.Read(key) - str := base64.StdEncoding.EncodeToString(key) - re := regexp.MustCompile("[+/=]") - str = re.ReplaceAllString(str, "") - return str +func CanIUseProvider(ctx context.Context, grpcClient client.FabricClient, providerId client.ProviderID, projectName string, provider client.Provider, serviceCount int) error { + canUseReq := defangv1.CanIUseRequest{ + Project: projectName, + Provider: providerId.Value(), + ServiceCount: int32(serviceCount), // #nosec G115 - service count will not overflow int32 + } + term.Debug("Function invoked: client.CanIUse") + resp, err := grpcClient.CanIUse(ctx, &canUseReq) + if err != nil { + return err + } + + term.Debug("Function invoked: provider.SetCanIUseConfig") + provider.SetCanIUseConfig(resp) + return nil +} + +func providerNotConfiguredError(providerId client.ProviderID) error { + if providerId == client.ProviderAuto { + term.Error("No provider configured") + return errors.New("No provider configured, please use the appropriate prompts and type /mcp.defang.AWS_Setup for AWS, /mcp.defang.GCP_Setup for GCP, or /mcp.defang.Playground_Setup for Playground in the chat.") + } + return nil +} + +func CheckProviderConfigured(ctx context.Context, client *cliClient.GrpcClient, providerId cliClient.ProviderID, projectName string, serviceCount int) (cliClient.Provider, error) { + provider, err := cli.NewProvider(ctx, providerId, client) + if err != nil { + term.Error("Failed to get new provider", "error", err) + return nil, err + } + + _, err = provider.AccountInfo(ctx) + if err != nil { + return nil, err + } + + err = CanIUseProvider(ctx, client, providerId, projectName, provider, serviceCount) + if err != nil { + term.Error("Failed to use provider", "error", err) + return nil, err + } + + return provider, nil } diff --git a/src/pkg/mcp/tools/deploy.go b/src/pkg/mcp/tools/deploy.go index 6966de6f4..eec112f26 100644 --- a/src/pkg/mcp/tools/deploy.go +++ b/src/pkg/mcp/tools/deploy.go @@ -19,7 +19,7 @@ import ( ) // setupDeployTool configures and adds the deployment tool to the MCP server -func setupDeployTool(s *server.MCPServer, cluster string, providerId cliClient.ProviderID) { +func setupDeployTool(s *server.MCPServer, cluster string, providerId *cliClient.ProviderID) { term.Debug("Creating deployment tool") composeUpTool := mcp.NewTool("deploy", mcp.WithDescription("Deploy services using defang"), @@ -33,6 +33,11 @@ func setupDeployTool(s *server.MCPServer, cluster string, providerId cliClient.P // Add the deployment tool handler - make it non-blocking term.Debug("Adding deployment tool handler") s.AddTool(composeUpTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + err := providerNotConfiguredError(*providerId) + if err != nil { + return mcp.NewToolResultErrorFromErr("No provider configured", err), err + } + // Get compose path term.Debug("Compose up tool called - deploying services") track.Evt("MCP Deploy Tool") @@ -69,16 +74,10 @@ func setupDeployTool(s *server.MCPServer, cluster string, providerId cliClient.P client.Track("MCP Deploy Tool") term.Debug("Function invoked: cli.NewProvider") - provider, err := cli.NewProvider(ctx, providerId, client) - if err != nil { - term.Error("Failed to get new provider", "error", err) - return mcp.NewToolResultErrorFromErr("Failed to get new provider", err), err - } - err = canIUseProvider(ctx, client, project.Name, provider, len(project.Services)) + provider, err := CheckProviderConfigured(ctx, client, *providerId, project.Name, len(project.Services)) if err != nil { - term.Error("Failed to use provider", "error", err) - return mcp.NewToolResultErrorFromErr("Failed to use provider", err), err + return mcp.NewToolResultErrorFromErr("Provider not configured correctly", err), err } // Deploy the services @@ -116,7 +115,7 @@ func setupDeployTool(s *server.MCPServer, cluster string, providerId cliClient.P term.Debugf("Deployment ID: %s", deployResp.Etag) var portal string - if providerId == cliClient.ProviderDefang { + if *providerId == cliClient.ProviderDefang { // Get the portal URL for browser preview portalURL := "https://portal.defang.io/" diff --git a/src/pkg/mcp/tools/destroy.go b/src/pkg/mcp/tools/destroy.go index fe60c2de9..debce9935 100644 --- a/src/pkg/mcp/tools/destroy.go +++ b/src/pkg/mcp/tools/destroy.go @@ -10,14 +10,13 @@ import ( cliClient "github.com/DefangLabs/defang/src/pkg/cli/client" "github.com/DefangLabs/defang/src/pkg/term" "github.com/DefangLabs/defang/src/pkg/track" - defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" "github.com/bufbuild/connect-go" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" ) // setupDestroyTool configures and adds the destroy tool to the MCP server -func setupDestroyTool(s *server.MCPServer, cluster string, providerId cliClient.ProviderID) { +func setupDestroyTool(s *server.MCPServer, cluster string, providerId *cliClient.ProviderID) { term.Debug("Creating destroy tool") composeDownTool := mcp.NewTool("destroy", mcp.WithDescription("Remove services using defang."), @@ -34,6 +33,11 @@ func setupDestroyTool(s *server.MCPServer, cluster string, providerId cliClient. term.Debug("Compose down tool called - removing services") track.Evt("MCP Destroy Tool") + err := providerNotConfiguredError(*providerId) + if err != nil { + return mcp.NewToolResultErrorFromErr("No provider configured", err), err + } + term.Debug("Function invoked: cli.Connect") client, err := cli.Connect(ctx, cluster) if err != nil { @@ -43,7 +47,7 @@ func setupDestroyTool(s *server.MCPServer, cluster string, providerId cliClient. client.Track("MCP Destroy Tool") term.Debug("Function invoked: cli.NewProvider") - provider, err := cli.NewProvider(ctx, providerId, client) + provider, err := cli.NewProvider(ctx, *providerId, client) if err != nil { term.Error("Failed to get new provider", "error", err) return mcp.NewToolResultErrorFromErr("Failed to get new provider", err), err @@ -70,7 +74,7 @@ func setupDestroyTool(s *server.MCPServer, cluster string, providerId cliClient. return mcp.NewToolResultErrorFromErr("Failed to load project name", err), err } - err = canIUseProvider(ctx, client, projectName, provider, 0) + err = CanIUseProvider(ctx, client, *providerId, projectName, provider, 0) if err != nil { term.Error("Failed to use provider", "error", err) return mcp.NewToolResultErrorFromErr("Failed to use provider", err), err @@ -90,31 +94,9 @@ func setupDestroyTool(s *server.MCPServer, cluster string, providerId cliClient. return result, err } - return mcp.NewToolResultErrorFromErr("Failed to destroy project", err), err + return mcp.NewToolResultErrorFromErr("Failed to send destroy request", err), err } - return mcp.NewToolResultText(fmt.Sprintf("Successfully destroyed project: %s, etag: %s", projectName, deployment)), nil + return mcp.NewToolResultText(fmt.Sprintf("The project is in the process of being destroyed: %s, please tail this deployment ID: %s for status updates.", projectName, deployment)), nil }) } - -func canIUseProvider(ctx context.Context, grpcClient cliClient.FabricClient, projectName string, provider cliClient.Provider, serviceCount int) error { - info, err := provider.AccountInfo(ctx) - if err != nil { - return err - } - - canUseReq := defangv1.CanIUseRequest{ - Project: projectName, - Provider: info.Provider.Value(), - ServiceCount: int32(serviceCount), // #nosec G115 - service count will not overflow int32 - } - term.Debug("Function invoked: client.CanIUse") - resp, err := grpcClient.CanIUse(ctx, &canUseReq) - if err != nil { - return err - } - - term.Debug("Function invoked: provider.SetCanIUseConfig") - provider.SetCanIUseConfig(resp) - return nil -} diff --git a/src/pkg/mcp/tools/estimate.go b/src/pkg/mcp/tools/estimate.go index 7a887faec..9d448e2c0 100644 --- a/src/pkg/mcp/tools/estimate.go +++ b/src/pkg/mcp/tools/estimate.go @@ -18,11 +18,7 @@ import ( ) // setupEstimateTool configures and adds the estimate tool to the MCP server -func setupEstimateTool(s *server.MCPServer, cluster string, providerId cliClient.ProviderID) { - if providerId == cliClient.ProviderDefang { - providerId = cliClient.ProviderAWS // Default to AWS - } - +func setupEstimateTool(s *server.MCPServer, cluster string, providerId *cliClient.ProviderID) { term.Debug("Creating estimate tool") estimateTool := mcp.NewTool("estimate", mcp.WithDescription("Estimate the cost of a Defang project deployed to AWS"), @@ -51,6 +47,12 @@ func setupEstimateTool(s *server.MCPServer, cluster string, providerId cliClient term.Debug("Estimate tool called") track.Evt("MCP Estimate Tool") + if *providerId == cliClient.ProviderDefang || *providerId == cliClient.ProviderAuto { + // We only support estimates for AWS and GCP, not playground; suggest use setup prompt for another provider + err := errors.New("estimates are only supported for AWS and GCP; please configure another provider using the appropriate prompts and type /mcp.defang.AWS_Setup for AWS or /mcp.defang.GCP_Setup for GCP") + return mcp.NewToolResultErrorFromErr("No compatible provider configured", err), err + } + wd, err := request.RequireString("working_directory") if err != nil || wd == "" { term.Error("Invalid working directory", "error", errors.New("working_directory is required")) diff --git a/src/pkg/mcp/tools/listConfig.go b/src/pkg/mcp/tools/listConfig.go index 04ae673f2..b9857adeb 100644 --- a/src/pkg/mcp/tools/listConfig.go +++ b/src/pkg/mcp/tools/listConfig.go @@ -17,7 +17,7 @@ import ( ) // setupSetConfigTool configures and adds the estimate tool to the MCP server -func setupListConfigTool(s *server.MCPServer, cluster string, providerId cliClient.ProviderID) { +func setupListConfigTool(s *server.MCPServer, cluster string, providerId *cliClient.ProviderID) { term.Debug("Creating list config tool") listConfigTool := mcp.NewTool("list_configs", mcp.WithDescription("List all config variables for the defang project"), @@ -34,6 +34,11 @@ func setupListConfigTool(s *server.MCPServer, cluster string, providerId cliClie term.Debug("List Config tool called") track.Evt("MCP List Config Tool") + err := providerNotConfiguredError(*providerId) + if err != nil { + return mcp.NewToolResultErrorFromErr("No provider configured", err), err + } + wd, err := request.RequireString("working_directory") if err != nil || wd == "" { term.Error("Invalid working directory", "error", errors.New("working_directory is required")) @@ -53,7 +58,7 @@ func setupListConfigTool(s *server.MCPServer, cluster string, providerId cliClie } term.Debug("Function invoked: cli.NewProvider") - provider, err := cli.NewProvider(ctx, providerId, client) + provider, err := cli.NewProvider(ctx, *providerId, client) if err != nil { term.Error("Failed to get new provider", "error", err) diff --git a/src/pkg/mcp/tools/removeConfig.go b/src/pkg/mcp/tools/removeConfig.go index b55e106b2..87c2b04a7 100644 --- a/src/pkg/mcp/tools/removeConfig.go +++ b/src/pkg/mcp/tools/removeConfig.go @@ -16,7 +16,7 @@ import ( ) // setupRemoveConfigTool configures and adds the estimate tool to the MCP server -func setupRemoveConfigTool(s *server.MCPServer, cluster string, providerId cliClient.ProviderID) { +func setupRemoveConfigTool(s *server.MCPServer, cluster string, providerId *cliClient.ProviderID) { term.Debug("Creating remove config tool") removeConfigTool := mcp.NewTool("remove_config", mcp.WithDescription("Remove a config variable for the defang project"), @@ -37,6 +37,11 @@ func setupRemoveConfigTool(s *server.MCPServer, cluster string, providerId cliCl term.Debug("Remove Config tool called") track.Evt("MCP Remove Config Tool") + err := providerNotConfiguredError(*providerId) + if err != nil { + return mcp.NewToolResultErrorFromErr("No provider configured", err), err + } + wd, err := request.RequireString("working_directory") if err != nil || wd == "" { term.Error("Invalid working directory", "error", errors.New("working_directory is required")) @@ -62,7 +67,7 @@ func setupRemoveConfigTool(s *server.MCPServer, cluster string, providerId cliCl } term.Debug("Function invoked: cli.NewProvider") - provider, err := cli.NewProvider(ctx, providerId, client) + provider, err := cli.NewProvider(ctx, *providerId, client) if err != nil { term.Error("Failed to get new provider", "error", err) diff --git a/src/pkg/mcp/tools/services.go b/src/pkg/mcp/tools/services.go index b476e9537..f7fe685f2 100644 --- a/src/pkg/mcp/tools/services.go +++ b/src/pkg/mcp/tools/services.go @@ -19,7 +19,7 @@ import ( ) // setupServicesTool configures and adds the services tool to the MCP server -func setupServicesTool(s *server.MCPServer, cluster string, providerId cliClient.ProviderID) { +func setupServicesTool(s *server.MCPServer, cluster string, providerId *cliClient.ProviderID) { term.Debug("Creating services tool") servicesTool := mcp.NewTool("services", mcp.WithDescription("List information about services in Defang Playground"), @@ -35,6 +35,11 @@ func setupServicesTool(s *server.MCPServer, cluster string, providerId cliClient term.Debug("Services tool called - fetching services from Defang") track.Evt("MCP Services Tool") + err := providerNotConfiguredError(*providerId) + if err != nil { + return mcp.NewToolResultErrorFromErr("No provider configured", err), err + } + wd, err := request.RequireString("working_directory") if err != nil || wd == "" { term.Error("Invalid working directory", "error", errors.New("working_directory is required")) @@ -57,7 +62,7 @@ func setupServicesTool(s *server.MCPServer, cluster string, providerId cliClient // Create a Defang client term.Debug("Function invoked: cli.NewProvider") - provider, err := cli.NewProvider(ctx, providerId, client) + provider, err := cli.NewProvider(ctx, *providerId, client) if err != nil { term.Error("Failed to create provider", "error", err) return mcp.NewToolResultErrorFromErr("Failed to create provider", err), err diff --git a/src/pkg/mcp/tools/setConfig.go b/src/pkg/mcp/tools/setConfig.go index d58319bc5..ef1a7e1f5 100644 --- a/src/pkg/mcp/tools/setConfig.go +++ b/src/pkg/mcp/tools/setConfig.go @@ -16,7 +16,7 @@ import ( ) // setupSetConfigTool configures and adds the estimate tool to the MCP server -func setupSetConfigTool(s *server.MCPServer, cluster string, providerId cliClient.ProviderID) { +func setupSetConfigTool(s *server.MCPServer, cluster string, providerId *cliClient.ProviderID) { term.Debug("Creating set config tool") setConfigTool := mcp.NewTool("set_config", mcp.WithDescription("Set a config variable for the defang project"), @@ -42,6 +42,11 @@ func setupSetConfigTool(s *server.MCPServer, cluster string, providerId cliClien term.Debug("Set Config tool called") track.Evt("MCP Set Config Tool") + err := providerNotConfiguredError(*providerId) + if err != nil { + return mcp.NewToolResultErrorFromErr("No provider configured", err), err + } + wd, err := request.RequireString("working_directory") if err != nil || wd == "" { term.Error("Invalid working directory", "error", errors.New("working_directory is required")) @@ -73,7 +78,7 @@ func setupSetConfigTool(s *server.MCPServer, cluster string, providerId cliClien } term.Debug("Function invoked: cli.NewProvider") - provider, err := cli.NewProvider(ctx, providerId, client) + provider, err := cli.NewProvider(ctx, *providerId, client) if err != nil { term.Error("Failed to get new provider", "error", err) diff --git a/src/pkg/mcp/tools/tools.go b/src/pkg/mcp/tools/tools.go index 04a27fb63..2459b27e3 100644 --- a/src/pkg/mcp/tools/tools.go +++ b/src/pkg/mcp/tools/tools.go @@ -7,11 +7,7 @@ import ( ) // SetupTools configures and adds all the MCP tools to the server -func SetupTools(s *server.MCPServer, cluster string, authPort int, providerId client.ProviderID) { - if providerId == "" || providerId == client.ProviderAuto { - providerId = client.ProviderDefang // Default to Defang Playground if not specified - } - +func SetupTools(s *server.MCPServer, cluster string, authPort int, providerId *client.ProviderID) { // Create a tool for logging in and getting a new token term.Debug("Setting up login tool") setupLoginTool(s, cluster, authPort)