Skip to content
Merged
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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,11 @@ In case multi-cluster support is enabled (default) and you have access to multip

- **projects_list** - List all the OpenShift projects in the current cluster

- **nodes_log** - Get logs from a Kubernetes node (kubelet, kube-proxy, or other system logs). This accesses node logs through the Kubernetes API proxy to the kubelet
- `name` (`string`) **(required)** - Name of the node to get logs from
- `query` (`string`) **(required)** - query specifies services(s) or files from which to return logs (required). Example: "kubelet" to fetch kubelet logs, "/<log-file-name>" to fetch a specific log file from the node (e.g., "/var/log/kubelet.log" or "/var/log/kube-proxy.log")
- `tailLines` (`integer`) - Number of lines to retrieve from the end of the logs (Optional, 0 means all logs)

- **pods_list** - List all the Kubernetes pods in the current cluster from all namespaces
- `labelSelector` (`string`) - Optional Kubernetes label selector (e.g. 'app=myapp,env=prod' or 'app in (myapp,yourapp)'), use this option when you want to filter the pods by label

Expand Down
4 changes: 2 additions & 2 deletions pkg/kubernetes/accesscontrol_clientset.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ func (a *AccessControlClientset) DiscoveryClient() discovery.DiscoveryInterface
return a.discoveryClient
}

func (a *AccessControlClientset) NodesLogs(ctx context.Context, name, logPath string) (*rest.Request, error) {
func (a *AccessControlClientset) NodesLogs(ctx context.Context, name string) (*rest.Request, error) {
gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Node"}
if !isAllowed(a.staticConfig, gvk) {
return nil, isNotAllowedError(gvk)
Expand All @@ -49,7 +49,7 @@ func (a *AccessControlClientset) NodesLogs(ctx context.Context, name, logPath st
return nil, fmt.Errorf("failed to get node %s: %w", name, err)
}

url := []string{"api", "v1", "nodes", name, "proxy", "logs", logPath}
url := []string{"api", "v1", "nodes", name, "proxy", "logs"}
return a.delegate.CoreV1().RESTClient().
Get().
AbsPath(url...), nil
Expand Down
10 changes: 6 additions & 4 deletions pkg/kubernetes/nodes.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,23 @@ import (
"fmt"
)

func (k *Kubernetes) NodesLog(ctx context.Context, name string, logPath string, tail int64) (string, error) {
func (k *Kubernetes) NodesLog(ctx context.Context, name string, query string, tailLines int64) (string, error) {
// Use the node proxy API to access logs from the kubelet
// https://kubernetes.io/docs/concepts/cluster-administration/system-logs/#log-query
// Common log paths:
// - /var/log/kubelet.log - kubelet logs
// - /var/log/kube-proxy.log - kube-proxy logs
// - /var/log/containers/ - container logs

req, err := k.AccessControlClientset().NodesLogs(ctx, name, logPath)
req, err := k.AccessControlClientset().NodesLogs(ctx, name)
if err != nil {
return "", err
}

req.Param("query", query)
// Query parameters for tail
if tail > 0 {
req.Param("tailLines", fmt.Sprintf("%d", tail))
if tailLines > 0 {
req.Param("tailLines", fmt.Sprintf("%d", tailLines))
}

result := req.Do(ctx)
Expand Down
86 changes: 54 additions & 32 deletions pkg/mcp/nodes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package mcp

import (
"net/http"
"strconv"
"testing"

"github.com/BurntSushi/toml"
Expand Down Expand Up @@ -43,21 +44,25 @@ func (s *NodesSuite) TestNodesLog() {
}`))
return
}
// Get Empty Log response
if req.URL.Path == "/api/v1/nodes/existing-node/proxy/logs/empty.log" {
// Get Proxy Logs
if req.URL.Path == "/api/v1/nodes/existing-node/proxy/logs" {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(``))
return
}
// Get Kubelet Log response
if req.URL.Path == "/api/v1/nodes/existing-node/proxy/logs/kubelet.log" {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
logContent := "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n"
if req.URL.Query().Get("tailLines") != "" {
query := req.URL.Query().Get("query")
var logContent string
switch query {
case "/empty.log":
logContent = ""
case "/kubelet.log":
logContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n"
default:
w.WriteHeader(http.StatusNotFound)
return
}
_, err := strconv.Atoi(req.URL.Query().Get("tailLines"))
if err == nil {
logContent = "Line 4\nLine 5\n"
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(logContent))
return
}
Expand All @@ -77,9 +82,25 @@ func (s *NodesSuite) TestNodesLog() {
"expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text)
})
})
s.Run("nodes_log(name=inexistent-node)", func() {
s.Run("nodes_log(name=existing-node, query=nil)", func() {
toolResult, err := s.CallTool("nodes_log", map[string]interface{}{
"name": "existing-node",
})
s.Require().NotNil(toolResult, "toolResult should not be nil")
s.Run("has error", func() {
s.Truef(toolResult.IsError, "call tool should fail")
s.Nilf(err, "call tool should not return error object")
})
s.Run("describes missing name", func() {
expectedMessage := "failed to get node log, missing argument query"
s.Equalf(expectedMessage, toolResult.Content[0].(mcp.TextContent).Text,
"expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text)
})
})
s.Run("nodes_log(name=inexistent-node, query=/kubelet.log)", func() {
toolResult, err := s.CallTool("nodes_log", map[string]interface{}{
"name": "inexistent-node",
"name": "inexistent-node",
"query": "/kubelet.log",
})
s.Require().NotNil(toolResult, "toolResult should not be nil")
s.Run("has error", func() {
Expand All @@ -92,10 +113,10 @@ func (s *NodesSuite) TestNodesLog() {
"expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text)
})
})
s.Run("nodes_log(name=existing-node, log_path=missing.log)", func() {
s.Run("nodes_log(name=existing-node, query=/missing.log)", func() {
toolResult, err := s.CallTool("nodes_log", map[string]interface{}{
"name": "existing-node",
"log_path": "missing.log",
"name": "existing-node",
"query": "/missing.log",
})
s.Require().NotNil(toolResult, "toolResult should not be nil")
s.Run("has error", func() {
Expand All @@ -108,10 +129,10 @@ func (s *NodesSuite) TestNodesLog() {
"expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text)
})
})
s.Run("nodes_log(name=existing-node, log_path=empty.log)", func() {
s.Run("nodes_log(name=existing-node, query=/empty.log)", func() {
toolResult, err := s.CallTool("nodes_log", map[string]interface{}{
"name": "existing-node",
"log_path": "empty.log",
"name": "existing-node",
"query": "/empty.log",
})
s.Require().NotNil(toolResult, "toolResult should not be nil")
s.Run("no error", func() {
Expand All @@ -124,10 +145,10 @@ func (s *NodesSuite) TestNodesLog() {
"expected descriptive message '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text)
})
})
s.Run("nodes_log(name=existing-node, log_path=kubelet.log)", func() {
s.Run("nodes_log(name=existing-node, query=/kubelet.log)", func() {
toolResult, err := s.CallTool("nodes_log", map[string]interface{}{
"name": "existing-node",
"log_path": "kubelet.log",
"name": "existing-node",
"query": "/kubelet.log",
})
s.Require().NotNil(toolResult, "toolResult should not be nil")
s.Run("no error", func() {
Expand All @@ -141,11 +162,11 @@ func (s *NodesSuite) TestNodesLog() {
})
})
for _, tailCase := range []interface{}{2, int64(2), float64(2)} {
s.Run("nodes_log(name=existing-node, log_path=kubelet.log, tail=2)", func() {
s.Run("nodes_log(name=existing-node, query=/kubelet.log, tailLines=2)", func() {
toolResult, err := s.CallTool("nodes_log", map[string]interface{}{
"name": "existing-node",
"log_path": "kubelet.log",
"tail": tailCase,
"name": "existing-node",
"query": "/kubelet.log",
"tailLines": tailCase,
})
s.Require().NotNil(toolResult, "toolResult should not be nil")
s.Run("no error", func() {
Expand All @@ -158,11 +179,11 @@ func (s *NodesSuite) TestNodesLog() {
"expected log content '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text)
})
})
s.Run("nodes_log(name=existing-node, log_path=kubelet.log, tail=-1)", func() {
s.Run("nodes_log(name=existing-node, query=/kubelet.log, tailLines=-1)", func() {
toolResult, err := s.CallTool("nodes_log", map[string]interface{}{
"name": "existing-node",
"log_path": "kubelet.log",
"tail": -1,
"name": "existing-node",
"query": "/kubelet.log",
"tail": -1,
})
s.Require().NotNil(toolResult, "toolResult should not be nil")
s.Run("no error", func() {
Expand All @@ -185,7 +206,8 @@ func (s *NodesSuite) TestNodesLogDenied() {
s.InitMcpClient()
s.Run("nodes_log (denied)", func() {
toolResult, err := s.CallTool("nodes_log", map[string]interface{}{
"name": "does-not-matter",
"name": "does-not-matter",
"query": "/does-not-matter-either.log",
})
s.Require().NotNil(toolResult, "toolResult should not be nil")
s.Run("has error", func() {
Expand Down
14 changes: 7 additions & 7 deletions pkg/mcp/testdata/toolsets-core-tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,24 +45,24 @@
"inputSchema": {
"type": "object",
"properties": {
"log_path": {
"default": "kubelet.log",
"description": "Path to the log file on the node (e.g. 'kubelet.log', 'kube-proxy.log'). Default is 'kubelet.log'",
"type": "string"
},
"name": {
"description": "Name of the node to get logs from",
"type": "string"
},
"tail": {
"query": {
"description": "query specifies services(s) or files from which to return logs (required). Example: \"kubelet\" to fetch kubelet logs, \"/\u003clog-file-name\u003e\" to fetch a specific log file from the node (e.g., \"/var/log/kubelet.log\" or \"/var/log/kube-proxy.log\")",
"type": "string"
},
"tailLines": {
"default": 100,
"description": "Number of lines to retrieve from the end of the logs (Optional, 0 means all logs)",
"minimum": 0,
"type": "integer"
}
},
"required": [
"name"
"name",
"query"
]
},
"name": "nodes_log"
Expand Down
14 changes: 7 additions & 7 deletions pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json
Original file line number Diff line number Diff line change
Expand Up @@ -215,24 +215,24 @@
],
"type": "string"
},
"log_path": {
"default": "kubelet.log",
"description": "Path to the log file on the node (e.g. 'kubelet.log', 'kube-proxy.log'). Default is 'kubelet.log'",
"type": "string"
},
"name": {
"description": "Name of the node to get logs from",
"type": "string"
},
"tail": {
"query": {
"description": "query specifies services(s) or files from which to return logs (required). Example: \"kubelet\" to fetch kubelet logs, \"/\u003clog-file-name\u003e\" to fetch a specific log file from the node (e.g., \"/var/log/kubelet.log\" or \"/var/log/kube-proxy.log\")",
"type": "string"
},
"tailLines": {
"default": 100,
"description": "Number of lines to retrieve from the end of the logs (Optional, 0 means all logs)",
"minimum": 0,
"type": "integer"
}
},
"required": [
"name"
"name",
"query"
]
},
"name": "nodes_log"
Expand Down
14 changes: 7 additions & 7 deletions pkg/mcp/testdata/toolsets-full-tools-multicluster.json
Original file line number Diff line number Diff line change
Expand Up @@ -191,24 +191,24 @@
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
"type": "string"
},
"log_path": {
"default": "kubelet.log",
"description": "Path to the log file on the node (e.g. 'kubelet.log', 'kube-proxy.log'). Default is 'kubelet.log'",
"type": "string"
},
"name": {
"description": "Name of the node to get logs from",
"type": "string"
},
"tail": {
"query": {
"description": "query specifies services(s) or files from which to return logs (required). Example: \"kubelet\" to fetch kubelet logs, \"/\u003clog-file-name\u003e\" to fetch a specific log file from the node (e.g., \"/var/log/kubelet.log\" or \"/var/log/kube-proxy.log\")",
"type": "string"
},
"tailLines": {
"default": 100,
"description": "Number of lines to retrieve from the end of the logs (Optional, 0 means all logs)",
"minimum": 0,
"type": "integer"
}
},
"required": [
"name"
"name",
"query"
]
},
"name": "nodes_log"
Expand Down
14 changes: 7 additions & 7 deletions pkg/mcp/testdata/toolsets-full-tools-openshift.json
Original file line number Diff line number Diff line change
Expand Up @@ -151,24 +151,24 @@
"inputSchema": {
"type": "object",
"properties": {
"log_path": {
"default": "kubelet.log",
"description": "Path to the log file on the node (e.g. 'kubelet.log', 'kube-proxy.log'). Default is 'kubelet.log'",
"type": "string"
},
"name": {
"description": "Name of the node to get logs from",
"type": "string"
},
"tail": {
"query": {
"description": "query specifies services(s) or files from which to return logs (required). Example: \"kubelet\" to fetch kubelet logs, \"/\u003clog-file-name\u003e\" to fetch a specific log file from the node (e.g., \"/var/log/kubelet.log\" or \"/var/log/kube-proxy.log\")",
"type": "string"
},
"tailLines": {
"default": 100,
"description": "Number of lines to retrieve from the end of the logs (Optional, 0 means all logs)",
"minimum": 0,
"type": "integer"
}
},
"required": [
"name"
"name",
"query"
]
},
"name": "nodes_log"
Expand Down
14 changes: 7 additions & 7 deletions pkg/mcp/testdata/toolsets-full-tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -151,24 +151,24 @@
"inputSchema": {
"type": "object",
"properties": {
"log_path": {
"default": "kubelet.log",
"description": "Path to the log file on the node (e.g. 'kubelet.log', 'kube-proxy.log'). Default is 'kubelet.log'",
"type": "string"
},
"name": {
"description": "Name of the node to get logs from",
"type": "string"
},
"tail": {
"query": {
"description": "query specifies services(s) or files from which to return logs (required). Example: \"kubelet\" to fetch kubelet logs, \"/\u003clog-file-name\u003e\" to fetch a specific log file from the node (e.g., \"/var/log/kubelet.log\" or \"/var/log/kube-proxy.log\")",
"type": "string"
},
"tailLines": {
"default": 100,
"description": "Number of lines to retrieve from the end of the logs (Optional, 0 means all logs)",
"minimum": 0,
"type": "integer"
}
},
"required": [
"name"
"name",
"query"
]
},
"name": "nodes_log"
Expand Down
Loading
Loading