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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ require (
github.com/go-logr/logr v1.4.3
github.com/google/gnostic-models v0.7.1
github.com/google/jsonschema-go v0.4.2
github.com/miekg/dns v1.1.57
github.com/modelcontextprotocol/go-sdk v1.4.1
github.com/prometheus/client_golang v1.23.2
github.com/spf13/afero v1.15.0
Expand Down Expand Up @@ -139,11 +140,13 @@ require (
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/term v0.40.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/time v0.12.0 // indirect
golang.org/x/tools v0.41.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
google.golang.org/grpc v1.79.2 // indirect
Expand Down
142 changes: 142 additions & 0 deletions pkg/toolsets/netedge/probe_dns_local.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package netedge

import (
"encoding/json"
"fmt"
"net"
"strings"
"time"

"github.com/containers/kubernetes-mcp-server/pkg/api"
"github.com/google/jsonschema-go/jsonschema"
"github.com/miekg/dns"
"k8s.io/utils/ptr"
)

// dnsExchange interface allows mocking the dns client for tests.
type dnsExchange interface {
Exchange(m *dns.Msg, a string) (r *dns.Msg, rtt time.Duration, err error)
}

// defaultDNSClient wraps the miekg/dns client.
type defaultDNSClient struct {
client *dns.Client
}

func (d *defaultDNSClient) Exchange(m *dns.Msg, a string) (*dns.Msg, time.Duration, error) {
return d.client.Exchange(m, a)
}

// DNSResult represents the required JSON response format for probe_dns_local
type DNSResult struct {
Answers []string `json:"answers"`
Rcode string `json:"rcode"`
LatencyMS int64 `json:"latency_ms"`
}

func initProbeDNSLocal() []api.ServerTool {
return initProbeDNSLocalWith(&defaultDNSClient{client: new(dns.Client)})
}

// initProbeDNSLocalWith creates probe_dns_local tools using the provided dnsExchange.
// Use initProbeDNSLocal() for production; pass a mock to this function in tests.
func initProbeDNSLocalWith(client dnsExchange) []api.ServerTool {
return []api.ServerTool{
{
Tool: api.Tool{
Name: "probe_dns_local",
Description: "Run a DNS query using local libraries on the MCP server host to verify connectivity and resolution.",
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"server": {
Type: "string",
Description: "DNS server IP (e.g. 8.8.8.8, 10.0.0.10)",
},
"name": {
Type: "string",
Description: "FQDN to query",
},
"type": {
Type: "string",
Description: "Record type (A, AAAA, CNAME, TXT, SRV, etc.). Defaults to A.",
Default: json.RawMessage(`"A"`),
},
},
Required: []string{"server", "name"},
},
Annotations: api.ToolAnnotations{
Title: "Probe DNS (Local)",
ReadOnlyHint: ptr.To(true),
DestructiveHint: ptr.To(false),
OpenWorldHint: ptr.To(true),
},
},
ClusterAware: ptr.To(false),
Handler: makeProbeDNSLocalHandler(client),
},
}
}

func makeProbeDNSLocalHandler(client dnsExchange) api.ToolHandlerFunc {
return func(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
serverParam, ok := params.GetArguments()["server"].(string)
if !ok || serverParam == "" {
return api.NewToolCallResultStructured(nil, fmt.Errorf("server parameter is required")), nil
}

nameParam, ok := params.GetArguments()["name"].(string)
if !ok || nameParam == "" {
return api.NewToolCallResultStructured(nil, fmt.Errorf("name parameter is required")), nil
}

typeParam, ok := params.GetArguments()["type"].(string)
if !ok || typeParam == "" {
typeParam = "A"
}

// Ensure name falls back to a FQDN format
fqdn := dns.Fqdn(nameParam)

// Ensure server parameter has a port
if _, _, err := net.SplitHostPort(serverParam); err != nil {
// If port is missing, try adding default DNS port 53
appended := net.JoinHostPort(serverParam, "53")
if _, _, err2 := net.SplitHostPort(appended); err2 == nil {
serverParam = appended
} else {
return api.NewToolCallResultStructured(nil, fmt.Errorf("invalid server address format: %w", err)), nil
}
}

recordType, ok := dns.StringToType[strings.ToUpper(typeParam)]
if !ok {
return api.NewToolCallResultStructured(nil, fmt.Errorf("invalid or unsupported DNS record type: %s", typeParam)), nil
}

msg := new(dns.Msg)
msg.SetQuestion(fqdn, recordType)
msg.RecursionDesired = true

resp, rtt, err := client.Exchange(msg, serverParam)

if err != nil {
// Log network level errors directly to the tool output so agent can interpret it
return api.NewToolCallResultStructured(nil, fmt.Errorf("DNS query failed: %w", err)), nil
}

result := DNSResult{
Answers: make([]string, 0, len(resp.Answer)),
Rcode: dns.RcodeToString[resp.Rcode],
LatencyMS: rtt.Milliseconds(),
}

for _, answer := range resp.Answer {
// Replace tabs with spaces for prettier JSON presentation if needed
ans := strings.ReplaceAll(answer.String(), "\t", " ")
result.Answers = append(result.Answers, ans)
}

return api.NewToolCallResultStructured(result, nil), nil
}
}
188 changes: 188 additions & 0 deletions pkg/toolsets/netedge/probe_dns_local_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
package netedge
Comment thread
bentito marked this conversation as resolved.

import (
"encoding/json"
"net"
"time"

"github.com/containers/kubernetes-mcp-server/pkg/api"
"github.com/miekg/dns"
)

type mockDNSClient struct {
msg *dns.Msg
rtt time.Duration
err error
lastServer string
}

func (m *mockDNSClient) Exchange(msg *dns.Msg, server string) (*dns.Msg, time.Duration, error) {
m.lastServer = server
return m.msg, m.rtt, m.err
}

func (s *NetEdgeTestSuite) TestProbeDNSLocalHandler() {
// Setup static success response
successMsg := new(dns.Msg)
successMsg.Rcode = dns.RcodeSuccess

aRecord, _ := dns.NewRR("example.com. 3600 IN A 93.184.216.34")
successMsg.Answer = append(successMsg.Answer, aRecord)

s.Run("success query A record", func() {
mock := &mockDNSClient{msg: successMsg, rtt: 10 * time.Millisecond}
s.SetArgs(map[string]interface{}{
"server": "8.8.8.8",
"name": "example.com",
"type": "A",
})
handler := makeProbeDNSLocalHandler(mock)

result, err := handler(s.params)

s.Require().NoError(err)
s.Require().NoError(result.Error)

var res DNSResult
jsonErr := json.Unmarshal([]byte(result.Content), &res)
s.Require().NoError(jsonErr)
s.Assert().Equal("NOERROR", res.Rcode)
s.Assert().Equal(int64(10), res.LatencyMS)
s.Assert().Len(res.Answers, 1)
s.Assert().Contains(res.Answers[0], "93.184.216.34")

structured, ok := result.StructuredContent.(DNSResult)
s.Require().True(ok)
s.Assert().Equal("NOERROR", structured.Rcode)
})

s.Run("missing name parameter", func() {
mock := &mockDNSClient{msg: successMsg}
s.SetArgs(map[string]interface{}{"server": "8.8.8.8"})
handler := makeProbeDNSLocalHandler(mock)

result, err := handler(s.params)

s.Require().NoError(err)
s.Require().NotNil(result.Error)
s.Assert().Contains(result.Error.Error(), "name parameter is required")
})

s.Run("missing server parameter", func() {
mock := &mockDNSClient{msg: successMsg}
s.SetArgs(map[string]interface{}{"name": "example.com"})
handler := makeProbeDNSLocalHandler(mock)

result, err := handler(s.params)

s.Require().NoError(err)
s.Require().NotNil(result.Error)
s.Assert().Contains(result.Error.Error(), "server parameter is required")
})

s.Run("invalid record type", func() {
mock := &mockDNSClient{msg: successMsg}
s.SetArgs(map[string]interface{}{
"server": "8.8.8.8",
"name": "example.com",
"type": "INVALID",
})
handler := makeProbeDNSLocalHandler(mock)

result, err := handler(s.params)

s.Require().NoError(err)
s.Require().NotNil(result.Error)
s.Assert().Contains(result.Error.Error(), "invalid or unsupported DNS record type: INVALID")
})

s.Run("network failure from library", func() {
mock := &mockDNSClient{
msg: nil,
rtt: 0,
err: &net.OpError{Op: "dial", Net: "udp", Err: net.UnknownNetworkError("timeout")},
}
s.SetArgs(map[string]interface{}{
"server": "8.8.8.8",
"name": "example.com",
"type": "A",
})
handler := makeProbeDNSLocalHandler(mock)

result, err := handler(s.params)

s.Require().NoError(err)
s.Require().NotNil(result.Error)
s.Assert().Contains(result.Error.Error(), "DNS query failed")
})

s.Run("default type is A if omitted", func() {
mock := &mockDNSClient{msg: successMsg, rtt: 5 * time.Millisecond}
s.SetArgs(map[string]interface{}{
"server": "8.8.8.8",
"name": "example.com",
})
handler := makeProbeDNSLocalHandler(mock)

result, err := handler(s.params)

s.Require().NoError(err)
s.Require().NoError(result.Error)

var res DNSResult
jsonErr := json.Unmarshal([]byte(result.Content), &res)
s.Require().NoError(jsonErr)
s.Assert().Equal("NOERROR", res.Rcode)

structured, ok := result.StructuredContent.(DNSResult)
s.Require().True(ok)
s.Assert().Equal("NOERROR", structured.Rcode)
})

s.Run("ipv4 address appends default port", func() {
mock := &mockDNSClient{msg: successMsg, rtt: 5 * time.Millisecond}
s.SetArgs(map[string]interface{}{
"server": "1.1.1.1",
"name": "example.com",
})
handler := makeProbeDNSLocalHandler(mock)

result, err := handler(s.params)

s.Require().NoError(err)
s.Require().NoError(result.Error)
s.Assert().Equal("1.1.1.1:53", mock.lastServer)
})

s.Run("ipv6 address appends default port", func() {
mock := &mockDNSClient{msg: successMsg, rtt: 5 * time.Millisecond}
s.SetArgs(map[string]interface{}{
"server": "2001:4860:4860::8888",
"name": "example.com",
})
handler := makeProbeDNSLocalHandler(mock)

result, err := handler(s.params)

s.Require().NoError(err)
s.Require().NoError(result.Error)
s.Assert().Equal("[2001:4860:4860::8888]:53", mock.lastServer)
})

s.Run("invalid result is returned as structured error", func() {
mock := &mockDNSClient{msg: successMsg, rtt: 5 * time.Millisecond}
s.SetArgs(map[string]interface{}{
"server": "8.8.8.8",
"name": "example.com",
"type": "A",
})
handler := makeProbeDNSLocalHandler(mock)

result, err := handler(s.params)
_ = result

s.Require().NoError(err)
// Ensure structured content is returned (not just raw string)
s.Assert().IsType(api.ToolCallResult{}, *result)
})
}
1 change: 1 addition & 0 deletions pkg/toolsets/netedge/toolset.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ func (t *Toolset) GetTools(_ api.Openshift) []api.ServerTool {
InitQueryPrometheus(),
initCoreDNS(),
initEndpoints(),
initProbeDNSLocal(),
)
}

Expand Down
8 changes: 8 additions & 0 deletions vendor/github.com/miekg/dns/.codecov.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions vendor/github.com/miekg/dns/.gitignore

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions vendor/github.com/miekg/dns/AUTHORS

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions vendor/github.com/miekg/dns/CODEOWNERS

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions vendor/github.com/miekg/dns/CONTRIBUTORS

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading