diff --git a/cmd/mcpserver/agent/args/args.go b/cmd/mcpserver/agent/args/args.go index 9c29c369b..e88785a57 100644 --- a/cmd/mcpserver/agent/args/args.go +++ b/cmd/mcpserver/agent/args/args.go @@ -24,7 +24,7 @@ var ( argConsoleUrl = pflag.String("console-url", helpers.GetEnv(controller.EnvConsoleURL, ""), "URL to the Console, i.e. https://console.onplural.sh") argConsoleToken = pflag.String("console-token", helpers.GetEnv(EnvConsoleToken, ""), "Deploy token to the Console API") argAgentRunID = pflag.String("agent-run-id", helpers.GetEnv(controller.EnvAgentRunID, ""), "ID of the Agent Run being executed") - argExcludeTools = pflag.String("exclude-tools", helpers.GetEnv(EnvExcludeTools, ""), "Comma-separated list of tools to exclude from the default set. Available tools: createBranch, agentPullRequest, fetchAgentRunTodos, updateAgentRunAnalysis, updateAgentRunTodos") + argExcludeTools = pflag.String("exclude-tools", helpers.GetEnv(EnvExcludeTools, ""), "Comma-separated list of tools to exclude from the default set. Available tools: createBranch, agentPullRequest, fetchAgentRunTodos, updateAgentRunAnalysis, updateAgentRunTodos, downloadServiceManifests") ) func init() { diff --git a/cmd/mcpserver/agent/main.go b/cmd/mcpserver/agent/main.go index 1f5b97c97..c813ae87b 100644 --- a/cmd/mcpserver/agent/main.go +++ b/cmd/mcpserver/agent/main.go @@ -67,6 +67,7 @@ func createServerTools(client console.Client) []agent.Option { tool.GetPRStateTool: tool.NewGetPRState(), tool.GetCILogsTool: tool.NewGetCILogs(), tool.ReactToCommentTool: tool.NewReactToComment(), + tool.DownloadManifestsTool: tool.NewDownloadManifests(client, args.AgentRunID()), } for _, excluded := range excludedTools { diff --git a/internal/mcpserver/agent/tool/downloadmanifests.go b/internal/mcpserver/agent/tool/downloadmanifests.go new file mode 100644 index 000000000..f591c8797 --- /dev/null +++ b/internal/mcpserver/agent/tool/downloadmanifests.go @@ -0,0 +1,174 @@ +package tool + +import ( + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + + "github.com/pluralsh/deployment-operator/pkg/agentrun-harness/environment" + console "github.com/pluralsh/deployment-operator/pkg/client" + "github.com/pluralsh/deployment-operator/pkg/manifests" +) + +// manifestsSubdir is the top-level directory (relative to the agent harness +// working directory) under which manifests for individual services are +// extracted. The actual files live in +// "//-/". +const manifestsSubdir = "manifests" + +// safeNamePattern matches characters that are safe to use in a directory name +// derived from a cluster handle / service name. +var safeNamePattern = regexp.MustCompile(`[^a-zA-Z0-9._-]+`) + +func (in *DownloadManifests) Install(s *server.MCPServer) { + s.AddTool( + mcp.NewTool( + in.id.String(), + mcp.WithDescription(in.description), + mcp.WithString("cluster", + mcp.Required(), + mcp.Description("Handle of the Plural cluster the service is deployed to (e.g. 'mgmt' or 'prod-eu-1')"), + ), + mcp.WithString("service", + mcp.Required(), + mcp.Description("Name of the Plural service whose rendered manifests should be downloaded"), + ), + ), + in.handler, + ) +} + +func (in *DownloadManifests) handler(_ context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if err := in.fromRequest(request); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("could not handle download manifests request: %v", err)), nil + } + + svc, err := in.client.GetServiceDeploymentByHandle(in.ClusterHandle, in.ServiceName) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to look up service %q on cluster %q: %v", in.ServiceName, in.ClusterHandle, err)), nil + } + if svc.Tarball == nil || *svc.Tarball == "" { + return mcp.NewToolResultError(fmt.Sprintf("service %q on cluster %q does not yet have a rendered tarball available", in.ServiceName, in.ClusterHandle)), nil + } + + _, token := in.client.GetCredentials() + if token == "" { + return mcp.NewToolResultError("Plural Console credentials are not configured for the MCP server"), nil + } + + baseDir, err := resolveManifestsBaseDir() + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to resolve manifests base directory: %v", err)), nil + } + + targetDir := filepath.Join(baseDir, manifestsSubdir, sanitizeSegment(in.ClusterHandle)+"-"+sanitizeSegment(in.ServiceName)) + + if err := os.RemoveAll(targetDir); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to clear target directory %q: %v", targetDir, err)), nil + } + if err := os.MkdirAll(targetDir, 0o755); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to create target directory %q: %v", targetDir, err)), nil + } + + reader, _, err := manifests.GetReader(*svc.Tarball, token) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to download service tarball: %v", err)), nil + } + defer reader.Close() + + if err := manifests.Untar(targetDir, reader); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to extract service tarball into %q: %v", targetDir, err)), nil + } + + return mcp.NewToolResultJSON(struct { + Success bool `json:"success"` + Message string `json:"message"` + Cluster string `json:"cluster"` + Service string `json:"service"` + ServiceID string `json:"serviceId"` + Directory string `json:"directory"` + Instructions string `json:"instructions"` + }{ + Success: true, + Message: fmt.Sprintf("downloaded manifests for service %q on cluster %q", in.ServiceName, in.ClusterHandle), + Cluster: in.ClusterHandle, + Service: in.ServiceName, + ServiceID: svc.ID, + Directory: targetDir, + Instructions: fmt.Sprintf( + "The rendered Kubernetes manifests for the service have been written to %q. "+ + "Use Read/Glob/Grep against this directory to inspect the actual resources Plural is applying "+ + "(including resources rendered from external Helm charts) instead of guessing via web searches.", + targetDir, + ), + }) +} + +func (in *DownloadManifests) fromRequest(request mcp.CallToolRequest) (err error) { + if in.ClusterHandle, err = request.RequireString("cluster"); err != nil { + return + } + + if in.ServiceName, err = request.RequireString("service"); err != nil { + return + } + + in.ClusterHandle = strings.TrimSpace(in.ClusterHandle) + in.ServiceName = strings.TrimSpace(in.ServiceName) + + if in.ClusterHandle == "" { + return fmt.Errorf("cluster handle must not be empty") + } + if in.ServiceName == "" { + return fmt.Errorf("service name must not be empty") + } + + return nil +} + +// resolveManifestsBaseDir picks the directory under which the +// "manifests/-" tree should live. We prefer the parent of +// the cloned repository (the agent harness working directory) so that +// downloaded manifests are kept side-by-side with the repo without polluting +// the git working tree. +func resolveManifestsBaseDir() (string, error) { + if cfg, err := environment.Load(); err == nil && cfg != nil && cfg.Dir != "" { + return filepath.Dir(cfg.Dir), nil + } + + return os.Getwd() +} + +// sanitizeSegment normalises a value coming from outside the harness into +// something safe to use as a directory name segment. +func sanitizeSegment(s string) string { + s = strings.TrimSpace(s) + s = safeNamePattern.ReplaceAllString(s, "-") + s = strings.Trim(s, "-") + if s == "" { + return "service" + } + return s +} + +func NewDownloadManifests(client console.Client, agentRunID string) Tool { + return &DownloadManifests{ + ConsoleTool: ConsoleTool{ + id: DownloadManifestsTool, + description: "Downloads the fully rendered Kubernetes manifests for a Plural service " + + "and writes them to a dedicated '-' subdirectory under '" + manifestsSubdir + "/' " + + "next to the cloned repository. Use this whenever you need to understand what Plural " + + "is actually applying for a service - including resources rendered from external Helm " + + "charts or the Plural gitops layout - instead of guessing via web searches. After it " + + "returns, inspect the listed directory with Read/Glob/Grep.", + client: client, + agentRunID: agentRunID, + }, + } +} diff --git a/internal/mcpserver/agent/tool/tool.go b/internal/mcpserver/agent/tool/tool.go index c976134ea..6e94c6856 100644 --- a/internal/mcpserver/agent/tool/tool.go +++ b/internal/mcpserver/agent/tool/tool.go @@ -35,6 +35,8 @@ func ToID(id string) (ID, error) { return CreateCommitTool, nil case string(ReactToCommentTool): return ReactToCommentTool, nil + case string(DownloadManifestsTool): + return DownloadManifestsTool, nil } return "", fmt.Errorf("invalid tool ID: %s", id) @@ -50,6 +52,7 @@ const ( GetPRStateTool ID = "getPRState" GetCILogsTool ID = "getCILogs" ReactToCommentTool ID = "reactToComment" + DownloadManifestsTool ID = "downloadServiceManifests" ) // Tool is an MCP tool that can be installed on the MCP server @@ -106,3 +109,12 @@ type CreateBranch struct { type FetchTodos struct { ConsoleTool } + +// DownloadManifests is an MCP tool that fetches the rendered Kubernetes +// manifests for a Plural service via the service files GraphQL API and +// writes them to a local subdirectory so the agent can inspect them. +type DownloadManifests struct { + ConsoleTool + ClusterHandle string + ServiceName string +} diff --git a/pkg/agentrun-harness/tool/claude/agents.go b/pkg/agentrun-harness/tool/claude/agents.go index 0e7605813..d22217e12 100644 --- a/pkg/agentrun-harness/tool/claude/agents.go +++ b/pkg/agentrun-harness/tool/claude/agents.go @@ -4,7 +4,7 @@ const analysisAgent = `{ "analysis": { "description": "Analyze code for potential issues, vulnerabilities and improvements. Use PROACTIVELY.", "prompt": "You are a read-only autonomous analysis agent.", - "tools": ["Read", "Grep", "Glob", "Bash", "mcp__plural__updateAgentRunAnalysis"] + "tools": ["Read", "Grep", "Glob", "Bash", "mcp__plural__updateAgentRunAnalysis", "mcp__plural__downloadServiceManifests"] } }` @@ -12,7 +12,7 @@ const autonomousAgent = `{ "autonomous": { "description": "Autonomous agent for making code changes and creating pull requests. Use PROACTIVELY.", "prompt": "You are an autonomous coding agent, highly skilled in coding and code analysis.", - "tools": ["Read", "Write", "Edit", "MultiEdit", "Bash", "Grep", "Glob", "WebFetch", "mcp__plural__agentPullRequest", "mcp__plural__createBranch", "mcp__plural__fetchAgentRunTodos", "mcp__plural__updateAgentRunTodos"] + "tools": ["Read", "Write", "Edit", "MultiEdit", "Bash", "Grep", "Glob", "WebFetch", "mcp__plural__agentPullRequest", "mcp__plural__createBranch", "mcp__plural__fetchAgentRunTodos", "mcp__plural__updateAgentRunTodos", "mcp__plural__downloadServiceManifests"] } }` @@ -20,6 +20,6 @@ const babysitAgent = `{ "babysit": { "description": "Autonomous agent responding to pull request feedback. Commits to the existing PR branch. Does NOT create new PRs. Use PROACTIVELY.", "prompt": "You are an autonomous coding agent. Your pull request is already open. Address reviewer comments and fix CI failures, then commit to the existing branch.", - "tools": ["Read", "Write", "Edit", "MultiEdit", "Bash", "Grep", "Glob", "WebFetch", "mcp__plural__createCommit", "mcp__plural__fetchAgentRunTodos", "mcp__plural__updateAgentRunTodos", "mcp__plural__getPRState", "mcp__plural__getCILogs", "mcp__plural__reactToComment"] + "tools": ["Read", "Write", "Edit", "MultiEdit", "Bash", "Grep", "Glob", "WebFetch", "mcp__plural__createCommit", "mcp__plural__fetchAgentRunTodos", "mcp__plural__updateAgentRunTodos", "mcp__plural__getPRState", "mcp__plural__getCILogs", "mcp__plural__downloadServiceManifests", "mcp__plural__reactToComment"] } }` diff --git a/pkg/agentrun-harness/tool/claude/claude.go b/pkg/agentrun-harness/tool/claude/claude.go index 110576277..5a69409b5 100644 --- a/pkg/agentrun-harness/tool/claude/claude.go +++ b/pkg/agentrun-harness/tool/claude/claude.go @@ -202,6 +202,7 @@ func (in *Claude) ConfigureBabysitRun() error { "mcp__plural__updateAgentRunTodos", "mcp__plural__getPRState", "mcp__plural__getCILogs", + "mcp__plural__downloadServiceManifests", "mcp__plural__reactToComment") for _, cfg := range in.Config.Run.Runtime.ExaMcpConfigs { @@ -265,7 +266,8 @@ func (in *Claude) Configure(consoleURL, consoleToken, _ string) error { "Bash(grep:*)", "Bash(find:*)", "WebFetch", - "mcp__plural__updateAgentRunAnalysis"). + "mcp__plural__updateAgentRunAnalysis", + "mcp__plural__downloadServiceManifests"). DenyTools("Edit", "Write", "Bash(rm:*)", "Bash(sudo:*)") } else { settings.AllowTools( @@ -278,7 +280,8 @@ func (in *Claude) Configure(consoleURL, consoleToken, _ string) error { "mcp__plural__agentPullRequest", "mcp__plural__createBranch", "mcp__plural__fetchAgentRunTodos", - "mcp__plural__updateAgentRunTodos") + "mcp__plural__updateAgentRunTodos", + "mcp__plural__downloadServiceManifests") } for _, cfg := range in.Config.Run.Runtime.ExaMcpConfigs { diff --git a/pkg/agentrun-harness/tool/codex/codex.go b/pkg/agentrun-harness/tool/codex/codex.go index b722ab333..ea4967f31 100644 --- a/pkg/agentrun-harness/tool/codex/codex.go +++ b/pkg/agentrun-harness/tool/codex/codex.go @@ -140,7 +140,7 @@ func (in *Codex) Configure(consoleURL, consoleToken, deployToken string) error { Type: "stdio", Command: "/usr/local/bin/mcpserver", TrustPolicy: "always", - EnabledTools: []string{"updateAgentRunAnalysis"}, + EnabledTools: []string{"updateAgentRunAnalysis", "downloadServiceManifests"}, Env: mcpBaseEnv, }} case console.AgentRunModeWrite: @@ -158,7 +158,7 @@ func (in *Codex) Configure(consoleURL, consoleToken, deployToken string) error { Type: "stdio", Command: "/usr/local/bin/mcpserver", TrustPolicy: "always", - EnabledTools: []string{"agentPullRequest", "createBranch", "fetchAgentRunTodos", "updateAgentRunTodos", "getPRState", "getCILogs", "reactToComment", "createCommit"}, + EnabledTools: []string{"agentPullRequest", "createBranch", "fetchAgentRunTodos", "updateAgentRunTodos", "getPRState", "getCILogs", "reactToComment", "createCommit", "downloadServiceManifests"}, Env: mcpBaseEnv, }} default: diff --git a/pkg/agentrun-harness/tool/gemini/settings_test.go b/pkg/agentrun-harness/tool/gemini/settings_test.go index 1c1bee9e1..59f85c135 100644 --- a/pkg/agentrun-harness/tool/gemini/settings_test.go +++ b/pkg/agentrun-harness/tool/gemini/settings_test.go @@ -70,7 +70,7 @@ func TestSettingsTemplate_GenerateAndVerifyContents(t *testing.T) { } }) - t.Run("ANALYZE mode sets includeTools to only updateAgentRunAnalysis for plural MCP server", func(t *testing.T) { + t.Run("ANALYZE mode sets includeTools for plural MCP server", func(t *testing.T) { input := *baseInput input.AgentRunMode = console.AgentRunModeAnalyze @@ -107,8 +107,18 @@ func TestSettingsTemplate_GenerateAndVerifyContents(t *testing.T) { tools = append(tools, s) } } - if len(tools) != 1 || tools[0] != "updateAgentRunAnalysis" { - t.Errorf("includeTools must be exactly [\"updateAgentRunAnalysis\"] in ANALYZE mode, got: %v", tools) + want := map[string]struct{}{ + "updateAgentRunAnalysis": {}, + "downloadServiceManifests": {}, + } + for _, name := range tools { + delete(want, name) + } + if len(tools) != 2 { + t.Errorf("includeTools must contain exactly 2 tools in ANALYZE mode, got: %v", tools) + } + if len(want) != 0 { + t.Errorf("includeTools missing expected entries in ANALYZE mode, missing: %v", want) } }) diff --git a/pkg/agentrun-harness/tool/gemini/templates/settings.json.gotmpl b/pkg/agentrun-harness/tool/gemini/templates/settings.json.gotmpl index 91c99adb1..dad994efa 100644 --- a/pkg/agentrun-harness/tool/gemini/templates/settings.json.gotmpl +++ b/pkg/agentrun-harness/tool/gemini/templates/settings.json.gotmpl @@ -60,7 +60,7 @@ "trust": true{{ if eq .AgentRunMode "WRITE" }}, "excludeTools": ["updateAgentRunAnalysis"] {{ else }}, - "includeTools": ["updateAgentRunAnalysis"] + "includeTools": ["updateAgentRunAnalysis", "downloadServiceManifests"] {{ end }} }{{ range .ExaMcpConfigs }}, "{{ .Name }}": { diff --git a/pkg/client/console.go b/pkg/client/console.go index ce599def4..6ceae7030 100644 --- a/pkg/client/console.go +++ b/pkg/client/console.go @@ -55,6 +55,7 @@ type Client interface { GetClusterBackup(clusterID, namespace, name string) (*console.ClusterBackupFragment, error) GetServices(after *string, first *int64) (*console.PagedClusterServicesForAgent, error) GetService(id string) (*console.ServiceDeploymentForAgent, error) + GetServiceDeploymentByHandle(cluster, name string) (*console.ServiceDeploymentExtended, error) GetServiceDeploymentComponents(id string) (*console.GetServiceDeploymentComponents_ServiceDeployment, error) UpdateComponents(id, revisionID string, sha *string, components []*console.ComponentAttributes, errs []*console.ServiceErrorAttributes, metadata *console.ServiceMetadataAttributes) error UpdateServiceErrors(id string, errs []*console.ServiceErrorAttributes) error diff --git a/pkg/client/service.go b/pkg/client/service.go index fd912da40..980ec7392 100644 --- a/pkg/client/service.go +++ b/pkg/client/service.go @@ -26,6 +26,19 @@ func (c *client) GetService(id string) (*console.ServiceDeploymentForAgent, erro return resp.ServiceDeployment, nil } +func (c *client) GetServiceDeploymentByHandle(cluster, name string) (*console.ServiceDeploymentExtended, error) { + resp, err := c.consoleClient.GetServiceDeploymentByHandle(c.ctx, cluster, name) + if err != nil { + return nil, err + } + + if resp == nil || resp.ServiceDeployment == nil { + return nil, errors.New("service deployment not found") + } + + return resp.ServiceDeployment, nil +} + func (c *client) GetServiceDeploymentComponents(id string) (*console.GetServiceDeploymentComponents_ServiceDeployment, error) { resp, err := c.consoleClient.GetServiceDeploymentComponents(c.ctx, id) if err != nil { diff --git a/pkg/test/mocks/Client_mock.go b/pkg/test/mocks/Client_mock.go index e0b1f8f3f..820a31b19 100644 --- a/pkg/test/mocks/Client_mock.go +++ b/pkg/test/mocks/Client_mock.go @@ -1535,6 +1535,65 @@ func (_c *ClientMock_GetService_Call) RunAndReturn(run func(string) (*goclient.S return _c } +// GetServiceDeploymentByHandle provides a mock function with given fields: cluster, name +func (_m *ClientMock) GetServiceDeploymentByHandle(cluster string, name string) (*goclient.ServiceDeploymentExtended, error) { + ret := _m.Called(cluster, name) + + if len(ret) == 0 { + panic("no return value specified for GetServiceDeploymentByHandle") + } + + var r0 *goclient.ServiceDeploymentExtended + var r1 error + if rf, ok := ret.Get(0).(func(string, string) (*goclient.ServiceDeploymentExtended, error)); ok { + return rf(cluster, name) + } + if rf, ok := ret.Get(0).(func(string, string) *goclient.ServiceDeploymentExtended); ok { + r0 = rf(cluster, name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*goclient.ServiceDeploymentExtended) + } + } + + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(cluster, name) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ClientMock_GetServiceDeploymentByHandle_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetServiceDeploymentByHandle' +type ClientMock_GetServiceDeploymentByHandle_Call struct { + *mock.Call +} + +// GetServiceDeploymentByHandle is a helper method to define mock.On call +// - cluster string +// - name string +func (_e *ClientMock_Expecter) GetServiceDeploymentByHandle(cluster interface{}, name interface{}) *ClientMock_GetServiceDeploymentByHandle_Call { + return &ClientMock_GetServiceDeploymentByHandle_Call{Call: _e.mock.On("GetServiceDeploymentByHandle", cluster, name)} +} + +func (_c *ClientMock_GetServiceDeploymentByHandle_Call) Run(run func(cluster string, name string)) *ClientMock_GetServiceDeploymentByHandle_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(string)) + }) + return _c +} + +func (_c *ClientMock_GetServiceDeploymentByHandle_Call) Return(_a0 *goclient.ServiceDeploymentExtended, _a1 error) *ClientMock_GetServiceDeploymentByHandle_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *ClientMock_GetServiceDeploymentByHandle_Call) RunAndReturn(run func(string, string) (*goclient.ServiceDeploymentExtended, error)) *ClientMock_GetServiceDeploymentByHandle_Call { + _c.Call.Return(run) + return _c +} + // GetServiceDeploymentComponents provides a mock function with given fields: id func (_m *ClientMock) GetServiceDeploymentComponents(id string) (*goclient.GetServiceDeploymentComponents_ServiceDeployment, error) { ret := _m.Called(id)