-
Notifications
You must be signed in to change notification settings - Fork 43
NE-2451: Implement probe_dns_local diagnostic tool #176
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,188 @@ | ||
| package netedge | ||
|
|
||
| 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) | ||
| }) | ||
| } | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Uh oh!
There was an error while loading. Please reload this page.