Skip to content

Commit 26faced

Browse files
committed
Add kernel MCP tools
Implements MCP tools for inspecting kernel level networking configurations: - get-conntrack: retrieves connection tracking entries from a Kubernetes node. - get-iptables: retrieves iptables/ip6tables rules from a Kubernetes node. - get-nft: retrieves nftables configuration from a Kubernetes node. - get-ip: executes 'ip' utility commands on a node. Signed-off-by: Arnab Ghosh <arnabghosh89@gmail.com>
1 parent de5ea64 commit 26faced

File tree

13 files changed

+1255
-0
lines changed

13 files changed

+1255
-0
lines changed

cmd/ovnk-mcp-server/main.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"syscall"
1212

1313
mcp "github.com/modelcontextprotocol/go-sdk/mcp"
14+
kernelmcp "github.com/ovn-kubernetes/ovn-kubernetes-mcp/pkg/kernel/mcp"
1415
kubernetesmcp "github.com/ovn-kubernetes/ovn-kubernetes-mcp/pkg/kubernetes/mcp"
1516
)
1617

@@ -36,6 +37,10 @@ func main() {
3637
}
3738
log.Println("Adding Kubernetes tools to OVN-K MCP server")
3839
k8sMcpServer.AddTools(ovnkMcpServer)
40+
41+
kernelMcpServer := kernelmcp.NewMCPServer(k8sMcpServer)
42+
log.Println("Adding Kernel tools to OVN-K MCP server")
43+
kernelMcpServer.AddTools(ovnkMcpServer)
3944
}
4045

4146
// Create a context that can be cancelled to shutdown the server.

pkg/kernel/mcp/conntrack.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package kernel
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"strconv"
7+
"strings"
8+
9+
"github.com/modelcontextprotocol/go-sdk/mcp"
10+
"github.com/ovn-kubernetes/ovn-kubernetes-mcp/pkg/kernel/types"
11+
)
12+
13+
const conntrackSystemFile = "/proc/net/nf_conntrack"
14+
15+
// GetConntrack MCP handler for conntrack operations.
16+
// GetConntrack retrieves connection tracking entries from a Kubernetes node.
17+
// Falls back to /proc/net/nf_conntrack parsing when conntrack CLI unavailable.
18+
// TODO: Add support for conntrack event monitoring (-E flag).
19+
// TODO: Add support for -G (get specific entry) command.
20+
func (s *MCPServer) GetConntrack(ctx context.Context, req *mcp.CallToolRequest, in types.ListConntrackParams) (*mcp.CallToolResult, types.Result, error) {
21+
conntrackCliAvailable := s.UtilityExists(ctx, req, in.Node, in.Image, "conntrack")
22+
if err := ValidateConntrackCommand(in.Command, conntrackCliAvailable); err != nil {
23+
return nil, types.Result{}, fmt.Errorf("error while getting list of conntrack entries: %w", err)
24+
}
25+
26+
if !conntrackCliAvailable {
27+
stdout, err := s.getConntrackFromFile(ctx, req, in.Node, in.Image, in.FilterParameters)
28+
if err != nil {
29+
return nil, types.Result{}, fmt.Errorf("error while getting list of conntrack entries: %w", err)
30+
}
31+
return nil, types.Result{Data: stdout}, nil
32+
} else {
33+
stdout, err := s.getConntrackUsingCLI(ctx, req, in.Node, in.Image, in.Command, in.FilterParameters)
34+
if err != nil {
35+
return nil, types.Result{}, fmt.Errorf("error while getting list of conntrack entries: %w", err)
36+
}
37+
return nil, types.Result{Data: stdout}, nil
38+
}
39+
}
40+
41+
// getConntrackUsingCLI executes conntrack CLI commands.
42+
func (s *MCPServer) getConntrackUsingCLI(ctx context.Context, req *mcp.CallToolRequest, node, image, command, filterParameters string) (string, error) {
43+
cmd := newCommand("conntrack")
44+
switch command {
45+
case "-L":
46+
cmd.add("-L")
47+
cmd.add(strings.Fields(filterParameters)...)
48+
case "-S":
49+
cmd.add("-S")
50+
case "-C":
51+
cmd.add("-C")
52+
default:
53+
cmd.add("-L")
54+
cmd.add(strings.Fields(filterParameters)...)
55+
}
56+
return s.executeCommand(ctx, req, node, image, cmd.build())
57+
}
58+
59+
// getConntrackFromFile parses /proc/net/nf_conntrack directly.
60+
// TODO: Add filter support while getting conntrack entries from /proc/net/nf_conntrack.
61+
func (s *MCPServer) getConntrackFromFile(ctx context.Context, req *mcp.CallToolRequest, node, image, filterParameters string) (string, error) {
62+
cmd := newCommand("cat")
63+
cmd.add(conntrackSystemFile)
64+
return s.executeCommand(ctx, req, node, image, cmd.build())
65+
}
66+
67+
// ValidateConntrackCommand validates the command to be used to get list of conntrack entries.
68+
// It returns an error if conntrack CLI is not available and any other operation than list is being performed.
69+
func ValidateConntrackCommand(command string, cliAvailable bool) error {
70+
if command == "" {
71+
return nil
72+
}
73+
if !cliAvailable && strings.TrimSpace(command) != "-L" {
74+
return fmt.Errorf("mentioned image does not have conntrack utility, only -L is supported with limited filters")
75+
}
76+
if _, err := strconv.Atoi(command); err == nil {
77+
return fmt.Errorf("invalid command: %s", command)
78+
}
79+
validTables := map[string]bool{
80+
"-L": true,
81+
"-S": true,
82+
"-C": true,
83+
"--dump": true,
84+
"--stats": true,
85+
"--count": true,
86+
}
87+
88+
if !validTables[strings.TrimSpace(command)] {
89+
return fmt.Errorf("invalid command: %s", command)
90+
}
91+
return nil
92+
}

pkg/kernel/mcp/conntrack_test.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package kernel
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestValidateConntrackCommand(t *testing.T) {
8+
tests := []struct {
9+
name string
10+
command string
11+
cliAvailable bool
12+
wantError bool
13+
}{
14+
{
15+
name: "empty string with CLI available",
16+
command: "",
17+
cliAvailable: true,
18+
wantError: false,
19+
},
20+
{
21+
name: "empty string without CLI",
22+
command: "",
23+
cliAvailable: false,
24+
wantError: false,
25+
},
26+
{
27+
name: "numeric string with CLI available",
28+
command: "321",
29+
cliAvailable: true,
30+
wantError: true,
31+
},
32+
{
33+
name: "numeric string without CLI",
34+
command: "321",
35+
cliAvailable: false,
36+
wantError: true,
37+
},
38+
{
39+
name: "valid command -L with CLI available",
40+
command: "-L",
41+
cliAvailable: true,
42+
wantError: false,
43+
},
44+
{
45+
name: "valid command -L without CLI",
46+
command: "-L",
47+
cliAvailable: false,
48+
wantError: false,
49+
},
50+
{
51+
name: "valid command -S with CLI available",
52+
command: "-S",
53+
cliAvailable: true,
54+
wantError: false,
55+
},
56+
{
57+
name: "valid command -S without CLI",
58+
command: "-S",
59+
cliAvailable: false,
60+
wantError: true,
61+
},
62+
{
63+
name: "valid command -C with CLI available",
64+
command: "-C",
65+
cliAvailable: true,
66+
wantError: false,
67+
},
68+
{
69+
name: "valid command -C without CLI",
70+
command: "-C",
71+
cliAvailable: false,
72+
wantError: true,
73+
},
74+
{
75+
name: "invalid command with CLI available",
76+
command: "-E",
77+
cliAvailable: true,
78+
wantError: true,
79+
},
80+
{
81+
name: "invalid command without CLI",
82+
command: "-E",
83+
cliAvailable: false,
84+
wantError: true,
85+
},
86+
{
87+
name: "invalid command -D with CLI available",
88+
command: "-D",
89+
cliAvailable: true,
90+
wantError: true,
91+
},
92+
{
93+
name: "lowercase -l with CLI available",
94+
command: "-l",
95+
cliAvailable: true,
96+
wantError: true,
97+
},
98+
{
99+
name: "command with spaces",
100+
command: "-L ",
101+
cliAvailable: true,
102+
wantError: false,
103+
},
104+
}
105+
106+
for _, tt := range tests {
107+
t.Run(tt.name, func(t *testing.T) {
108+
err := ValidateConntrackCommand(tt.command, tt.cliAvailable)
109+
if (err != nil) != tt.wantError {
110+
t.Errorf("ValidateConntrackCommand(%q, %v) error = %v, wantError %v", tt.command, tt.cliAvailable, err, tt.wantError)
111+
}
112+
})
113+
}
114+
}

pkg/kernel/mcp/ip.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package kernel
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"strconv"
7+
"strings"
8+
9+
"github.com/modelcontextprotocol/go-sdk/mcp"
10+
"github.com/ovn-kubernetes/ovn-kubernetes-mcp/pkg/kernel/types"
11+
)
12+
13+
// GetIPCommandOutput MCP handler for ip utility operations.
14+
// GetIPCommandOutput executes 'ip' utility commands on a node.
15+
// Requires ip utility in the debug container image.
16+
func (s *MCPServer) GetIPCommandOutput(ctx context.Context, req *mcp.CallToolRequest, in types.ListIPParams) (*mcp.CallToolResult, types.Result, error) {
17+
ipCliAvailable := s.UtilityExists(ctx, req, in.Node, in.Image, "ip")
18+
if !ipCliAvailable {
19+
return nil, types.Result{}, fmt.Errorf("error while getting ip data: mentioned image does not have ip utility")
20+
}
21+
22+
if err := ValidateIPCommand(in.Command); err != nil {
23+
return nil, types.Result{}, fmt.Errorf("error while getting ip data: %w", err)
24+
}
25+
26+
cmd := newCommand("ip")
27+
cmd.addIfNotEmpty(in.Options, in.Options)
28+
cmd.add(strings.Fields(in.Command)...)
29+
cmd.addIfNotEmpty(in.FilterParameters, strings.Fields(in.FilterParameters)...)
30+
31+
stdout, err := s.executeCommand(ctx, req, in.Node, in.Image, cmd.build())
32+
if err != nil {
33+
return nil, types.Result{}, fmt.Errorf("error while getting ip data: %w", err)
34+
}
35+
return nil, types.Result{Data: stdout}, nil
36+
}
37+
38+
// ValidateIPCommand validates that the IP command is allowed.
39+
func ValidateIPCommand(ipCommand string) error {
40+
if _, err := strconv.Atoi(ipCommand); err == nil {
41+
return fmt.Errorf("invalid ip command: %s", ipCommand)
42+
}
43+
validIpCommand := map[string]bool{
44+
"address show": true,
45+
"link show": true,
46+
"neighbour show": true,
47+
"netns show": true,
48+
"route show": true,
49+
"rule show": true,
50+
"vrf show": true,
51+
}
52+
53+
if !validIpCommand[strings.TrimSpace(ipCommand)] {
54+
return fmt.Errorf("invalid ip command: %s", ipCommand)
55+
}
56+
return nil
57+
}

pkg/kernel/mcp/ip_test.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package kernel
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestValidateIPCommand(t *testing.T) {
8+
tests := []struct {
9+
name string
10+
ipCommand string
11+
wantError bool
12+
}{
13+
{
14+
name: "empty string",
15+
ipCommand: "",
16+
wantError: true,
17+
},
18+
{
19+
name: "numeric string",
20+
ipCommand: "999",
21+
wantError: true,
22+
},
23+
{
24+
name: "valid command - address show",
25+
ipCommand: "address show",
26+
wantError: false,
27+
},
28+
{
29+
name: "valid command - link show",
30+
ipCommand: "link show",
31+
wantError: false,
32+
},
33+
{
34+
name: "valid command - neighbour show",
35+
ipCommand: "neighbour show",
36+
wantError: false,
37+
},
38+
{
39+
name: "valid command - netns show",
40+
ipCommand: "netns show",
41+
wantError: false,
42+
},
43+
{
44+
name: "valid command - route show",
45+
ipCommand: "route show",
46+
wantError: false,
47+
},
48+
{
49+
name: "valid command - rule show",
50+
ipCommand: "rule show",
51+
wantError: false,
52+
},
53+
{
54+
name: "valid command - vrf show",
55+
ipCommand: "vrf show",
56+
wantError: false,
57+
},
58+
{
59+
name: "invalid command - addr show",
60+
ipCommand: "addr show",
61+
wantError: true,
62+
},
63+
{
64+
name: "invalid command - neighbor show",
65+
ipCommand: "neighbor show",
66+
wantError: true,
67+
},
68+
{
69+
name: "invalid command - address add",
70+
ipCommand: "address add",
71+
wantError: true,
72+
},
73+
{
74+
name: "invalid command - link set",
75+
ipCommand: "link set",
76+
wantError: true,
77+
},
78+
{
79+
name: "invalid command - route add",
80+
ipCommand: "route add",
81+
wantError: true,
82+
},
83+
{
84+
name: "invalid command - just address",
85+
ipCommand: "address",
86+
wantError: true,
87+
},
88+
{
89+
name: "invalid command - uppercase",
90+
ipCommand: "ADDRESS SHOW",
91+
wantError: true,
92+
},
93+
{
94+
name: "invalid command - extra spaces",
95+
ipCommand: "address show",
96+
wantError: true,
97+
},
98+
{
99+
name: "valid command - with trailing space",
100+
ipCommand: "address show ",
101+
wantError: false,
102+
},
103+
}
104+
105+
for _, tt := range tests {
106+
t.Run(tt.name, func(t *testing.T) {
107+
err := ValidateIPCommand(tt.ipCommand)
108+
if (err != nil) != tt.wantError {
109+
t.Errorf("ValidateIPCommand(%q) error = %v, wantError %v", tt.ipCommand, err, tt.wantError)
110+
}
111+
})
112+
}
113+
}

0 commit comments

Comments
 (0)