Skip to content

Commit 1a9e505

Browse files
committed
Add sosreport MCP tools
Implements MCP tools for analyzing sosreports: - sos-list-plugins: Lists enabled sosreport plugins with their command counts - sos-list-commands: Returns all commands executed by a specific plugin - sos-search-commands: Searches for commands matching a pattern across all plugins - sos-get-command: Retrieves command output from sosreport with optional regex filtering - sos-search-pod-logs: Searches Kubernetes pod container logs with pattern matching Assisted-By: Claude Sonnet 4.5 Signed-off-by: Patryk Diak <pdiak@redhat.com>
1 parent e63bf6c commit 1a9e505

File tree

18 files changed

+1457
-1
lines changed

18 files changed

+1457
-1
lines changed

cmd/ovnk-mcp-server/main.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212

1313
mcp "github.com/modelcontextprotocol/go-sdk/mcp"
1414
kubernetesmcp "github.com/ovn-kubernetes/ovn-kubernetes-mcp/pkg/kubernetes/mcp"
15+
sosreportmcp "github.com/ovn-kubernetes/ovn-kubernetes-mcp/pkg/sosreport/mcp"
1516
)
1617

1718
type MCPServerConfig struct {
@@ -34,9 +35,14 @@ func main() {
3435
if err != nil {
3536
log.Fatalf("Failed to create OVN-K MCP server: %v", err)
3637
}
37-
log.Println("Adding tools to OVN-K MCP server")
38+
log.Println("Adding Kubernetes tools to OVN-K MCP server")
3839
k8sMcpServer.AddTools(ovnkMcpServer)
3940
}
41+
if serverCfg.Mode == "offline" {
42+
sosreportServer := sosreportmcp.NewMCPServer()
43+
log.Println("Adding sosreport tools to OVN-K MCP server")
44+
sosreportServer.AddTools(ovnkMcpServer)
45+
}
4046

4147
// Create a context that can be cancelled to shutdown the server.
4248
ctx, cancel := context.WithCancel(context.Background())

pkg/sosreport/mcp/commands.go

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
package sosreport
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"regexp"
8+
"github.com/ovn-kubernetes/ovn-kubernetes-mcp/pkg/sosreport/types"
9+
)
10+
11+
const (
12+
// defaultResultLimit is the default maximum number of lines/results to return
13+
defaultResultLimit = 100
14+
)
15+
16+
// getCommandOutput reads a command output file by filepath from manifest
17+
func getCommandOutput(sosreportPath, relativeFilepath, pattern string, maxLines int) (string, error) {
18+
if err := validateSosreportPath(sosreportPath); err != nil {
19+
return "", err
20+
}
21+
22+
if err := validateRelativePath(relativeFilepath); err != nil {
23+
return "", err
24+
}
25+
26+
fullPath := filepath.Join(sosreportPath, relativeFilepath)
27+
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
28+
return "", fmt.Errorf("command output file not found: %s", relativeFilepath)
29+
}
30+
31+
file, err := os.Open(fullPath)
32+
if err != nil {
33+
return "", fmt.Errorf("failed to open file: %w", err)
34+
}
35+
defer file.Close()
36+
37+
var searchPattern *regexp.Regexp
38+
if pattern != "" {
39+
searchPattern, err = regexp.Compile(pattern)
40+
if err != nil {
41+
return "", fmt.Errorf("invalid pattern: %w", err)
42+
}
43+
}
44+
45+
if maxLines == 0 {
46+
maxLines = defaultResultLimit
47+
}
48+
49+
output, err := readWithLimit(file, searchPattern, maxLines)
50+
if err != nil {
51+
return "", err
52+
}
53+
54+
if output == "" && pattern != "" {
55+
return fmt.Sprintf("No lines matching pattern %q found\n", pattern), nil
56+
}
57+
58+
return output, nil
59+
}
60+
61+
// listPlugins returns a list of enabled plugins with their command counts
62+
func listPlugins(sosreportPath string) (types.ListPluginsResult, error) {
63+
manifest, err := loadManifest(sosreportPath)
64+
if err != nil {
65+
return types.ListPluginsResult{}, err
66+
}
67+
68+
var result types.ListPluginsResult
69+
totalCommands := 0
70+
71+
// Only show enabled plugins
72+
for pluginName, plugin := range manifest.Components.Report.Plugins {
73+
commandCount := len(plugin.Commands)
74+
totalCommands += commandCount
75+
76+
result.Plugins = append(result.Plugins, types.PluginSummary{
77+
Name: pluginName,
78+
CommandCount: commandCount,
79+
})
80+
}
81+
82+
result.TotalCommands = totalCommands
83+
return result, nil
84+
}
85+
86+
// listCommands returns all commands for a specific plugin
87+
func listCommands(sosreportPath, pluginName string) (types.ListCommandsResult, error) {
88+
manifest, err := loadManifest(sosreportPath)
89+
if err != nil {
90+
return types.ListCommandsResult{}, err
91+
}
92+
93+
plugin, exists := manifest.Components.Report.Plugins[pluginName]
94+
if !exists {
95+
return types.ListCommandsResult{}, fmt.Errorf("plugin %q not found in manifest", pluginName)
96+
}
97+
98+
result := types.ListCommandsResult{
99+
Plugin: pluginName,
100+
CommandCount: len(plugin.Commands),
101+
}
102+
103+
for _, cmd := range plugin.Commands {
104+
result.Commands = append(result.Commands, types.CommandSummary{
105+
Exec: cmd.Exec,
106+
Filepath: cmd.Filepath,
107+
})
108+
}
109+
110+
return result, nil
111+
}
112+
113+
// searchCommands searches for commands matching a pattern across all plugins
114+
func searchCommands(sosreportPath, pattern string, maxResults int) (types.SearchCommandsResult, error) {
115+
manifest, err := loadManifest(sosreportPath)
116+
if err != nil {
117+
return types.SearchCommandsResult{}, err
118+
}
119+
120+
searchPattern, err := regexp.Compile(pattern)
121+
if err != nil {
122+
return types.SearchCommandsResult{}, fmt.Errorf("invalid search pattern: %w", err)
123+
}
124+
125+
var result types.SearchCommandsResult
126+
if maxResults == 0 {
127+
maxResults = defaultResultLimit
128+
}
129+
130+
for pluginName, plugin := range manifest.Components.Report.Plugins {
131+
for _, cmd := range plugin.Commands {
132+
if searchPattern.MatchString(cmd.Exec) || searchPattern.MatchString(cmd.Filepath) {
133+
result.Matches = append(result.Matches, types.CommandMatch{
134+
Plugin: pluginName,
135+
Exec: cmd.Exec,
136+
Filepath: cmd.Filepath,
137+
})
138+
139+
if len(result.Matches) >= maxResults {
140+
result.Total = len(result.Matches)
141+
return result, nil
142+
}
143+
}
144+
}
145+
}
146+
147+
result.Total = len(result.Matches)
148+
return result, nil
149+
}

pkg/sosreport/mcp/commands_test.go

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package sosreport
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
const sosreportTestData = "testdata/sosreport"
9+
10+
func TestGetCommandOutput(t *testing.T) {
11+
tests := []struct {
12+
name string
13+
sosreport string
14+
filepath string
15+
pattern string
16+
maxLines int
17+
wantError bool
18+
errorMsg string
19+
wantContains string
20+
wantMinLines int
21+
wantMaxLines int
22+
}{
23+
{
24+
name: "get ovs-vsctl output without pattern",
25+
sosreport: sosreportTestData,
26+
filepath: "sos_commands/openvswitch/ovs-vsctl_-t_5_show",
27+
pattern: "",
28+
maxLines: 0,
29+
wantError: false,
30+
wantContains: "Bridge br-int",
31+
wantMinLines: 1,
32+
},
33+
{
34+
name: "get ovs-ofctl output without pattern",
35+
sosreport: sosreportTestData,
36+
filepath: "sos_commands/openvswitch/ovs-ofctl_dump-flows_br-int",
37+
pattern: "",
38+
maxLines: 0,
39+
wantError: false,
40+
wantContains: "cookie=0x0",
41+
wantMinLines: 1,
42+
},
43+
{
44+
name: "get ip addr show output",
45+
sosreport: sosreportTestData,
46+
filepath: "sos_commands/networking/ip_addr_show",
47+
pattern: "",
48+
maxLines: 0,
49+
wantError: false,
50+
wantContains: "lo:",
51+
wantMinLines: 1,
52+
},
53+
{
54+
name: "filter with pattern no matches",
55+
sosreport: sosreportTestData,
56+
filepath: "sos_commands/networking/ip_addr_show",
57+
pattern: "NOTFOUND",
58+
maxLines: 0,
59+
wantError: false,
60+
wantContains: "No lines matching pattern",
61+
wantMinLines: 1,
62+
},
63+
{
64+
name: "limit max lines",
65+
sosreport: sosreportTestData,
66+
filepath: "sos_commands/networking/ip_addr_show",
67+
pattern: "",
68+
maxLines: 2,
69+
wantError: false,
70+
wantContains: "output truncated",
71+
wantMaxLines: 2,
72+
},
73+
{
74+
name: "non-existent file",
75+
sosreport: sosreportTestData,
76+
filepath: "sos_commands/non-existent-file",
77+
pattern: "",
78+
maxLines: 0,
79+
wantError: true,
80+
errorMsg: "command output file not found",
81+
},
82+
{
83+
name: "invalid sosreport path",
84+
sosreport: "testdata/non-existent",
85+
filepath: "sos_commands/openvswitch/ovs-vsctl_-t_5_show",
86+
pattern: "",
87+
maxLines: 0,
88+
wantError: true,
89+
errorMsg: "sosreport path does not exist",
90+
},
91+
{
92+
name: "invalid regex pattern",
93+
sosreport: sosreportTestData,
94+
filepath: "sos_commands/networking/ip_addr_show",
95+
pattern: "[invalid(",
96+
maxLines: 0,
97+
wantError: true,
98+
errorMsg: "invalid pattern",
99+
},
100+
}
101+
102+
for _, tt := range tests {
103+
t.Run(tt.name, func(t *testing.T) {
104+
output, err := getCommandOutput(tt.sosreport, tt.filepath, tt.pattern, tt.maxLines)
105+
if tt.wantError {
106+
if err == nil {
107+
t.Errorf("getCommandOutput() expected error but got nil")
108+
} else if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) {
109+
t.Errorf("getCommandOutput() error = %v, want error containing %q", err, tt.errorMsg)
110+
}
111+
return
112+
}
113+
if err != nil {
114+
t.Errorf("getCommandOutput() unexpected error = %v", err)
115+
return
116+
}
117+
118+
// Check for expected content
119+
if tt.wantContains != "" && !strings.Contains(output, tt.wantContains) {
120+
t.Errorf("getCommandOutput() output does not contain %q, got:\n%s", tt.wantContains, output)
121+
}
122+
123+
// Check line count if specified (excluding truncation message and empty lines)
124+
if tt.wantMinLines > 0 || tt.wantMaxLines > 0 {
125+
lines := strings.Split(output, "\n")
126+
lineCount := 0
127+
for _, line := range lines {
128+
if line != "" && !strings.Contains(line, "output truncated") && !strings.HasPrefix(line, "...") {
129+
lineCount++
130+
}
131+
}
132+
133+
if tt.wantMinLines > 0 && lineCount < tt.wantMinLines {
134+
t.Errorf("getCommandOutput() got %d lines, want at least %d", lineCount, tt.wantMinLines)
135+
}
136+
137+
if tt.wantMaxLines > 0 && lineCount > tt.wantMaxLines {
138+
t.Errorf("getCommandOutput() got %d lines, want at most %d. Output:\n%s", lineCount, tt.wantMaxLines, output)
139+
}
140+
}
141+
})
142+
}
143+
}

0 commit comments

Comments
 (0)