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
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,14 @@ For connecting to the MCP server, the following steps are required:
make build
```

The server supports 2 operating modes:
- `live-cluster` (default): Connect to a live Kubernetes cluster for real-time debugging
- `offline`: Offline troubleshooting without requiring cluster access

The server currently supports 2 transport modes: `stdio` and `http`.

### Live Cluster Mode

For `stdio` mode, the server can be run and connected to by using the following configuration in an MCP host (Cursor, Claude, etc.):

```json
Expand All @@ -27,7 +33,7 @@ For `stdio` mode, the server can be run and connected to by using the following
}
```

For `http` mode, the server should be started separately.
For `http` mode, the server should be started separately:

```shell
./PATH-TO-THE-LOCAL-GIT-REPO/_output/ovnk-mcp-server --transport http --kubeconfig /PATH-TO-THE-KUBECONFIG-FILE
Expand All @@ -44,3 +50,26 @@ The following configuration should be used in an MCP host (Cursor, Claude, etc.)
}
}
```

### Offline Mode

For offline troubleshooting (e.g., analyzing sosreports), use `--mode offline`:

For `stdio` mode:

```json
{
"mcpServers": {
"ovn-kubernetes": {
"command": "/PATH-TO-THE-LOCAL-GIT-REPO/_output/ovnk-mcp-server",
"args": ["--mode", "offline"]
}
}
}
```

For `http` mode:

```shell
./PATH-TO-THE-LOCAL-GIT-REPO/_output/ovnk-mcp-server --transport http --mode offline
```
6 changes: 6 additions & 0 deletions cmd/ovnk-mcp-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

mcp "github.com/modelcontextprotocol/go-sdk/mcp"
kubernetesmcp "github.com/ovn-kubernetes/ovn-kubernetes-mcp/pkg/kubernetes/mcp"
sosreportmcp "github.com/ovn-kubernetes/ovn-kubernetes-mcp/pkg/sosreport/mcp"
)

type MCPServerConfig struct {
Expand All @@ -37,6 +38,11 @@ func main() {
log.Println("Adding Kubernetes tools to OVN-K MCP server")
k8sMcpServer.AddTools(ovnkMcpServer)
}
if serverCfg.Mode == "offline" {
sosreportServer := sosreportmcp.NewMCPServer()
log.Println("Adding sosreport tools to OVN-K MCP server")
sosreportServer.AddTools(ovnkMcpServer)
}

// Create a context that can be cancelled to shutdown the server.
ctx, cancel := context.WithCancel(context.Background())
Expand Down
149 changes: 149 additions & 0 deletions pkg/sosreport/mcp/commands.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package sosreport

import (
"fmt"
"os"
"path/filepath"
"regexp"
"github.com/ovn-kubernetes/ovn-kubernetes-mcp/pkg/sosreport/types"
)

const (
// defaultResultLimit is the default maximum number of lines/results to return
defaultResultLimit = 100
)

// getCommandOutput reads a command output file by filepath from manifest
func getCommandOutput(sosreportPath, relativeFilepath, pattern string, maxLines int) (string, error) {
if err := validateSosreportPath(sosreportPath); err != nil {
return "", err
}

if err := validateRelativePath(relativeFilepath); err != nil {
return "", err
}

fullPath := filepath.Join(sosreportPath, relativeFilepath)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The relative path should be validated so that it doesn't contain patterns like ../, ../../, etc. for traversing file path outside of the sosreport path.

if _, err := os.Stat(fullPath); os.IsNotExist(err) {
return "", fmt.Errorf("command output file not found: %s", relativeFilepath)
}

file, err := os.Open(fullPath)
if err != nil {
return "", fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()

var searchPattern *regexp.Regexp
if pattern != "" {
searchPattern, err = regexp.Compile(pattern)
if err != nil {
return "", fmt.Errorf("invalid pattern: %w", err)
}
}

if maxLines <= 0 {
maxLines = defaultResultLimit
}

output, err := readWithLimit(file, searchPattern, maxLines)
if err != nil {
return "", err
}

if output == "" && pattern != "" {
return fmt.Sprintf("No lines matching pattern %q found\n", pattern), nil
}

return output, nil
}

// listPlugins returns a list of enabled plugins with their command counts
func listPlugins(sosreportPath string) (types.ListPluginsResult, error) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice plugin aggregation for the collected logs from sos report's manifest.json file !
what about the remaining logs collected outside of sos_commands directory.
For example need to analyze ovs-vswitchd and ovsdb-server.log logs from /var/log/openvswitch directory.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see a rason to do that as they are already there as part of sos_commands: sos_commands/openvswitch/:

journalctl_--no-pager_--unit_openvswitch
journalctl_--no-pager_--unit_openvswitch-ipsec
journalctl_--no-pager_--unit_openvswitch-nonetwork
journalctl_--no-pager_--unit_ovs-configuration
journalctl_--no-pager_--unit_ovsdb-server
journalctl_--no-pager_--unit_ovs-vswitchd

manifest, err := loadManifest(sosreportPath)
if err != nil {
return types.ListPluginsResult{}, err
}

var result types.ListPluginsResult
totalCommands := 0

// Only show enabled plugins
for pluginName, plugin := range manifest.Components.Report.Plugins {
commandCount := len(plugin.Commands)
totalCommands += commandCount

result.Plugins = append(result.Plugins, types.PluginSummary{
Name: pluginName,
CommandCount: commandCount,
})
}

result.TotalCommands = totalCommands
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just for my understanding: what is the reason for exposing CommandCount and TotalCommands via MCP tooling ? is that really needed ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it hurts, it can help the model to narrow down the search criteria if there are too many results.

return result, nil
}

// listCommands returns all commands for a specific plugin
func listCommands(sosreportPath, pluginName string) (types.ListCommandsResult, error) {
manifest, err := loadManifest(sosreportPath)
if err != nil {
return types.ListCommandsResult{}, err
}

plugin, exists := manifest.Components.Report.Plugins[pluginName]
if !exists {
return types.ListCommandsResult{}, fmt.Errorf("plugin %q not found in manifest", pluginName)
}

result := types.ListCommandsResult{
Plugin: pluginName,
CommandCount: len(plugin.Commands),
}

for _, cmd := range plugin.Commands {
result.Commands = append(result.Commands, types.CommandSummary{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

every sosreport tools return the output with content and structuredContent which are the same contents. is this specific design? for example

{
  "content": [
    {
      "type": "text",
      "text": "{\"output\":\" cookie=0x304, duration=13656.724s, table=0, n_packets=0, n_bytes=0, idle_age=13656, priority=700,icmp,in_port=2,nw_dst=10.0.0.4,icmp_type=3,icmp_code=4 actions=drop\\n
...
    }
  ],
  "structuredContent": {
    "output": " cookie=0x304, duration=13656.724s, table=0, n_packets=0, n_bytes=0, idle_age=13656, priority=700,icmp,in_port=2,nw_dst=10.0.0.4,icmp_type=3,icmp_code=4 actions=drop\n
...
  }

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great observation!
Is this specific to the sosreport though? I don't think I am doing anything different and I would just brush it as mcp-sdk quirk.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right, it's not specific to sosreport. It should be mcp-sdk designation^^

Exec: cmd.Exec,
Filepath: cmd.Filepath,
})
}

return result, nil
}

// searchCommands searches for commands matching a pattern across all plugins
func searchCommands(sosreportPath, pattern string, maxResults int) (types.SearchCommandsResult, error) {
manifest, err := loadManifest(sosreportPath)
if err != nil {
return types.SearchCommandsResult{}, err
}

searchPattern, err := regexp.Compile(pattern)
if err != nil {
return types.SearchCommandsResult{}, fmt.Errorf("invalid search pattern: %w", err)
}

var result types.SearchCommandsResult
if maxResults <= 0 {
maxResults = defaultResultLimit
}

for pluginName, plugin := range manifest.Components.Report.Plugins {
for _, cmd := range plugin.Commands {
if searchPattern.MatchString(cmd.Exec) || searchPattern.MatchString(cmd.Filepath) {
result.Matches = append(result.Matches, types.CommandMatch{
Plugin: pluginName,
Exec: cmd.Exec,
Filepath: cmd.Filepath,
})

if len(result.Matches) >= maxResults {
result.Total = len(result.Matches)
return result, nil
}
}
}
}

result.Total = len(result.Matches)
return result, nil
}
Loading
Loading