Skip to content
Open
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 cmd/mcpserver/agent/args/args.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
1 change: 1 addition & 0 deletions cmd/mcpserver/agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
174 changes: 174 additions & 0 deletions internal/mcpserver/agent/tool/downloadmanifests.go
Original file line number Diff line number Diff line change
@@ -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
// "<workingDir>/<manifestsSubdir>/<handle>-<service>/".
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,
),
})
}
Comment on lines +47 to +111
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Per-request state stored on shared receiver

fromRequest writes in.ClusterHandle and in.ServiceName directly onto the *DownloadManifests receiver, which is a single instance shared across all concurrent MCP tool calls. Two simultaneous invocations can interleave: Request A sets ClusterHandle = "prod", Request B overwrites it with ClusterHandle = "dev", then Request A proceeds to call GetServiceDeploymentByHandle("dev", ...) — fetching and extracting the wrong service's manifests, and potentially writing them to the wrong targetDir.

The fix is to keep clusterHandle and serviceName as function-local variables in handler instead of struct fields.


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/<handle>-<service>" 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 '<handle>-<name>' 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,
},
}
}
12 changes: 12 additions & 0 deletions internal/mcpserver/agent/tool/tool.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
}
6 changes: 3 additions & 3 deletions pkg/agentrun-harness/tool/claude/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,22 @@ 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"]
}
}`

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"]
}
}`

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"]
}
}`
7 changes: 5 additions & 2 deletions pkg/agentrun-harness/tool/claude/claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand All @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions pkg/agentrun-harness/tool/codex/codex.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
16 changes: 13 additions & 3 deletions pkg/agentrun-harness/tool/gemini/settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
}
})

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

Expand Down Expand Up @@ -107,8 +107,18 @@
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": {},

Check failure on line 111 in pkg/agentrun-harness/tool/gemini/settings_test.go

View workflow job for this annotation

GitHub Actions / Lint

File is not properly formatted (gofmt)
"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)
}
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
"trust": true{{ if eq .AgentRunMode "WRITE" }},
"excludeTools": ["updateAgentRunAnalysis"]
{{ else }},
"includeTools": ["updateAgentRunAnalysis"]
"includeTools": ["updateAgentRunAnalysis", "downloadServiceManifests"]
{{ end }}
}{{ range .ExaMcpConfigs }},
"{{ .Name }}": {
Expand Down
1 change: 1 addition & 0 deletions pkg/client/console.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions pkg/client/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading