Skip to content

Commit 3720f23

Browse files
Merge pull request #64 from matzew/sync-downstream
NO-JIRA: Sync downstream with the latest changes in upstream
2 parents a41f09c + cd16ebf commit 3720f23

File tree

12 files changed

+362
-46
lines changed

12 files changed

+362
-46
lines changed

internal/test/mcp.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"testing"
77

88
"github.com/mark3labs/mcp-go/client"
9+
"github.com/mark3labs/mcp-go/client/transport"
910
"github.com/mark3labs/mcp-go/mcp"
1011
"github.com/stretchr/testify/require"
1112
"golang.org/x/net/context"
@@ -17,12 +18,12 @@ type McpClient struct {
1718
*client.Client
1819
}
1920

20-
func NewMcpClient(t *testing.T, mcpHttpServer http.Handler) *McpClient {
21+
func NewMcpClient(t *testing.T, mcpHttpServer http.Handler, options ...transport.StreamableHTTPCOption) *McpClient {
2122
require.NotNil(t, mcpHttpServer, "McpHttpServer must be provided")
2223
var err error
2324
ret := &McpClient{ctx: t.Context()}
2425
ret.testServer = httptest.NewServer(mcpHttpServer)
25-
ret.Client, err = client.NewStreamableHttpClient(ret.testServer.URL + "/mcp")
26+
ret.Client, err = client.NewStreamableHttpClient(ret.testServer.URL+"/mcp", options...)
2627
require.NoError(t, err, "Expected no error creating MCP client")
2728
err = ret.Start(t.Context())
2829
require.NoError(t, err, "Expected no error starting MCP client")

pkg/kubernetes/accesscontrol_clientset.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,22 @@ func (a *AccessControlClientset) NodesLogs(ctx context.Context, name string) (*r
5555
AbsPath(url...), nil
5656
}
5757

58+
func (a *AccessControlClientset) NodesStatsSummary(ctx context.Context, name string) (*rest.Request, error) {
59+
gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Node"}
60+
if !isAllowed(a.staticConfig, gvk) {
61+
return nil, isNotAllowedError(gvk)
62+
}
63+
64+
if _, err := a.delegate.CoreV1().Nodes().Get(ctx, name, metav1.GetOptions{}); err != nil {
65+
return nil, fmt.Errorf("failed to get node %s: %w", name, err)
66+
}
67+
68+
url := []string{"api", "v1", "nodes", name, "proxy", "stats", "summary"}
69+
return a.delegate.CoreV1().RESTClient().
70+
Get().
71+
AbsPath(url...), nil
72+
}
73+
5874
func (a *AccessControlClientset) Pods(namespace string) (corev1.PodInterface, error) {
5975
gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Pod"}
6076
if !isAllowed(a.staticConfig, gvk) {

pkg/kubernetes/nodes.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,25 @@ func (k *Kubernetes) NodesLog(ctx context.Context, name string, query string, ta
3636

3737
return string(rawData), nil
3838
}
39+
40+
func (k *Kubernetes) NodesStatsSummary(ctx context.Context, name string) (string, error) {
41+
// Use the node proxy API to access stats summary from the kubelet
42+
// This endpoint provides CPU, memory, filesystem, and network statistics
43+
44+
req, err := k.AccessControlClientset().NodesStatsSummary(ctx, name)
45+
if err != nil {
46+
return "", err
47+
}
48+
49+
result := req.Do(ctx)
50+
if result.Error() != nil {
51+
return "", fmt.Errorf("failed to get node stats summary: %w", result.Error())
52+
}
53+
54+
rawData, err := result.Raw()
55+
if err != nil {
56+
return "", fmt.Errorf("failed to read node stats summary response: %w", err)
57+
}
58+
59+
return string(rawData), nil
60+
}

pkg/mcp/common_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -443,9 +443,9 @@ func (s *BaseMcpSuite) TearDownTest() {
443443
}
444444
}
445445

446-
func (s *BaseMcpSuite) InitMcpClient() {
446+
func (s *BaseMcpSuite) InitMcpClient(options ...transport.StreamableHTTPCOption) {
447447
var err error
448448
s.mcpServer, err = NewServer(Configuration{StaticConfig: s.Cfg})
449449
s.Require().NoError(err, "Expected no error creating MCP server")
450-
s.McpClient = test.NewMcpClient(s.T(), s.mcpServer.ServeHTTP(nil))
450+
s.McpClient = test.NewMcpClient(s.T(), s.mcpServer.ServeHTTP(nil), options...)
451451
}

pkg/mcp/mcp_test.go

Lines changed: 50 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ import (
1010
"time"
1111

1212
"github.com/containers/kubernetes-mcp-server/internal/test"
13-
"github.com/mark3labs/mcp-go/client"
13+
"github.com/mark3labs/mcp-go/client/transport"
1414
"github.com/mark3labs/mcp-go/mcp"
15+
"github.com/stretchr/testify/suite"
1516
)
1617

1718
func TestWatchKubeConfig(t *testing.T) {
@@ -48,16 +49,19 @@ func TestWatchKubeConfig(t *testing.T) {
4849
})
4950
}
5051

51-
func TestSseHeaders(t *testing.T) {
52-
mockServer := test.NewMockServer()
53-
defer mockServer.Close()
54-
before := func(c *mcpContext) {
55-
c.withKubeConfig(mockServer.Config())
56-
c.clientOptions = append(c.clientOptions, client.WithHeaders(map[string]string{"kubernetes-authorization": "Bearer a-token-from-mcp-client"}))
57-
}
58-
pathHeaders := make(map[string]http.Header, 0)
59-
mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
60-
pathHeaders[req.URL.Path] = req.Header.Clone()
52+
type McpHeadersSuite struct {
53+
BaseMcpSuite
54+
mockServer *test.MockServer
55+
pathHeaders map[string]http.Header
56+
}
57+
58+
func (s *McpHeadersSuite) SetupTest() {
59+
s.BaseMcpSuite.SetupTest()
60+
s.mockServer = test.NewMockServer()
61+
s.Cfg.KubeConfig = s.mockServer.KubeconfigFile(s.T())
62+
s.pathHeaders = make(map[string]http.Header)
63+
s.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
64+
s.pathHeaders[req.URL.Path] = req.Header.Clone()
6165
// Request Performed by DiscoveryClient to Kube API (Get API Groups legacy -core-)
6266
if req.URL.Path == "/api" {
6367
w.Header().Set("Content-Type", "application/json")
@@ -90,38 +94,42 @@ func TestSseHeaders(t *testing.T) {
9094
}
9195
w.WriteHeader(404)
9296
}))
93-
testCaseWithContext(t, &mcpContext{before: before}, func(c *mcpContext) {
94-
_, _ = c.callTool("pods_list", map[string]interface{}{})
95-
t.Run("DiscoveryClient propagates headers to Kube API", func(t *testing.T) {
96-
if len(pathHeaders) == 0 {
97-
t.Fatalf("No requests were made to Kube API")
98-
}
99-
if pathHeaders["/api"] == nil || pathHeaders["/api"].Get("Authorization") != "Bearer a-token-from-mcp-client" {
100-
t.Fatalf("Overridden header Authorization not found in request to /api")
101-
}
102-
if pathHeaders["/apis"] == nil || pathHeaders["/apis"].Get("Authorization") != "Bearer a-token-from-mcp-client" {
103-
t.Fatalf("Overridden header Authorization not found in request to /apis")
104-
}
105-
if pathHeaders["/api/v1"] == nil || pathHeaders["/api/v1"].Get("Authorization") != "Bearer a-token-from-mcp-client" {
106-
t.Fatalf("Overridden header Authorization not found in request to /api/v1")
107-
}
97+
}
98+
99+
func (s *McpHeadersSuite) TearDownTest() {
100+
s.BaseMcpSuite.TearDownTest()
101+
if s.mockServer != nil {
102+
s.mockServer.Close()
103+
}
104+
}
105+
106+
func (s *McpHeadersSuite) TestAuthorizationHeaderPropagation() {
107+
cases := []string{"kubernetes-authorization", "Authorization"}
108+
for _, header := range cases {
109+
s.InitMcpClient(transport.WithHTTPHeaders(map[string]string{header: "Bearer a-token-from-mcp-client"}))
110+
_, _ = s.CallTool("pods_list", map[string]interface{}{})
111+
s.Require().Greater(len(s.pathHeaders), 0, "No requests were made to Kube API")
112+
s.Run("DiscoveryClient propagates "+header+" header to Kube API", func() {
113+
s.Require().NotNil(s.pathHeaders["/api"], "No requests were made to /api")
114+
s.Equal("Bearer a-token-from-mcp-client", s.pathHeaders["/api"].Get("Authorization"), "Overridden header Authorization not found in request to /api")
115+
s.Require().NotNil(s.pathHeaders["/apis"], "No requests were made to /apis")
116+
s.Equal("Bearer a-token-from-mcp-client", s.pathHeaders["/apis"].Get("Authorization"), "Overridden header Authorization not found in request to /apis")
117+
s.Require().NotNil(s.pathHeaders["/api/v1"], "No requests were made to /api/v1")
118+
s.Equal("Bearer a-token-from-mcp-client", s.pathHeaders["/api/v1"].Get("Authorization"), "Overridden header Authorization not found in request to /api/v1")
108119
})
109-
t.Run("DynamicClient propagates headers to Kube API", func(t *testing.T) {
110-
if len(pathHeaders) == 0 {
111-
t.Fatalf("No requests were made to Kube API")
112-
}
113-
if pathHeaders["/api/v1/namespaces/default/pods"] == nil || pathHeaders["/api/v1/namespaces/default/pods"].Get("Authorization") != "Bearer a-token-from-mcp-client" {
114-
t.Fatalf("Overridden header Authorization not found in request to /api/v1/namespaces/default/pods")
115-
}
120+
s.Run("DynamicClient propagates "+header+" header to Kube API", func() {
121+
s.Require().NotNil(s.pathHeaders["/api/v1/namespaces/default/pods"], "No requests were made to /api/v1/namespaces/default/pods")
122+
s.Equal("Bearer a-token-from-mcp-client", s.pathHeaders["/api/v1/namespaces/default/pods"].Get("Authorization"), "Overridden header Authorization not found in request to /api/v1/namespaces/default/pods")
116123
})
117-
_, _ = c.callTool("pods_delete", map[string]interface{}{"name": "a-pod-to-delete"})
118-
t.Run("kubernetes.Interface propagates headers to Kube API", func(t *testing.T) {
119-
if len(pathHeaders) == 0 {
120-
t.Fatalf("No requests were made to Kube API")
121-
}
122-
if pathHeaders["/api/v1/namespaces/default/pods/a-pod-to-delete"] == nil || pathHeaders["/api/v1/namespaces/default/pods/a-pod-to-delete"].Get("Authorization") != "Bearer a-token-from-mcp-client" {
123-
t.Fatalf("Overridden header Authorization not found in request to /api/v1/namespaces/default/pods/a-pod-to-delete")
124-
}
124+
_, _ = s.CallTool("pods_delete", map[string]interface{}{"name": "a-pod-to-delete"})
125+
s.Run("kubernetes.Interface propagates "+header+" header to Kube API", func() {
126+
s.Require().NotNil(s.pathHeaders["/api/v1/namespaces/default/pods/a-pod-to-delete"], "No requests were made to /api/v1/namespaces/default/pods/a-pod-to-delete")
127+
s.Equal("Bearer a-token-from-mcp-client", s.pathHeaders["/api/v1/namespaces/default/pods/a-pod-to-delete"].Get("Authorization"), "Overridden header Authorization not found in request to /api/v1/namespaces/default/pods/a-pod-to-delete")
125128
})
126-
})
129+
130+
}
131+
}
132+
133+
func TestMcpHeaders(t *testing.T) {
134+
suite.Run(t, new(McpHeadersSuite))
127135
}

pkg/mcp/nodes_test.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,115 @@ func (s *NodesSuite) TestNodesLogDenied() {
222222
})
223223
}
224224

225+
func (s *NodesSuite) TestNodesStatsSummary() {
226+
s.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
227+
// Get Node response
228+
if req.URL.Path == "/api/v1/nodes/existing-node" {
229+
w.Header().Set("Content-Type", "application/json")
230+
w.WriteHeader(http.StatusOK)
231+
_, _ = w.Write([]byte(`{
232+
"apiVersion": "v1",
233+
"kind": "Node",
234+
"metadata": {
235+
"name": "existing-node"
236+
}
237+
}`))
238+
return
239+
}
240+
// Get Stats Summary response
241+
if req.URL.Path == "/api/v1/nodes/existing-node/proxy/stats/summary" {
242+
w.Header().Set("Content-Type", "application/json")
243+
w.WriteHeader(http.StatusOK)
244+
_, _ = w.Write([]byte(`{
245+
"node": {
246+
"nodeName": "existing-node",
247+
"cpu": {
248+
"time": "2025-10-27T00:00:00Z",
249+
"usageNanoCores": 1000000000,
250+
"usageCoreNanoSeconds": 5000000000
251+
},
252+
"memory": {
253+
"time": "2025-10-27T00:00:00Z",
254+
"availableBytes": 8000000000,
255+
"usageBytes": 4000000000,
256+
"workingSetBytes": 3500000000
257+
}
258+
},
259+
"pods": []
260+
}`))
261+
return
262+
}
263+
w.WriteHeader(http.StatusNotFound)
264+
}))
265+
s.InitMcpClient()
266+
s.Run("nodes_stats_summary(name=nil)", func() {
267+
toolResult, err := s.CallTool("nodes_stats_summary", map[string]interface{}{})
268+
s.Require().NotNil(toolResult, "toolResult should not be nil")
269+
s.Run("has error", func() {
270+
s.Truef(toolResult.IsError, "call tool should fail")
271+
s.Nilf(err, "call tool should not return error object")
272+
})
273+
s.Run("describes missing name", func() {
274+
expectedMessage := "failed to get node stats summary, missing argument name"
275+
s.Equalf(expectedMessage, toolResult.Content[0].(mcp.TextContent).Text,
276+
"expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text)
277+
})
278+
})
279+
s.Run("nodes_stats_summary(name=inexistent-node)", func() {
280+
toolResult, err := s.CallTool("nodes_stats_summary", map[string]interface{}{
281+
"name": "inexistent-node",
282+
})
283+
s.Require().NotNil(toolResult, "toolResult should not be nil")
284+
s.Run("has error", func() {
285+
s.Truef(toolResult.IsError, "call tool should fail")
286+
s.Nilf(err, "call tool should not return error object")
287+
})
288+
s.Run("describes missing node", func() {
289+
expectedMessage := "failed to get node stats summary for inexistent-node: failed to get node inexistent-node: the server could not find the requested resource (get nodes inexistent-node)"
290+
s.Equalf(expectedMessage, toolResult.Content[0].(mcp.TextContent).Text,
291+
"expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text)
292+
})
293+
})
294+
s.Run("nodes_stats_summary(name=existing-node)", func() {
295+
toolResult, err := s.CallTool("nodes_stats_summary", map[string]interface{}{
296+
"name": "existing-node",
297+
})
298+
s.Require().NotNil(toolResult, "toolResult should not be nil")
299+
s.Run("no error", func() {
300+
s.Falsef(toolResult.IsError, "call tool should succeed")
301+
s.Nilf(err, "call tool should not return error object")
302+
})
303+
s.Run("returns stats summary", func() {
304+
content := toolResult.Content[0].(mcp.TextContent).Text
305+
s.Containsf(content, "existing-node", "expected stats to contain node name, got %v", content)
306+
s.Containsf(content, "usageNanoCores", "expected stats to contain CPU metrics, got %v", content)
307+
s.Containsf(content, "usageBytes", "expected stats to contain memory metrics, got %v", content)
308+
})
309+
})
310+
}
311+
312+
func (s *NodesSuite) TestNodesStatsSummaryDenied() {
313+
s.Require().NoError(toml.Unmarshal([]byte(`
314+
denied_resources = [ { version = "v1", kind = "Node" } ]
315+
`), s.Cfg), "Expected to parse denied resources config")
316+
s.InitMcpClient()
317+
s.Run("nodes_stats_summary (denied)", func() {
318+
toolResult, err := s.CallTool("nodes_stats_summary", map[string]interface{}{
319+
"name": "does-not-matter",
320+
})
321+
s.Require().NotNil(toolResult, "toolResult should not be nil")
322+
s.Run("has error", func() {
323+
s.Truef(toolResult.IsError, "call tool should fail")
324+
s.Nilf(err, "call tool should not return error object")
325+
})
326+
s.Run("describes denial", func() {
327+
expectedMessage := "failed to get node stats summary for does-not-matter: resource not allowed: /v1, Kind=Node"
328+
s.Equalf(expectedMessage, toolResult.Content[0].(mcp.TextContent).Text,
329+
"expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text)
330+
})
331+
})
332+
}
333+
225334
func TestNodes(t *testing.T) {
226335
suite.Run(t, new(NodesSuite))
227336
}

pkg/mcp/testdata/toolsets-core-tools.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,29 @@
6767
},
6868
"name": "nodes_log"
6969
},
70+
{
71+
"annotations": {
72+
"title": "Node: Stats Summary",
73+
"readOnlyHint": true,
74+
"destructiveHint": false,
75+
"idempotentHint": false,
76+
"openWorldHint": true
77+
},
78+
"description": "Get detailed resource usage statistics from a Kubernetes node via the kubelet's Summary API. Provides comprehensive metrics including CPU, memory, filesystem, and network usage at the node, pod, and container levels. On systems with cgroup v2 and kernel 4.20+, also includes PSI (Pressure Stall Information) metrics that show resource pressure for CPU, memory, and I/O. See https://kubernetes.io/docs/reference/instrumentation/understand-psi-metrics/ for details on PSI metrics",
79+
"inputSchema": {
80+
"type": "object",
81+
"properties": {
82+
"name": {
83+
"description": "Name of the node to get stats from",
84+
"type": "string"
85+
}
86+
},
87+
"required": [
88+
"name"
89+
]
90+
},
91+
"name": "nodes_stats_summary"
92+
},
7093
{
7194
"annotations": {
7295
"title": "Pods: Delete",

pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,37 @@
237237
},
238238
"name": "nodes_log"
239239
},
240+
{
241+
"annotations": {
242+
"title": "Node: Stats Summary",
243+
"readOnlyHint": true,
244+
"destructiveHint": false,
245+
"idempotentHint": false,
246+
"openWorldHint": true
247+
},
248+
"description": "Get detailed resource usage statistics from a Kubernetes node via the kubelet's Summary API. Provides comprehensive metrics including CPU, memory, filesystem, and network usage at the node, pod, and container levels. On systems with cgroup v2 and kernel 4.20+, also includes PSI (Pressure Stall Information) metrics that show resource pressure for CPU, memory, and I/O. See https://kubernetes.io/docs/reference/instrumentation/understand-psi-metrics/ for details on PSI metrics",
249+
"inputSchema": {
250+
"type": "object",
251+
"properties": {
252+
"context": {
253+
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
254+
"enum": [
255+
"extra-cluster",
256+
"fake-context"
257+
],
258+
"type": "string"
259+
},
260+
"name": {
261+
"description": "Name of the node to get stats from",
262+
"type": "string"
263+
}
264+
},
265+
"required": [
266+
"name"
267+
]
268+
},
269+
"name": "nodes_stats_summary"
270+
},
240271
{
241272
"annotations": {
242273
"title": "Pods: Delete",

0 commit comments

Comments
 (0)