diff --git a/pkg/mcp/nodes_test.go b/pkg/mcp/nodes_test.go index c0135de6..aa0135d9 100644 --- a/pkg/mcp/nodes_test.go +++ b/pkg/mcp/nodes_test.go @@ -199,6 +199,186 @@ func (s *NodesSuite) TestNodesLog() { } } +func (s *NodesSuite) TestNodesLogWithArrayQuery() { + s.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + // Get Node response + if req.URL.Path == "/api/v1/nodes/existing-node" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{ + "apiVersion": "v1", + "kind": "Node", + "metadata": { + "name": "existing-node" + } + }`)) + return + } + // Get Proxy Logs + if req.URL.Path == "/api/v1/nodes/existing-node/proxy/logs" { + w.Header().Set("Content-Type", "text/plain") + query := req.URL.Query().Get("query") + var logContent string + switch query { + case "/kubelet.log": + logContent = "Kubelet log line 1\nKubelet log line 2\n" + case "/kube-proxy.log": + logContent = "Kube-proxy log line 1\nKube-proxy log line 2\n" + case "/empty.log": + logContent = "" + default: + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(logContent)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + s.InitMcpClient() + + s.Run("nodes_log(name=existing-node, query=[/kubelet.log, /kube-proxy.log])", func() { + toolResult, err := s.CallTool("nodes_log", map[string]interface{}{ + "name": "existing-node", + "query": []interface{}{"/kubelet.log", "/kube-proxy.log"}, + }) + s.Require().NotNil(toolResult, "toolResult should not be nil") + s.Run("no error", func() { + s.Falsef(toolResult.IsError, "call tool should succeed") + s.Nilf(err, "call tool should not return error object") + }) + s.Run("returns logs from both files with separators", func() { + result := toolResult.Content[0].(mcp.TextContent).Text + s.Contains(result, "=== Logs for query: /kubelet.log ===", "should contain kubelet log header") + s.Contains(result, "Kubelet log line 1\nKubelet log line 2\n", "should contain kubelet log content") + s.Contains(result, "=== Logs for query: /kube-proxy.log ===", "should contain kube-proxy log header") + s.Contains(result, "Kube-proxy log line 1\nKube-proxy log line 2\n", "should contain kube-proxy log content") + }) + }) + + s.Run("nodes_log(name=existing-node, query=[])", func() { + toolResult, err := s.CallTool("nodes_log", map[string]interface{}{ + "name": "existing-node", + "query": []interface{}{}, + }) + 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 empty array", func() { + expectedMessage := "failed to get node log, query array cannot be empty" + 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=existing-node, query=[''])", func() { + toolResult, err := s.CallTool("nodes_log", map[string]interface{}{ + "name": "existing-node", + "query": []interface{}{""}, + }) + 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 empty string in array", func() { + expectedMessage := "failed to get node log, query array element 0 cannot be empty" + 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=existing-node, query=[123])", func() { + toolResult, err := s.CallTool("nodes_log", map[string]interface{}{ + "name": "existing-node", + "query": []interface{}{123}, + }) + 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 non-string element in array", func() { + expectedMessage := "failed to get node log, query array element 0 is not a string" + 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=existing-node, query=[/kubelet.log, /empty.log])", func() { + toolResult, err := s.CallTool("nodes_log", map[string]interface{}{ + "name": "existing-node", + "query": []interface{}{"/kubelet.log", "/empty.log"}, + }) + s.Require().NotNil(toolResult, "toolResult should not be nil") + s.Run("no error", func() { + s.Falsef(toolResult.IsError, "call tool should succeed") + s.Nilf(err, "call tool should not return error object") + }) + s.Run("handles empty log in array", func() { + result := toolResult.Content[0].(mcp.TextContent).Text + s.Contains(result, "=== Logs for query: /kubelet.log ===", "should contain kubelet log header") + s.Contains(result, "Kubelet log line 1\nKubelet log line 2\n", "should contain kubelet log content") + s.Contains(result, "=== Logs for query: /empty.log ===", "should contain empty log header") + s.Contains(result, "The node existing-node has not logged any message yet for query '/empty.log' or the log file is empty", "should contain empty log message") + }) + }) + + s.Run("nodes_log(name=existing-node, query=[/missing.log])", func() { + toolResult, err := s.CallTool("nodes_log", map[string]interface{}{ + "name": "existing-node", + "query": []interface{}{"/missing.log"}, + }) + 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 log file", func() { + expectedMessage := "failed to get node log for existing-node: failed to get node logs: the server could not find the requested resource" + 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=existing-node, query=[/missing1.log, /missing2.log])", func() { + toolResult, err := s.CallTool("nodes_log", map[string]interface{}{ + "name": "existing-node", + "query": []interface{}{"/missing1.log", "/missing2.log"}, + }) + 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 log file with query when multiple queries", func() { + expectedMessage := "failed to get node log for existing-node (query: /missing1.log): failed to get node logs: the server could not find the requested resource" + 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=existing-node, query='')", func() { + toolResult, err := s.CallTool("nodes_log", map[string]interface{}{ + "name": "existing-node", + "query": "", + }) + 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 empty string query", func() { + expectedMessage := "failed to get node log, query cannot be empty" + s.Equalf(expectedMessage, toolResult.Content[0].(mcp.TextContent).Text, + "expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text) + }) + }) +} + func (s *NodesSuite) TestNodesLogDenied() { s.Require().NoError(toml.Unmarshal([]byte(` denied_resources = [ { version = "v1", kind = "Node" } ] diff --git a/pkg/mcp/testdata/toolsets-core-tools.json b/pkg/mcp/testdata/toolsets-core-tools.json index fc5e86b7..10ae0985 100644 --- a/pkg/mcp/testdata/toolsets-core-tools.json +++ b/pkg/mcp/testdata/toolsets-core-tools.json @@ -50,8 +50,20 @@ "type": "string" }, "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" + "description": "query specifies service(s) or files from which to return logs (required). Can be a single string or array of strings. Example: \"kubelet\" to fetch kubelet logs, \"/\u003clog-file-name\u003e\" to fetch a specific log file from the node (e.g., \"kubelet.log\" or \"kube-proxy.log\"), or [\"kubelet\", \"kube-proxy.log\"] for multiple sources", + "oneOf": [ + { + "description": "Single query specifying a service or file from which to return logs. Example: \"kubelet\" or \"kubelet.log\"", + "type": "string" + }, + { + "description": "Array of queries specifying multiple services or files from which to return logs", + "items": { + "type": "string" + }, + "type": "array" + } + ] }, "tailLines": { "default": 100, diff --git a/pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json b/pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json index 83b32139..7dfb51c2 100644 --- a/pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json +++ b/pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json @@ -220,8 +220,20 @@ "type": "string" }, "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" + "description": "query specifies service(s) or files from which to return logs (required). Can be a single string or array of strings. Example: \"kubelet\" to fetch kubelet logs, \"/\u003clog-file-name\u003e\" to fetch a specific log file from the node (e.g., \"kubelet.log\" or \"kube-proxy.log\"), or [\"kubelet\", \"kube-proxy.log\"] for multiple sources", + "oneOf": [ + { + "description": "Single query specifying a service or file from which to return logs. Example: \"kubelet\" or \"kubelet.log\"", + "type": "string" + }, + { + "description": "Array of queries specifying multiple services or files from which to return logs", + "items": { + "type": "string" + }, + "type": "array" + } + ] }, "tailLines": { "default": 100, diff --git a/pkg/mcp/testdata/toolsets-full-tools-multicluster.json b/pkg/mcp/testdata/toolsets-full-tools-multicluster.json index a7cdeb47..d6e80d58 100644 --- a/pkg/mcp/testdata/toolsets-full-tools-multicluster.json +++ b/pkg/mcp/testdata/toolsets-full-tools-multicluster.json @@ -196,8 +196,20 @@ "type": "string" }, "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" + "description": "query specifies service(s) or files from which to return logs (required). Can be a single string or array of strings. Example: \"kubelet\" to fetch kubelet logs, \"/\u003clog-file-name\u003e\" to fetch a specific log file from the node (e.g., \"kubelet.log\" or \"kube-proxy.log\"), or [\"kubelet\", \"kube-proxy.log\"] for multiple sources", + "oneOf": [ + { + "description": "Single query specifying a service or file from which to return logs. Example: \"kubelet\" or \"kubelet.log\"", + "type": "string" + }, + { + "description": "Array of queries specifying multiple services or files from which to return logs", + "items": { + "type": "string" + }, + "type": "array" + } + ] }, "tailLines": { "default": 100, diff --git a/pkg/mcp/testdata/toolsets-full-tools-openshift.json b/pkg/mcp/testdata/toolsets-full-tools-openshift.json index 3bba52d2..f7fcbbe2 100644 --- a/pkg/mcp/testdata/toolsets-full-tools-openshift.json +++ b/pkg/mcp/testdata/toolsets-full-tools-openshift.json @@ -156,8 +156,20 @@ "type": "string" }, "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" + "description": "query specifies service(s) or files from which to return logs (required). Can be a single string or array of strings. Example: \"kubelet\" to fetch kubelet logs, \"/\u003clog-file-name\u003e\" to fetch a specific log file from the node (e.g., \"kubelet.log\" or \"kube-proxy.log\"), or [\"kubelet\", \"kube-proxy.log\"] for multiple sources", + "oneOf": [ + { + "description": "Single query specifying a service or file from which to return logs. Example: \"kubelet\" or \"kubelet.log\"", + "type": "string" + }, + { + "description": "Array of queries specifying multiple services or files from which to return logs", + "items": { + "type": "string" + }, + "type": "array" + } + ] }, "tailLines": { "default": 100, diff --git a/pkg/mcp/testdata/toolsets-full-tools.json b/pkg/mcp/testdata/toolsets-full-tools.json index d9d6b91e..a3e5045e 100644 --- a/pkg/mcp/testdata/toolsets-full-tools.json +++ b/pkg/mcp/testdata/toolsets-full-tools.json @@ -156,8 +156,20 @@ "type": "string" }, "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" + "description": "query specifies service(s) or files from which to return logs (required). Can be a single string or array of strings. Example: \"kubelet\" to fetch kubelet logs, \"/\u003clog-file-name\u003e\" to fetch a specific log file from the node (e.g., \"kubelet.log\" or \"kube-proxy.log\"), or [\"kubelet\", \"kube-proxy.log\"] for multiple sources", + "oneOf": [ + { + "description": "Single query specifying a service or file from which to return logs. Example: \"kubelet\" or \"kubelet.log\"", + "type": "string" + }, + { + "description": "Array of queries specifying multiple services or files from which to return logs", + "items": { + "type": "string" + }, + "type": "array" + } + ] }, "tailLines": { "default": 100, diff --git a/pkg/toolsets/core/nodes.go b/pkg/toolsets/core/nodes.go index c9e84032..8829bd32 100644 --- a/pkg/toolsets/core/nodes.go +++ b/pkg/toolsets/core/nodes.go @@ -23,8 +23,20 @@ func initNodes() []api.ServerTool { Description: "Name of the node to get logs from", }, "query": { - Type: "string", - Description: `query specifies services(s) or files from which to return logs (required). Example: "kubelet" to fetch kubelet logs, "/" to fetch a specific log file from the node (e.g., "/var/log/kubelet.log" or "/var/log/kube-proxy.log")`, + OneOf: []*jsonschema.Schema{ + { + Type: "string", + Description: `Single query specifying a service or file from which to return logs. Example: "kubelet" or "kubelet.log"`, + }, + { + Type: "array", + Description: `Array of queries specifying multiple services or files from which to return logs`, + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + }, + Description: `query specifies service(s) or files from which to return logs (required). Can be a single string or array of strings. Example: "kubelet" to fetch kubelet logs, "/" to fetch a specific log file from the node (e.g., "kubelet.log" or "kube-proxy.log"), or ["kubelet", "kube-proxy.log"] for multiple sources`, }, "tailLines": { Type: "integer", @@ -51,10 +63,38 @@ func nodesLog(params api.ToolHandlerParams) (*api.ToolCallResult, error) { if !ok || name == "" { return api.NewToolCallResult("", errors.New("failed to get node log, missing argument name")), nil } - query, ok := params.GetArguments()["query"].(string) - if !ok || query == "" { + + // Handle query parameter - can be string or array of strings + var queries []string + queryArg := params.GetArguments()["query"] + if queryArg == nil { return api.NewToolCallResult("", errors.New("failed to get node log, missing argument query")), nil } + + switch v := queryArg.(type) { + case string: + if v == "" { + return api.NewToolCallResult("", errors.New("failed to get node log, query cannot be empty")), nil + } + queries = []string{v} + case []interface{}: + if len(v) == 0 { + return api.NewToolCallResult("", errors.New("failed to get node log, query array cannot be empty")), nil + } + for i, item := range v { + str, ok := item.(string) + if !ok { + return api.NewToolCallResult("", fmt.Errorf("failed to get node log, query array element %d is not a string", i)), nil + } + if str == "" { + return api.NewToolCallResult("", fmt.Errorf("failed to get node log, query array element %d cannot be empty", i)), nil + } + queries = append(queries, str) + } + default: + return api.NewToolCallResult("", fmt.Errorf("failed to get node log, query must be a string or array of strings, got %T", queryArg)), nil + } + tailLines := params.GetArguments()["tailLines"] var tailInt int64 if tailLines != nil { @@ -69,11 +109,41 @@ func nodesLog(params api.ToolHandlerParams) (*api.ToolCallResult, error) { return api.NewToolCallResult("", fmt.Errorf("failed to parse tail parameter: expected integer, got %T", tailLines)), nil } } - ret, err := params.NodesLog(params, name, query, 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) + + // Fetch logs for each query and concatenate results + var results string + for i, query := range queries { + ret, err := params.NodesLog(params, name, query, tailInt) + if err != nil { + // Only include query in error message if there are multiple queries + if len(queries) > 1 { + return api.NewToolCallResult("", fmt.Errorf("failed to get node log for %s (query: %s): %v", name, query, err)), nil + } + return api.NewToolCallResult("", fmt.Errorf("failed to get node log for %s: %v", name, err)), nil + } + + if len(queries) > 1 { + // Add separator between multiple queries + if i > 0 { + results += "\n\n" + } + results += fmt.Sprintf("=== Logs for query: %s ===\n", query) + } + + if ret == "" { + if len(queries) > 1 { + results += fmt.Sprintf("The node %s has not logged any message yet for query '%s' or the log file is empty\n", name, query) + } else { + results = fmt.Sprintf("The node %s has not logged any message yet or the log file is empty", name) + } + } else { + results += ret + } + } + + if results == "" { + results = fmt.Sprintf("The node %s has not logged any message yet or the log file is empty", name) } - return api.NewToolCallResult(ret, nil), nil + + return api.NewToolCallResult(results, nil), nil }