Skip to content

Commit 83acbff

Browse files
clamoriniereclaude
andcommitted
Add MCP tool auto-selection and improve test coverage
This commit enhances the kubectl-datadog MCP server with several improvements: **Auto-selection for MCP tools:** - Made `name` parameter optional in GetAgentStatusArgs, DescribeAgentFeaturesArgs, DescribeAgentComponentsArgs, and GetClusterAgentLeaderArgs - Added `selectDatadogAgent` helper method to eliminate code duplication - Auto-selects first DatadogAgent when name not provided (convenient for single-agent clusters) - Updated tool descriptions to reflect auto-selection capability **Added get_cluster_agent_leader tool:** - Implements cluster-agent leader discovery functionality - Returns leader pod name and election method (Lease or ConfigMap) - Supports auto-selection like other tools **Test improvements:** - Created comprehensive test suite for tools.go (0% → 100% coverage for helper functions) - Added 11 tests covering schema generation, auto-selection, and tool registration - Refactored tests to eliminate duplication using helper functions - Reduced test code from 377 to 230 lines (39% reduction) - Package coverage improved from 23.7% to 29.7% **Documentation updates:** - Updated kubectl-datadog-mcp.md with auto-selection examples - Added documentation for get_cluster_agent_leader tool - Updated architecture diagram with all 5 local tools **Code quality:** - Added explicit channel lifecycle documentation in PortForwarder - All changes pass linting with 0 issues 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent d094728 commit 83acbff

24 files changed

Lines changed: 2325 additions & 166 deletions

LICENSE-3rdparty.csv

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ core,github.com/google/btree,Apache-2.0
103103
core,github.com/google/cel-go,Apache-2.0
104104
core,github.com/google/gnostic-models,Apache-2.0
105105
core,github.com/google/go-cmp/cmp,BSD-3-Clause
106+
core,github.com/google/jsonschema-go/jsonschema,MIT
106107
core,github.com/google/pprof/profile,Apache-2.0
107108
core,github.com/google/shlex,Apache-2.0
108109
core,github.com/google/uuid,BSD-3-Clause
@@ -145,6 +146,7 @@ core,github.com/mitchellh/mapstructure,MIT
145146
core,github.com/mitchellh/reflectwalk,MIT
146147
core,github.com/moby/spdystream,Apache-2.0
147148
core,github.com/moby/term,Apache-2.0
149+
core,github.com/modelcontextprotocol/go-sdk,MIT
148150
core,github.com/modern-go/concurrent,Apache-2.0
149151
core,github.com/modern-go/reflect2,Apache-2.0
150152
core,github.com/mohae/deepcopy,MIT
@@ -200,6 +202,7 @@ core,github.com/ulikunitz/xz,BSD-3-Clause
200202
core,github.com/x448/float16,MIT
201203
core,github.com/xi2/xz,Unknown
202204
core,github.com/xlab/treeprint,MIT
205+
core,github.com/yosida95/uritemplate/v3,BSD-3-Clause
203206
core,go.etcd.io/bbolt,MIT
204207
core,go.opentelemetry.io/auto/sdk,Apache-2.0
205208
core,go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp,Apache-2.0

cmd/kubectl-datadog/autoscaling/cluster/install/guess/aws-auth.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import (
1010
)
1111

1212
// IsAwsAuthConfigMapPresent checks if the aws-auth ConfigMap exists in the kube-system namespace
13-
func IsAwsAuthConfigMapPresent(ctx context.Context, clientset *kubernetes.Clientset) (bool, error) {
13+
func IsAwsAuthConfigMapPresent(ctx context.Context, clientset kubernetes.Interface) (bool, error) {
1414
if _, err := clientset.CoreV1().ConfigMaps("kube-system").Get(ctx, "aws-auth", metav1.GetOptions{}); err != nil {
1515
if apierrors.IsNotFound(err) {
1616
return false, nil

cmd/kubectl-datadog/autoscaling/cluster/install/guess/nodesproperties.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ const nodeListChunkSize = 100
2323
// Format: aws:///ZONE/INSTANCE_ID (e.g., aws:///us-east-1a/i-0abc123def456789)
2424
var awsProviderIDRegexp = regexp.MustCompile(`^aws:///[^/]+/(i-[0-9a-f]+)$`)
2525

26-
func GetNodesProperties(ctx context.Context, clientset *kubernetes.Clientset, ec2Client *ec2.Client) (*NodePoolsSet, error) {
26+
func GetNodesProperties(ctx context.Context, clientset kubernetes.Interface, ec2Client *ec2.Client) (*NodePoolsSet, error) {
2727
nps := NewNodePoolsSet()
2828

2929
var cont string

cmd/kubectl-datadog/autoscaling/cluster/install/install.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -271,8 +271,8 @@ type clients struct {
271271
sts *sts.Client
272272

273273
// Kubernetes clients
274-
k8sClient client.Client // controller-runtime client
275-
k8sClientset *kubernetes.Clientset // typed Kubernetes client
274+
k8sClient client.Client // controller-runtime client
275+
k8sClientset kubernetes.Interface // typed Kubernetes client
276276
}
277277

278278
func (o *options) getClusterNameFromKubeconfig(ctx context.Context) (string, error) {

cmd/kubectl-datadog/clusteragent/leader/leader.go

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -131,12 +131,22 @@ func (o *options) run(cmd *cobra.Command) error {
131131
}
132132

133133
func (o *options) getLeaderFromLease(objKey client.ObjectKey) (string, error) {
134+
return GetLeaderFromLease(o.Client, objKey)
135+
}
136+
137+
func (o *options) getLeaderFromConfigMap(objKey client.ObjectKey) (string, error) {
138+
return GetLeaderFromConfigMap(o.Client, objKey)
139+
}
140+
141+
// GetLeaderFromLease retrieves the leader pod name from a Lease resource
142+
// This is a public function that can be reused by other packages
143+
func GetLeaderFromLease(c client.Client, objKey client.ObjectKey) (string, error) {
134144
lease := &coordv1.Lease{}
135-
err := o.Client.Get(context.TODO(), objKey, lease)
145+
err := c.Get(context.TODO(), objKey, lease)
136146
if err != nil && apierrors.IsNotFound(err) {
137147
return "", fmt.Errorf("lease %s/%s not found", objKey.Namespace, objKey.Name)
138148
} else if err != nil {
139-
return "", fmt.Errorf("unable to get leader election config map: %w", err)
149+
return "", fmt.Errorf("unable to get leader election lease: %w", err)
140150
}
141151

142152
// get the info from the lease
@@ -147,10 +157,12 @@ func (o *options) getLeaderFromLease(objKey client.ObjectKey) (string, error) {
147157
return *lease.Spec.HolderIdentity, nil
148158
}
149159

150-
func (o *options) getLeaderFromConfigMap(objKey client.ObjectKey) (string, error) {
160+
// GetLeaderFromConfigMap retrieves the leader pod name from a ConfigMap annotation
161+
// This is a public function that can be reused by other packages
162+
func GetLeaderFromConfigMap(c client.Client, objKey client.ObjectKey) (string, error) {
151163
// Get the config map holding the leader identity.
152164
cm := &corev1.ConfigMap{}
153-
err := o.Client.Get(context.TODO(), objKey, cm)
165+
err := c.Get(context.TODO(), objKey, cm)
154166
if err != nil && apierrors.IsNotFound(err) {
155167
return "", fmt.Errorf("config map %s/%s not found", objKey.Namespace, objKey.Name)
156168
} else if err != nil {
@@ -172,7 +184,13 @@ func (o *options) getLeaderFromConfigMap(objKey client.ObjectKey) (string, error
172184
}
173185

174186
func isLeaseSupported(client discovery.DiscoveryInterface) (bool, error) {
175-
apiGroupList, err := client.ServerGroups()
187+
return IsLeaseSupported(client)
188+
}
189+
190+
// IsLeaseSupported checks if the Kubernetes cluster supports Lease resources
191+
// This is a public function that can be reused by other packages
192+
func IsLeaseSupported(discoveryClient discovery.DiscoveryInterface) (bool, error) {
193+
apiGroupList, err := discoveryClient.ServerGroups()
176194
if err != nil {
177195
return false, fmt.Errorf("unable to discover APIGroups, err:%w", err)
178196
}

cmd/kubectl-datadog/mcp/client.go

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed
2+
// under the Apache License Version 2.0.
3+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
// Copyright 2016-present Datadog, Inc.
5+
6+
package mcp
7+
8+
import (
9+
"context"
10+
"fmt"
11+
"net/http"
12+
"time"
13+
14+
"github.com/modelcontextprotocol/go-sdk/mcp"
15+
)
16+
17+
// ClusterAgentMCPClient communicates with the cluster-agent MCP server via HTTP
18+
type ClusterAgentMCPClient struct {
19+
baseURL string
20+
endpoint string
21+
httpClient *http.Client
22+
mcpClient *mcp.Client
23+
session *mcp.ClientSession
24+
}
25+
26+
// ClusterAgentMCPClientConfig contains configuration for creating a ClusterAgentMCPClient
27+
type ClusterAgentMCPClientConfig struct {
28+
BaseURL string // e.g., "http://localhost:12345"
29+
Endpoint string // e.g., "/mcp"
30+
Timeout time.Duration // HTTP timeout for tool calls
31+
MaxRetries int // Maximum reconnection retries
32+
}
33+
34+
// NewClusterAgentMCPClient creates a new MCP HTTP client for cluster-agent communication
35+
func NewClusterAgentMCPClient(config ClusterAgentMCPClientConfig) (*ClusterAgentMCPClient, error) {
36+
if config.BaseURL == "" {
37+
return nil, fmt.Errorf("baseURL is required")
38+
}
39+
40+
if config.Endpoint == "" {
41+
config.Endpoint = "/mcp"
42+
}
43+
44+
if config.Timeout == 0 {
45+
config.Timeout = 120 * time.Second
46+
}
47+
48+
if config.MaxRetries == 0 {
49+
config.MaxRetries = 3
50+
}
51+
52+
// Create HTTP client with timeout
53+
httpClient := &http.Client{
54+
Timeout: config.Timeout,
55+
}
56+
57+
// Create MCP client
58+
mcpClient := mcp.NewClient(&mcp.Implementation{
59+
Name: "kubectl-datadog-proxy",
60+
Version: "1.0.0",
61+
}, nil)
62+
63+
return &ClusterAgentMCPClient{
64+
baseURL: config.BaseURL,
65+
endpoint: config.Endpoint,
66+
httpClient: httpClient,
67+
mcpClient: mcpClient,
68+
}, nil
69+
}
70+
71+
// Connect establishes a connection to the cluster-agent MCP server
72+
func (c *ClusterAgentMCPClient) Connect(ctx context.Context) error {
73+
// Build full endpoint URL
74+
fullURL := c.baseURL + c.endpoint
75+
76+
// Create streamable HTTP transport
77+
transport := &mcp.StreamableClientTransport{
78+
Endpoint: fullURL,
79+
HTTPClient: c.httpClient,
80+
MaxRetries: 3,
81+
}
82+
83+
// Connect to the cluster-agent MCP server
84+
session, err := c.mcpClient.Connect(ctx, transport, nil)
85+
if err != nil {
86+
return fmt.Errorf("failed to connect to cluster-agent MCP server: %w", err)
87+
}
88+
89+
c.session = session
90+
return nil
91+
}
92+
93+
// ListTools fetches the list of available tools from the cluster-agent
94+
func (c *ClusterAgentMCPClient) ListTools(ctx context.Context) ([]*mcp.Tool, error) {
95+
if c.session == nil {
96+
return nil, fmt.Errorf("client not connected, call Connect() first")
97+
}
98+
99+
// List tools from the cluster-agent
100+
result, err := c.session.ListTools(ctx, &mcp.ListToolsParams{})
101+
if err != nil {
102+
return nil, fmt.Errorf("failed to list tools: %w", err)
103+
}
104+
105+
return result.Tools, nil
106+
}
107+
108+
// CallTool executes a tool on the cluster-agent
109+
func (c *ClusterAgentMCPClient) CallTool(ctx context.Context, name string, arguments map[string]interface{}) (*mcp.CallToolResult, error) {
110+
if c.session == nil {
111+
return nil, fmt.Errorf("client not connected, call Connect() first")
112+
}
113+
114+
// Call the tool on the cluster-agent
115+
result, err := c.session.CallTool(ctx, &mcp.CallToolParams{
116+
Name: name,
117+
Arguments: arguments,
118+
})
119+
if err != nil {
120+
return nil, fmt.Errorf("failed to call tool %s: %w", name, err)
121+
}
122+
123+
return result, nil
124+
}
125+
126+
// Close closes the connection to the cluster-agent MCP server
127+
func (c *ClusterAgentMCPClient) Close() error {
128+
if c.session != nil {
129+
return c.session.Close()
130+
}
131+
return nil
132+
}
133+
134+
// IsConnected returns true if the client is connected to the cluster-agent
135+
func (c *ClusterAgentMCPClient) IsConnected() bool {
136+
return c.session != nil
137+
}
138+
139+
// GetServerCapabilities returns capabilities of the connected MCP server
140+
func (c *ClusterAgentMCPClient) GetServerCapabilities() (*mcp.ServerCapabilities, error) {
141+
if c.session == nil {
142+
return nil, fmt.Errorf("client not connected")
143+
}
144+
145+
// Server capabilities are available in the InitializeResult
146+
initResult := c.session.InitializeResult()
147+
if initResult == nil {
148+
return nil, fmt.Errorf("server not initialized")
149+
}
150+
151+
return initResult.Capabilities, nil
152+
}
153+
154+
// Ping sends a ping request to verify the connection is alive
155+
func (c *ClusterAgentMCPClient) Ping(ctx context.Context) error {
156+
if c.session == nil {
157+
return fmt.Errorf("client not connected")
158+
}
159+
160+
// Use a simple operation like listing tools to verify connectivity
161+
_, err := c.session.ListTools(ctx, &mcp.ListToolsParams{})
162+
if err != nil {
163+
return fmt.Errorf("ping failed: %w", err)
164+
}
165+
166+
return nil
167+
}
168+
169+
// Reconnect attempts to reconnect to the cluster-agent MCP server
170+
func (c *ClusterAgentMCPClient) Reconnect(ctx context.Context) error {
171+
// Close existing connection if any
172+
if c.session != nil {
173+
_ = c.session.Close()
174+
c.session = nil
175+
}
176+
177+
// Attempt to reconnect
178+
return c.Connect(ctx)
179+
}

0 commit comments

Comments
 (0)