diff --git a/pkg/kubernetes/nodes.go b/pkg/kubernetes/nodes.go new file mode 100644 index 00000000..c6bb58fe --- /dev/null +++ b/pkg/kubernetes/nodes.go @@ -0,0 +1,48 @@ +package kubernetes + +import ( + "context" + "fmt" +) + +func (k *Kubernetes) NodeLog(ctx context.Context, name string, logPath string, tail int64) (string, error) { + // Use the node proxy API to access logs from the kubelet + // Common log paths: + // - /var/log/kubelet.log - kubelet logs + // - /var/log/kube-proxy.log - kube-proxy logs + // - /var/log/containers/ - container logs + + if logPath == "" { + logPath = "kubelet.log" + } + + // Build the URL for the node proxy logs endpoint + url := []string{"api", "v1", "nodes", name, "proxy", "logs", logPath} + + // Query parameters for tail + params := make(map[string]string) + if tail > 0 { + params["tailLines"] = fmt.Sprintf("%d", tail) + } + + req := k.manager.discoveryClient.RESTClient(). + Get(). + AbsPath(url...) + + // Add tail parameter if specified + for key, value := range params { + req.Param(key, value) + } + + result := req.Do(ctx) + if result.Error() != nil { + return "", fmt.Errorf("failed to get node logs: %w", result.Error()) + } + + rawData, err := result.Raw() + if err != nil { + return "", fmt.Errorf("failed to read node log response: %w", err) + } + + return string(rawData), nil +} diff --git a/pkg/mcp/nodes_test.go b/pkg/mcp/nodes_test.go new file mode 100644 index 00000000..b9915778 --- /dev/null +++ b/pkg/mcp/nodes_test.go @@ -0,0 +1,66 @@ +package mcp + +import ( + "strings" + "testing" + + "github.com/mark3labs/mcp-go/mcp" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestNodeLog(t *testing.T) { + testCase(t, func(c *mcpContext) { + c.withEnvTest() + + // Create test node + kubernetesAdmin := c.newKubernetesClient() + node := &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-node-log", + }, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{ + {Type: corev1.NodeInternalIP, Address: "192.168.1.10"}, + }, + }, + } + + _, _ = kubernetesAdmin.CoreV1().Nodes().Create(c.ctx, node, metav1.CreateOptions{}) + + // Test node_log tool + toolResult, err := c.callTool("node_log", map[string]interface{}{ + "name": "test-node-log", + }) + + t.Run("node_log returns successfully or with expected error", func(t *testing.T) { + if err != nil { + t.Fatalf("call tool failed: %v", err) + } + // Node logs might not be available in test environment + // We just check that the tool call completes + if toolResult.IsError { + content := toolResult.Content[0].(mcp.TextContent).Text + // Expected error messages in test environment + if !strings.Contains(content, "failed to get node logs") && + !strings.Contains(content, "not logged any message yet") { + t.Logf("tool returned error (expected in test environment): %v", content) + } + } + }) + }) +} + +func TestNodeLogMissingArguments(t *testing.T) { + testCase(t, func(c *mcpContext) { + c.withEnvTest() + + t.Run("node_log requires name", func(t *testing.T) { + toolResult, err := c.callTool("node_log", map[string]interface{}{}) + + if err == nil && !toolResult.IsError { + t.Fatal("expected error when name is missing") + } + }) + }) +} diff --git a/pkg/mcp/testdata/toolsets-core-tools.json b/pkg/mcp/testdata/toolsets-core-tools.json index 43680dae..5039cf24 100644 --- a/pkg/mcp/testdata/toolsets-core-tools.json +++ b/pkg/mcp/testdata/toolsets-core-tools.json @@ -33,6 +33,40 @@ }, "name": "namespaces_list" }, + { + "annotations": { + "title": "Node: Log", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "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", + "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": { + "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": "node_log" + }, { "annotations": { "title": "Pods: Delete", diff --git a/pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json b/pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json index 97af6fb5..cec61b95 100644 --- a/pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json +++ b/pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json @@ -195,6 +195,48 @@ }, "name": "namespaces_list" }, + { + "annotations": { + "title": "Node: Log", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "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", + "inputSchema": { + "type": "object", + "properties": { + "context": { + "description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set", + "enum": [ + "extra-cluster", + "fake-context" + ], + "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": { + "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": "node_log" + }, { "annotations": { "title": "Pods: Delete", diff --git a/pkg/mcp/testdata/toolsets-full-tools-multicluster.json b/pkg/mcp/testdata/toolsets-full-tools-multicluster.json index 861a1b5a..7f1f91da 100644 --- a/pkg/mcp/testdata/toolsets-full-tools-multicluster.json +++ b/pkg/mcp/testdata/toolsets-full-tools-multicluster.json @@ -175,6 +175,44 @@ }, "name": "namespaces_list" }, + { + "annotations": { + "title": "Node: Log", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "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", + "inputSchema": { + "type": "object", + "properties": { + "context": { + "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": { + "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": "node_log" + }, { "annotations": { "title": "Pods: Delete", diff --git a/pkg/mcp/testdata/toolsets-full-tools-openshift.json b/pkg/mcp/testdata/toolsets-full-tools-openshift.json index b5018945..066eee0b 100644 --- a/pkg/mcp/testdata/toolsets-full-tools-openshift.json +++ b/pkg/mcp/testdata/toolsets-full-tools-openshift.json @@ -139,6 +139,40 @@ }, "name": "namespaces_list" }, + { + "annotations": { + "title": "Node: Log", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "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", + "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": { + "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": "node_log" + }, { "annotations": { "title": "Pods: Delete", diff --git a/pkg/mcp/testdata/toolsets-full-tools.json b/pkg/mcp/testdata/toolsets-full-tools.json index 7b9f471d..17b5e7e2 100644 --- a/pkg/mcp/testdata/toolsets-full-tools.json +++ b/pkg/mcp/testdata/toolsets-full-tools.json @@ -139,6 +139,40 @@ }, "name": "namespaces_list" }, + { + "annotations": { + "title": "Node: Log", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "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", + "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": { + "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": "node_log" + }, { "annotations": { "title": "Pods: Delete", diff --git a/pkg/toolsets/core/nodes.go b/pkg/toolsets/core/nodes.go new file mode 100644 index 00000000..5516dce6 --- /dev/null +++ b/pkg/toolsets/core/nodes.go @@ -0,0 +1,79 @@ +package core + +import ( + "errors" + "fmt" + + "github.com/google/jsonschema-go/jsonschema" + "k8s.io/utils/ptr" + + "github.com/containers/kubernetes-mcp-server/pkg/api" +) + +func initNodes() []api.ServerTool { + return []api.ServerTool{ + {Tool: api.Tool{ + Name: "node_log", + Description: "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", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "name": { + Type: "string", + Description: "Name of the node to get logs from", + }, + "log_path": { + Type: "string", + Description: "Path to the log file on the node (e.g. 'kubelet.log', 'kube-proxy.log'). Default is 'kubelet.log'", + Default: api.ToRawMessage("kubelet.log"), + }, + "tail": { + Type: "integer", + Description: "Number of lines to retrieve from the end of the logs (Optional, 0 means all logs)", + Default: api.ToRawMessage(100), + Minimum: ptr.To(float64(0)), + }, + }, + Required: []string{"name"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Node: Log", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(false), + OpenWorldHint: ptr.To(true), + }, + }, Handler: nodesLog}, + } +} + +func nodesLog(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + name := params.GetArguments()["name"] + if name == nil { + return api.NewToolCallResult("", errors.New("failed to get node log, missing argument name")), nil + } + logPath := params.GetArguments()["log_path"] + if logPath == nil { + logPath = "kubelet.log" + } + tail := params.GetArguments()["tail"] + var tailInt int64 + if tail != nil { + // Convert to int64 - safely handle both float64 (JSON number) and int types + switch v := tail.(type) { + case float64: + tailInt = int64(v) + case int: case int64: + tailInt = int64(v) + default: + return api.NewToolCallResult("", fmt.Errorf("failed to parse tail parameter: expected integer, got %T", tail)), nil + } + } + ret, err := params.NodeLog(params, name.(string), logPath.(string), tailInt) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get node log for %s: %v", name, err)), nil + } else if ret == "" { + ret = fmt.Sprintf("The node %s has not logged any message yet or the log file is empty", name) + } + return api.NewToolCallResult(ret, nil), nil +} diff --git a/pkg/toolsets/core/toolset.go b/pkg/toolsets/core/toolset.go index 9f88c7aa..dfd61f42 100644 --- a/pkg/toolsets/core/toolset.go +++ b/pkg/toolsets/core/toolset.go @@ -24,6 +24,7 @@ func (t *Toolset) GetTools(o internalk8s.Openshift) []api.ServerTool { return slices.Concat( initEvents(), initNamespaces(o), + initNodes(), initPods(), initResources(o), )