From 0f0c0fc3c2dbe6f3b4c120df7807ce6490af5276 Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Fri, 26 Sep 2025 11:09:01 -0700 Subject: [PATCH 1/2] refactor tool setup to colocate parameter descriptions and to derive system instructions from tool list --- src/pkg/mcp/tools/tools.go | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/pkg/mcp/tools/tools.go b/src/pkg/mcp/tools/tools.go index ad7a227f3..6965187c7 100644 --- a/src/pkg/mcp/tools/tools.go +++ b/src/pkg/mcp/tools/tools.go @@ -5,14 +5,12 @@ import ( "strings" "github.com/DefangLabs/defang/src/pkg/cli/client" - "github.com/DefangLabs/defang/src/pkg/track" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" ) var workingDirectoryOption = mcp.WithString("working_directory", mcp.Description("Path to project's working directory"), - mcp.Required(), ) var multipleComposeFilesOptions = mcp.WithArray("compose_file_paths", @@ -46,7 +44,6 @@ func CollectTools(cluster string, authPort int, providerId *client.ProviderID) [ Tool: mcp.NewTool("deploy", mcp.WithDescription("Deploy services using defang"), workingDirectoryOption, - multipleComposeFilesOptions, ), Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { cli := &DefaultToolCLI{} @@ -57,11 +54,9 @@ func CollectTools(cluster string, authPort int, providerId *client.ProviderID) [ Tool: mcp.NewTool("destroy", mcp.WithDescription("Destroy deployed services for the project in the current working directory"), workingDirectoryOption, - multipleComposeFilesOptions, ), Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { cli := &DefaultToolCLI{} - track.Evt("MCP Destroy Tool", track.P("provider", *providerId), track.P("cluster", cluster), track.P("client", MCPDevelopmentClient)) return handleDestroyTool(ctx, request, providerId, cluster, cli) }, }, @@ -69,7 +64,6 @@ func CollectTools(cluster string, authPort int, providerId *client.ProviderID) [ Tool: mcp.NewTool("estimate", mcp.WithDescription("Estimate the cost of deployed a Defang project."), workingDirectoryOption, - multipleComposeFilesOptions, mcp.WithString("provider", mcp.Description("The cloud provider to estimate costs for. Supported options are AWS or GCP"), mcp.DefaultString(strings.ToUpper(providerId.String())), @@ -77,9 +71,9 @@ func CollectTools(cluster string, authPort int, providerId *client.ProviderID) [ ), mcp.WithString("deployment_mode", - mcp.Description("The deployment mode for the estimate. Options are AFFORDABLE, BALANCED or HIGH_AVAILABILITY."), + mcp.Description("The deployment mode for the estimate. Options are AFFORDABLE, BALANCED or HIGH AVAILABILITY."), mcp.DefaultString("AFFORDABLE"), - mcp.Enum("AFFORDABLE", "BALANCED", "HIGH_AVAILABILITY"), + mcp.Enum("AFFORDABLE", "BALANCED", "HIGH AVAILABILITY"), ), ), Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { From 85616f7108d42ff535855feb675d54a30236c5b3 Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Fri, 26 Sep 2025 15:37:28 -0700 Subject: [PATCH 2/2] logs tool --- src/pkg/mcp/tests/client_test.go | 1 + src/pkg/mcp/tools/default_tool_cli.go | 4 + src/pkg/mcp/tools/interfaces.go | 11 +++ src/pkg/mcp/tools/tail.go | 115 ++++++++++++++++++++++++++ src/pkg/mcp/tools/tools.go | 22 +++++ 5 files changed, 153 insertions(+) create mode 100644 src/pkg/mcp/tools/tail.go diff --git a/src/pkg/mcp/tests/client_test.go b/src/pkg/mcp/tests/client_test.go index e9f2de471..64dbbdddd 100644 --- a/src/pkg/mcp/tests/client_test.go +++ b/src/pkg/mcp/tests/client_test.go @@ -249,6 +249,7 @@ var expectedToolsList = []string{ "services", "deploy", "destroy", + "logs", "estimate", "set_config", "remove_config", diff --git a/src/pkg/mcp/tools/default_tool_cli.go b/src/pkg/mcp/tools/default_tool_cli.go index 3e606ba15..ebae58857 100644 --- a/src/pkg/mcp/tools/default_tool_cli.go +++ b/src/pkg/mcp/tools/default_tool_cli.go @@ -55,6 +55,10 @@ func (DefaultToolCLI) ComposeUp(ctx context.Context, project *compose.Project, c return cli.ComposeUp(ctx, project, client, provider, uploadMode, mode) } +func (c *DefaultToolCLI) Tail(ctx context.Context, provider cliClient.Provider, project *compose.Project, options cli.TailOptions) error { + return cli.Tail(ctx, provider, project.Name, options) +} + func (DefaultToolCLI) ConfigureLoader(request mcp.CallToolRequest) cliClient.Loader { return configureLoader(request) } diff --git a/src/pkg/mcp/tools/interfaces.go b/src/pkg/mcp/tools/interfaces.go index de2b07457..f5f3255ab 100644 --- a/src/pkg/mcp/tools/interfaces.go +++ b/src/pkg/mcp/tools/interfaces.go @@ -4,6 +4,7 @@ package tools import ( "context" + cliTypes "github.com/DefangLabs/defang/src/pkg/cli" cliClient "github.com/DefangLabs/defang/src/pkg/cli/client" "github.com/DefangLabs/defang/src/pkg/cli/compose" "github.com/DefangLabs/defang/src/pkg/mcp/deployment_info" @@ -40,6 +41,16 @@ type DeployCLIInterface interface { OpenBrowser(url string) error } +type LogsCLIInterface interface { + Connecter + ProviderFactory + LoaderConfigurator + // Unique methods + Tail(ctx context.Context, provider cliClient.Provider, project *compose.Project, options cliTypes.TailOptions) error + CheckProviderConfigured(ctx context.Context, client *cliClient.GrpcClient, providerId cliClient.ProviderID, projectName string, serviceCount int) (cliClient.Provider, error) + LoadProject(ctx context.Context, loader cliClient.Loader) (*compose.Project, error) +} + type DestroyCLIInterface interface { Connecter ProviderFactory diff --git a/src/pkg/mcp/tools/tail.go b/src/pkg/mcp/tools/tail.go new file mode 100644 index 000000000..f98bb9d70 --- /dev/null +++ b/src/pkg/mcp/tools/tail.go @@ -0,0 +1,115 @@ +package tools + +import ( + "context" + "errors" + "fmt" + "os" + "time" + + "github.com/DefangLabs/defang/src/pkg/cli" + cliTypes "github.com/DefangLabs/defang/src/pkg/cli" + cliClient "github.com/DefangLabs/defang/src/pkg/cli/client" + "github.com/DefangLabs/defang/src/pkg/cli/compose" + "github.com/DefangLabs/defang/src/pkg/term" + "github.com/DefangLabs/defang/src/pkg/track" + "github.com/mark3labs/mcp-go/mcp" +) + +// DefaultLogsCLI implements LogsCLIInterface using actual CLI functions +type DefaultLogsCLI struct{} + +func (c *DefaultLogsCLI) Connect(ctx context.Context, cluster string) (*cliClient.GrpcClient, error) { + return cli.Connect(ctx, cluster) +} + +func (c *DefaultLogsCLI) NewProvider(ctx context.Context, providerId cliClient.ProviderID, client *cliClient.GrpcClient) (cliClient.Provider, error) { + return cli.NewProvider(ctx, providerId, client) +} + +func (c *DefaultLogsCLI) Tail(ctx context.Context, provider cliClient.Provider, project *compose.Project, options cliTypes.TailOptions) error { + return cli.Tail(ctx, provider, project.Name, options) +} + +func (c *DefaultLogsCLI) CheckProviderConfigured(ctx context.Context, client *cliClient.GrpcClient, providerId cliClient.ProviderID, projectName string, serviceCount int) (cliClient.Provider, error) { + return CheckProviderConfigured(ctx, client, providerId, projectName, serviceCount) +} + +func (c *DefaultLogsCLI) LoadProject(ctx context.Context, loader cliClient.Loader) (*compose.Project, error) { + return loader.LoadProject(ctx) +} + +func (c *DefaultLogsCLI) ConfigureLoader(request mcp.CallToolRequest) cliClient.Loader { + return configureLoader(request) +} + +func handleLogsTool(ctx context.Context, request mcp.CallToolRequest, cluster string, providerId *cliClient.ProviderID, cli LogsCLIInterface) (*mcp.CallToolResult, error) { + term.Debug("Tail tool called - opening logs in browser") + track.Evt("MCP Tail Tool") + wd, err := request.RequireString("working_directory") + if err != nil || wd == "" { + term.Error("Invalid working directory", "error", errors.New("working_directory is required")) + return mcp.NewToolResultErrorFromErr("Invalid working directory", errors.New("working_directory is required")), err + } + deploymentId, err := request.RequireString("deployment_id") + if err != nil || deploymentId == "" { + term.Error("Invalid deployment ID", "error", errors.New("deployment_id is required")) + return mcp.NewToolResultErrorFromErr("Invalid deployment ID", errors.New("deployment_id is required")), err + } + since := request.GetString("since", "") + until := request.GetString("until", "") + sinceTime, err := time.Parse(time.RFC3339, since) + if err != nil { + term.Error("Invalid parameter 'since', must be in RFC3339 format", "error", err) + return mcp.NewToolResultErrorFromErr("Invalid parameter 'since', must be in RFC3339 format", err), err + } + untilTime, err := time.Parse(time.RFC3339, until) + if err != nil { + term.Error("Invalid parameter 'until', must be in RFC3339 format", "error", err) + return mcp.NewToolResultErrorFromErr("Invalid parameter 'until', must be in RFC3339 format", err), err + } + + err = os.Chdir(wd) + if err != nil { + term.Error("Failed to change working directory", "error", err) + return mcp.NewToolResultErrorFromErr("Failed to change working directory", err), err + } + + loader := cli.ConfigureLoader(request) + + term.Debug("Function invoked: loader.LoadProject") + project, err := cli.LoadProject(ctx, loader) + if err != nil { + err = fmt.Errorf("failed to parse compose file: %w", err) + term.Error("Failed to deploy services", "error", err) + + return mcp.NewToolResultText(fmt.Sprintf("Local deployment failed: %v. Please provide a valid compose file path.", err)), err + } + + term.Debug("Function invoked: cli.Connect") + client, err := cli.Connect(ctx, cluster) + if err != nil { + return mcp.NewToolResultErrorFromErr("Could not connect", err), err + } + + term.Debug("Function invoked: cli.NewProvider") + + provider, err := cli.CheckProviderConfigured(ctx, client, *providerId, project.Name, len(project.Services)) + if err != nil { + return mcp.NewToolResultErrorFromErr("Provider not configured correctly", err), err + } + + err = cli.Tail(ctx, provider, project, cliTypes.TailOptions{ + Deployment: deploymentId, + Since: sinceTime, + Until: untilTime, + }) + + if err != nil { + err = fmt.Errorf("failed to tail logs: %w", err) + term.Error("Failed to tail logs", "error", err) + return mcp.NewToolResultErrorFromErr("Failed to tail logs", err), err + } + + return mcp.NewToolResultText("Done"), nil +} diff --git a/src/pkg/mcp/tools/tools.go b/src/pkg/mcp/tools/tools.go index 6965187c7..31deb383c 100644 --- a/src/pkg/mcp/tools/tools.go +++ b/src/pkg/mcp/tools/tools.go @@ -3,6 +3,7 @@ package tools import ( "context" "strings" + "time" "github.com/DefangLabs/defang/src/pkg/cli/client" "github.com/mark3labs/mcp-go/mcp" @@ -60,6 +61,27 @@ func CollectTools(cluster string, authPort int, providerId *client.ProviderID) [ return handleDestroyTool(ctx, request, providerId, cluster, cli) }, }, + { + Tool: mcp.NewTool("logs", + mcp.WithDescription("Fetch logs for a deployment."), + workingDirectoryOption, + mcp.WithString("deployment_id", + mcp.Description("The deployment ID for which to fetch logs"), + ), + mcp.WithString("since", + mcp.Description("The start time in RFC3339 format (e.g., 2006-01-02T15:04:05Z07:00)"), + mcp.DefaultString(time.Now().Add(-1*time.Hour).Format(time.RFC3339)), + ), + mcp.WithString("until", + mcp.Description("The end time in RFC3339 format (e.g., 2006-01-02T15:04:05Z07:00)"), + mcp.DefaultString(time.Now().Format(time.RFC3339)), + ), + ), + Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + cli := &DefaultToolCLI{} + return handleLogsTool(ctx, request, cluster, providerId, cli) + }, + }, { Tool: mcp.NewTool("estimate", mcp.WithDescription("Estimate the cost of deployed a Defang project."),