From ef66d5ae4ea184e15e386315df142431e636fc4e Mon Sep 17 00:00:00 2001 From: Dmytro Rashko Date: Wed, 24 Jun 2026 15:19:15 +0200 Subject: [PATCH 01/21] Migrate to GO SDK Signed-off-by: Dmytro Rashko --- Makefile | 4 +- cmd/main.go | 134 +- cmd/metrics_wrap_test.go | 127 -- cmd/testdata/tool_names_v0.2.1.txt | 132 ++ cmd/tools_regression_test.go | 81 ++ go.mod | 10 +- go.sum | 22 +- internal/cache/cache_test.go | 27 + internal/commands/builder_setters_test.go | 51 + internal/errors/tool_errors.go | 2 +- internal/errors/tool_errors_branches_test.go | 93 ++ internal/logger/logger_test.go | 15 + internal/mcp/mcp.go | 128 ++ internal/mcp/mcp_test.go | 131 ++ internal/telemetry/config_test.go | 12 + internal/telemetry/middleware.go | 78 - internal/telemetry/middleware_test.go | 677 +-------- pkg/argo/argo.go | 283 ++-- pkg/argo/argo_test.go | 165 +-- pkg/cilium/cilium.go | 1363 +++++++++--------- pkg/cilium/cilium_test.go | 457 +++--- pkg/helm/helm.go | 315 ++-- pkg/helm/helm_test.go | 189 +-- pkg/istio/istio.go | 416 +++--- pkg/istio/istio_test.go | 194 +-- pkg/k8s/k8s.go | 1016 +++++++------ pkg/k8s/k8s_test.go | 563 +++----- pkg/kubescape/kubescape.go | 438 +++--- pkg/kubescape/kubescape_test.go | 234 ++- pkg/prometheus/prometheus.go | 204 +-- pkg/prometheus/prometheus_test.go | 251 ++-- pkg/prometheus/promql.go | 20 +- pkg/utils/common.go | 44 +- pkg/utils/common_test.go | 21 +- pkg/utils/datetime_test.go | 18 +- test/e2e/helpers_test.go | 118 +- 36 files changed, 3773 insertions(+), 4260 deletions(-) delete mode 100644 cmd/metrics_wrap_test.go create mode 100644 cmd/testdata/tool_names_v0.2.1.txt create mode 100644 cmd/tools_regression_test.go create mode 100644 internal/commands/builder_setters_test.go create mode 100644 internal/errors/tool_errors_branches_test.go create mode 100644 internal/mcp/mcp.go create mode 100644 internal/mcp/mcp_test.go diff --git a/Makefile b/Makefile index 2c7dd490..d7305e14 100644 --- a/Makefile +++ b/Makefile @@ -58,11 +58,11 @@ tidy: ## Run go mod tidy to ensure dependencies are up to date. .PHONY: test test: build lint ## Run all tests with build, lint, and coverage - go test -tags=test -v -cover ./pkg/... ./internal/... + go test -tags=test -v -cover ./pkg/... ./internal/... ./cmd/... .PHONY: test-only test-only: ## Run tests only (without build/lint for faster iteration) - go test -tags=test -v -cover ./pkg/... ./internal/... + go test -tags=test -v -cover ./pkg/... ./internal/... ./cmd/... .PHONY: e2e e2e: test retag diff --git a/cmd/main.go b/cmd/main.go index 943b7db2..85e21e74 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -16,6 +16,7 @@ import ( "github.com/joho/godotenv" "github.com/kagent-dev/tools/internal/logger" + mcpserver "github.com/kagent-dev/tools/internal/mcp" "github.com/kagent-dev/tools/internal/metrics" "github.com/kagent-dev/tools/internal/telemetry" "github.com/kagent-dev/tools/internal/version" @@ -33,8 +34,7 @@ import ( "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" ) var ( @@ -140,16 +140,14 @@ func run(cmd *cobra.Command, args []string) { logger.Get().Info("Running in read-only mode - write operations are disabled") } - mcp := server.NewMCPServer( - Name, - Version, - ) + mcpSrv := sdkmcp.NewServer(&sdkmcp.Implementation{Name: Name, Version: Version}, nil) + + // Attach a single receiving middleware that instruments every tools/call + // with an OTel span and Prometheus invocation counters. Per-tool provider + // labels are recorded as each provider registers its tools. + mcpSrv.AddReceivingMiddleware(mcpserver.ToolMiddleware()) - // Register tools and wrap handlers with metrics instrumentation. - // registerMCP returns a map of tool_name -> tool_provider so that - // wrapToolHandlersWithMetrics knows which provider each tool belongs to. - toolProviders := registerMCP(mcp, tools, *kubeconfig, readOnly) - wrapToolHandlersWithMetrics(mcp, toolProviders) + registerMCP(mcpSrv, tools, *kubeconfig, readOnly) // Create wait group for server goroutines var wg sync.WaitGroup @@ -167,11 +165,12 @@ func run(cmd *cobra.Command, args []string) { if stdio { go func() { defer wg.Done() - runStdioServer(ctx, mcp) + runStdioServer(ctx, mcpSrv) }() } else { - sseServer := server.NewStreamableHTTPServer(mcp, - server.WithHeartbeatInterval(30*time.Second), + sseServer := sdkmcp.NewStreamableHTTPHandler( + func(*http.Request) *sdkmcp.Server { return mcpSrv }, + nil, ) // Create a mux to handle different routes @@ -293,29 +292,27 @@ func writeResponse(w http.ResponseWriter, data []byte) error { return err } -func runStdioServer(ctx context.Context, mcp *server.MCPServer) { +func runStdioServer(ctx context.Context, mcpSrv *sdkmcp.Server) { logger.Get().Info("Running KAgent Tools Server STDIO:", "tools", strings.Join(tools, ",")) - stdioServer := server.NewStdioServer(mcp) - if err := stdioServer.Listen(ctx, os.Stdin, os.Stdout); err != nil { + if err := mcpSrv.Run(ctx, &sdkmcp.StdioTransport{}); err != nil { logger.Get().Info("Stdio server stopped", "error", err) } } -// registerMCP registers tool providers with the MCP server and returns a mapping -// of tool_name -> tool_provider. This mapping is built using the ListTools() diff -// technique: we snapshot the tool list before and after each provider registers, -// so we know exactly which tools belong to which provider. -func registerMCP(mcp *server.MCPServer, enabledToolProviders []string, kubeconfig string, readOnly bool) map[string]string { - // A map to hold tool providers and their registration functions - toolProviderMap := map[string]func(*server.MCPServer){ - "argo": func(s *server.MCPServer) { argo.RegisterTools(s, readOnly) }, - "cilium": func(s *server.MCPServer) { cilium.RegisterTools(s, readOnly) }, - "helm": func(s *server.MCPServer) { helm.RegisterTools(s, readOnly) }, - "istio": func(s *server.MCPServer) { istio.RegisterTools(s, readOnly) }, - "k8s": func(s *server.MCPServer) { k8s.RegisterTools(s, nil, kubeconfig, readOnly) }, - "kubescape": func(s *server.MCPServer) { kubescape.RegisterTools(s, kubeconfig, readOnly) }, - "prometheus": func(s *server.MCPServer) { prometheus.RegisterTools(s, readOnly) }, - "utils": func(s *server.MCPServer) { utils.RegisterTools(s, readOnly) }, +// registerMCP registers the enabled tool providers with the MCP server. Each +// provider's RegisterTools call records tool->provider mappings and the tool +// inventory metric centrally (see internal/mcp.AddTool); invocation metrics and +// tracing are applied by the receiving middleware installed in run(). +func registerMCP(mcpSrv *sdkmcp.Server, enabledToolProviders []string, kubeconfig string, readOnly bool) { + toolProviderMap := map[string]func(*sdkmcp.Server){ + "argo": func(s *sdkmcp.Server) { argo.RegisterTools(s, readOnly) }, + "cilium": func(s *sdkmcp.Server) { cilium.RegisterTools(s, readOnly) }, + "helm": func(s *sdkmcp.Server) { helm.RegisterTools(s, readOnly) }, + "istio": func(s *sdkmcp.Server) { istio.RegisterTools(s, readOnly) }, + "k8s": func(s *sdkmcp.Server) { k8s.RegisterTools(s, nil, kubeconfig, readOnly) }, + "kubescape": func(s *sdkmcp.Server) { kubescape.RegisterTools(s, kubeconfig, readOnly) }, + "prometheus": func(s *sdkmcp.Server) { prometheus.RegisterTools(s, readOnly) }, + "utils": func(s *sdkmcp.Server) { utils.RegisterTools(s, readOnly) }, } // If no specific tools are specified, register all available tools. @@ -325,82 +322,11 @@ func registerMCP(mcp *server.MCPServer, enabledToolProviders []string, kubeconfi } } - // toolToProvider maps each tool name to its provider (e.g., "kubectl_get" -> "k8s"). - // This is used later by wrapToolHandlersWithMetrics to set the correct tool_provider label. - toolToProvider := make(map[string]string) - for _, toolProviderName := range enabledToolProviders { if registerFunc, ok := toolProviderMap[toolProviderName]; ok { - // Snapshot the tool list before this provider registers its tools. - // We need this because ListTools() returns ALL tools from ALL providers, - // so the only way to know which tools belong to THIS provider is to compare - // the list before and after registration. - toolsBefore := mcp.ListTools() - - registerFunc(mcp) - - // Determine which tools were just registered by this provider - // by finding tools that exist now but didn't exist before. - // Record each one in Prometheus so we can observe the full tool inventory. - for toolName := range mcp.ListTools() { - if _, existed := toolsBefore[toolName]; !existed { - metrics.KagentToolsMCPRegisteredTools.WithLabelValues(toolName, toolProviderName).Set(1) - toolToProvider[toolName] = toolProviderName - } - } + registerFunc(mcpSrv) } else { logger.Get().Error("Unknown tool specified", "provider", toolProviderName) } } - - return toolToProvider -} - -// wrapToolHandlersWithMetrics applies the wrapper/middleware pattern to instrument -// all registered MCP tool handlers with Prometheus invocation counters. -// -// How it works: -// 1. Grab all registered tools from the MCP server using ListTools() -// 2. For each tool, wrap its handler with a function that increments metrics -// 3. Replace all tools in the MCP server using SetTools() -// -// The wrapper function: -// - Increments kagent_tools_mcp_invocations_total on every call -// - Increments kagent_tools_mcp_invocations_failure_total when the handler returns a -// non-nil Go error OR when result.IsError is true (the MCP convention for tool-level -// failures - handlers return NewToolResultError(...), nil, not a Go error) -// - Calls the original handler unchanged - the tool's behaviour is not affected -// -// This uses the standard middleware/decorator pattern: the original handler and the -// wrapped handler have the same function signature, so they are interchangeable. -// No changes are required in any pkg/ file - all instrumentation happens centrally here. -func wrapToolHandlersWithMetrics(mcpServer *server.MCPServer, toolToProvider map[string]string) { - allTools := mcpServer.ListTools() - wrapped := make([]server.ServerTool, 0, len(allTools)) - - for name, st := range allTools { - originalHandler := st.Handler - toolName := name // capture for closure - provider := toolToProvider[toolName] - - wrapped = append(wrapped, server.ServerTool{ - Tool: st.Tool, - Handler: func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - metrics.KagentToolsMCPInvocationsTotal.WithLabelValues(toolName, provider).Inc() - - result, err := originalHandler(ctx, req) - - // Count as failure if the Go error is non-nil OR if the tool returned - // a result with IsError=true (the MCP convention for tool-level failures, - // which always return nil for the Go error). - if err != nil || (result != nil && result.IsError) { - metrics.KagentToolsMCPInvocationsFailureTotal.WithLabelValues(toolName, provider).Inc() - } - - return result, err - }, - }) - } - - mcpServer.SetTools(wrapped...) } diff --git a/cmd/metrics_wrap_test.go b/cmd/metrics_wrap_test.go deleted file mode 100644 index 0b8ca730..00000000 --- a/cmd/metrics_wrap_test.go +++ /dev/null @@ -1,127 +0,0 @@ -package main - -import ( - "context" - "fmt" - "testing" - - "github.com/kagent-dev/tools/internal/metrics" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" - promtest "github.com/prometheus/client_golang/prometheus/testutil" -) - -// newTestServer creates a fresh MCP server and resets the metric counters so -// tests do not interfere with each other. -func newTestServer() *server.MCPServer { - metrics.KagentToolsMCPInvocationsTotal.Reset() - metrics.KagentToolsMCPInvocationsFailureTotal.Reset() - return server.NewMCPServer("test-server", "test") -} - -// invokeWrapped registers handler on s, wraps all handlers with metrics, then -// calls the wrapped handler for toolName and returns its result. -func invokeWrapped(t *testing.T, s *server.MCPServer, toolName string, provider string, handler server.ToolHandlerFunc) (*mcp.CallToolResult, error) { - t.Helper() - s.AddTool(mcp.Tool{Name: toolName}, handler) - wrapToolHandlersWithMetrics(s, map[string]string{toolName: provider}) - st, ok := s.ListTools()[toolName] - if !ok { - t.Fatalf("tool %q not found after wrapping", toolName) - } - return st.Handler(context.Background(), mcp.CallToolRequest{}) -} - -// TestWrapToolHandlersWithMetrics_IsErrorIncrementsFailureCounter is the -// critical regression test for the bug identified in PR review: -// -// Handlers signal tool-level failures via NewToolResultError(...), nil -// (result.IsError=true, Go error=nil), so checking only `err != nil` would -// never count these as failures. -// -// To replicate manually: -// -// go test -v -run TestWrapToolHandlersWithMetrics_IsErrorIncrementsFailureCounter ./cmd/ -func TestWrapToolHandlersWithMetrics_IsErrorIncrementsFailureCounter(t *testing.T) { - s := newTestServer() - - result, err := invokeWrapped(t, s, "failing_tool", "test", - func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { - // This is the pattern used 214 times across pkg/ - returns a tool-level - // error with IsError=true but a nil Go error. - return mcp.NewToolResultError("kubectl: resource not found"), nil - }, - ) - - if err != nil { - t.Fatalf("expected nil Go error from handler, got: %v", err) - } - if !result.IsError { - t.Fatal("expected result.IsError=true") - } - - total := promtest.ToFloat64(metrics.KagentToolsMCPInvocationsTotal.WithLabelValues("failing_tool", "test")) - if total != 1 { - t.Errorf("invocations_total: expected 1, got %v", total) - } - - failures := promtest.ToFloat64(metrics.KagentToolsMCPInvocationsFailureTotal.WithLabelValues("failing_tool", "test")) - if failures != 1 { - t.Errorf("invocations_failure_total: expected 1, got %v (IsError=true was not counted as failure)", failures) - } -} - -// TestWrapToolHandlersWithMetrics_SuccessDoesNotIncrementFailureCounter verifies -// that a successful tool call does not touch the failure counter. -// -// To replicate manually: -// -// go test -v -run TestWrapToolHandlersWithMetrics_SuccessDoesNotIncrementFailureCounter ./cmd/ -func TestWrapToolHandlersWithMetrics_SuccessDoesNotIncrementFailureCounter(t *testing.T) { - s := newTestServer() - - _, err := invokeWrapped(t, s, "success_tool", "test", - func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return mcp.NewToolResultText("all good"), nil - }, - ) - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - total := promtest.ToFloat64(metrics.KagentToolsMCPInvocationsTotal.WithLabelValues("success_tool", "test")) - if total != 1 { - t.Errorf("invocations_total: expected 1, got %v", total) - } - - failures := promtest.ToFloat64(metrics.KagentToolsMCPInvocationsFailureTotal.WithLabelValues("success_tool", "test")) - if failures != 0 { - t.Errorf("invocations_failure_total: expected 0 for a successful call, got %v", failures) - } -} - -// TestWrapToolHandlersWithMetrics_GoErrorIncrementsFailureCounter verifies -// that a real Go error (e.g. infrastructure failure) is also counted. -// -// To replicate manually: -// -// go test -v -run TestWrapToolHandlersWithMetrics_GoErrorIncrementsFailureCounter ./cmd/ -func TestWrapToolHandlersWithMetrics_GoErrorIncrementsFailureCounter(t *testing.T) { - s := newTestServer() - - _, err := invokeWrapped(t, s, "broken_tool", "test", - func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return nil, fmt.Errorf("connection refused") - }, - ) - - if err == nil { - t.Fatal("expected a Go error, got nil") - } - - failures := promtest.ToFloat64(metrics.KagentToolsMCPInvocationsFailureTotal.WithLabelValues("broken_tool", "test")) - if failures != 1 { - t.Errorf("invocations_failure_total: expected 1 for Go error, got %v", failures) - } -} diff --git a/cmd/testdata/tool_names_v0.2.1.txt b/cmd/testdata/tool_names_v0.2.1.txt new file mode 100644 index 00000000..54a5c769 --- /dev/null +++ b/cmd/testdata/tool_names_v0.2.1.txt @@ -0,0 +1,132 @@ +# Tool names registered by the v0.2.1 release (pre go-sdk migration). +# Source of truth: `git grep 'mcp.NewTool("...")' v0.2.1 -- pkg/`. +# TestNoToolNameRegressions asserts every name below still exists in the +# current build so the SDK migration never silently renames/drops a tool. +# Add new tools freely; never remove a line without a deliberate, documented +# breaking change. +# Note: kubescape_get_sbom / kubescape_list_sboms were commented out (not +# registered) in v0.2.1, so they are intentionally absent here. +argo_check_plugin_logs +argo_pause_rollout +argo_promote_rollout +argo_rollouts_list +argo_set_rollout_image +argo_verify_argo_rollouts_controller_install +argo_verify_gateway_plugin +argo_verify_kubectl_plugin_install +cilium_connect_to_remote_cluster +cilium_delete_key_from_kv_store +cilium_delete_pcap_recorder +cilium_delete_policy_rules +cilium_delete_service +cilium_delete_xdp_cidr_filters +cilium_disconnect_endpoint +cilium_disconnect_remote_cluster +cilium_display_encryption_state +cilium_display_policy_node_information +cilium_display_selectors +cilium_flush_ipsec_state +cilium_fqdn_cache +cilium_get_bpf_map +cilium_get_daemon_status +cilium_get_endpoint_details +cilium_get_endpoint_health +cilium_get_endpoint_logs +cilium_get_endpoints_list +cilium_get_identity_details +cilium_get_kv_store_key +cilium_get_pcap_recorder +cilium_get_service_information +cilium_install_cilium +cilium_list_bgp_peers +cilium_list_bgp_routes +cilium_list_bpf_map_events +cilium_list_bpf_maps +cilium_list_cluster_nodes +cilium_list_envoy_config +cilium_list_identities +cilium_list_ip_addresses +cilium_list_local_redirect_policies +cilium_list_metrics +cilium_list_node_ids +cilium_list_pcap_recorders +cilium_list_services +cilium_list_xdp_cidr_filters +cilium_manage_endpoint_config +cilium_manage_endpoint_labels +cilium_request_debugging_information +cilium_set_kv_store_key +cilium_show_cluster_mesh_status +cilium_show_configuration_options +cilium_show_dns_names +cilium_show_features_status +cilium_show_ip_cache_information +cilium_show_load_information +cilium_status_and_version +cilium_toggle_cluster_mesh +cilium_toggle_configuration_option +cilium_toggle_hubble +cilium_uninstall_cilium +cilium_update_pcap_recorder +cilium_update_service +cilium_update_xdp_cidr_filters +cilium_upgrade_cilium +cilium_validate_cilium_network_policies +datetime_get_current_time +helm_get_release +helm_list_releases +helm_repo_add +helm_repo_update +helm_uninstall +helm_upgrade +istio_analyze_cluster_configuration +istio_apply_waypoint +istio_delete_waypoint +istio_generate_manifest +istio_generate_waypoint +istio_install_istio +istio_list_waypoints +istio_proxy_config +istio_proxy_status +istio_remote_clusters +istio_version +istio_waypoint_status +istio_ztunnel_config +k8s_annotate_resource +k8s_apply_manifest +k8s_check_service_connectivity +k8s_create_resource +k8s_create_resource_from_url +k8s_delete_resource +k8s_describe_resource +k8s_execute_command +k8s_generate_resource +k8s_get_available_api_resources +k8s_get_cluster_configuration +k8s_get_events +k8s_get_pod_logs +k8s_get_resource_yaml +k8s_get_resources +k8s_label_resource +k8s_patch_resource +k8s_patch_status +k8s_remove_annotation +k8s_remove_label +k8s_rollout +k8s_scale +kubescape_check_health +kubescape_get_application_profile +kubescape_get_configuration_scan +kubescape_get_network_neighborhood +kubescape_get_vulnerability_details +kubescape_list_application_profiles +kubescape_list_configuration_scans +kubescape_list_network_neighborhoods +kubescape_list_vulnerabilities +kubescape_list_vulnerability_manifests +prometheus_label_names_tool +prometheus_promql_tool +prometheus_query_range_tool +prometheus_query_tool +prometheus_targets_tool +shell diff --git a/cmd/tools_regression_test.go b/cmd/tools_regression_test.go new file mode 100644 index 00000000..83ae9b33 --- /dev/null +++ b/cmd/tools_regression_test.go @@ -0,0 +1,81 @@ +package main + +import ( + "bufio" + "context" + "os" + "sort" + "strings" + "testing" + + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// registeredToolNames spins up an in-process MCP server with every provider +// registered (readOnly=false so mutating tools are included too), connects an +// in-memory client, and returns the set of advertised tool names — the same +// list a real MCP client would see over the wire. +func registeredToolNames(t *testing.T) map[string]bool { + t.Helper() + ctx := context.Background() + + srv := sdkmcp.NewServer(&sdkmcp.Implementation{Name: "regression", Version: "test"}, nil) + registerMCP(srv, nil, "", false) // nil providers => register them all + + serverT, clientT := sdkmcp.NewInMemoryTransports() + go func() { _ = srv.Run(ctx, serverT) }() + + client := sdkmcp.NewClient(&sdkmcp.Implementation{Name: "regression-client", Version: "test"}, nil) + session, err := client.Connect(ctx, clientT, nil) + require.NoError(t, err) + defer func() { _ = session.Close() }() + + names := make(map[string]bool) + for tool, err := range session.Tools(ctx, nil) { + require.NoError(t, err) + names[tool.Name] = true + } + require.NotEmpty(t, names, "expected the server to advertise tools") + return names +} + +// readGoldenToolNames loads the committed list of tool names, ignoring blank +// lines and '#' comments. +func readGoldenToolNames(t *testing.T, path string) []string { + t.Helper() + f, err := os.Open(path) + require.NoError(t, err) + defer func() { _ = f.Close() }() + + var names []string + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + names = append(names, line) + } + require.NoError(t, sc.Err()) + return names +} + +// TestNoToolNameRegressions guards the go-sdk migration: every tool name shipped +// in the v0.2.1 release must still be registered under the same name. New tools +// are allowed; renames or removals are caught here. +func TestNoToolNameRegressions(t *testing.T) { + current := registeredToolNames(t) + old := readGoldenToolNames(t, "testdata/tool_names_v0.2.1.txt") + + var missing []string + for _, name := range old { + if !current[name] { + missing = append(missing, name) + } + } + sort.Strings(missing) + + assert.Emptyf(t, missing, "%d tool(s) from v0.2.1 are missing/renamed in the current build: %v", len(missing), missing) +} diff --git a/go.mod b/go.mod index 7535dbd8..3bd83439 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/joho/godotenv v1.5.1 github.com/kubescape/k8s-interface v0.0.203 github.com/kubescape/storage v0.0.239 - github.com/mark3labs/mcp-go v0.43.2 + github.com/modelcontextprotocol/go-sdk v1.6.1 github.com/onsi/ginkgo/v2 v2.27.2 github.com/onsi/gomega v1.38.2 github.com/prometheus/client_golang v1.23.2 @@ -38,13 +38,11 @@ require ( github.com/armosec/gojay v1.2.17 // indirect github.com/armosec/utils-go v0.0.58 // indirect github.com/armosec/utils-k8s-go v0.0.35 // indirect - github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/becheran/wildmatch-go v1.0.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect github.com/briandowns/spinner v1.23.2 // indirect - github.com/buger/jsonparser v1.1.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -99,6 +97,7 @@ require ( github.com/google/gnostic-models v0.7.1 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-containerregistry v0.20.6 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/google/licensecheck v0.3.1 // indirect github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8 // indirect github.com/google/uuid v1.6.0 // indirect @@ -106,14 +105,12 @@ require ( github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/invopop/jsonschema v0.13.0 // indirect github.com/jinzhu/copier v0.4.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.4 // indirect github.com/kubescape/go-logger v0.0.26 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/mackerelio/go-osstat v0.2.6 // indirect - github.com/mailru/easyjson v0.9.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect @@ -138,6 +135,8 @@ require ( github.com/sasha-s/go-deadlock v0.3.6 // indirect github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e // indirect github.com/seccomp/libseccomp-golang v0.10.0 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect @@ -155,7 +154,6 @@ require ( github.com/vishvananda/netns v0.0.5 // indirect github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651 // indirect github.com/wagoodman/go-progress v0.0.0-20230925121702-07e42b3cdba0 // indirect - github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/yl2chen/cidranger v1.0.2 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect diff --git a/go.sum b/go.sum index 8e473845..145106da 100644 --- a/go.sum +++ b/go.sum @@ -100,8 +100,6 @@ github.com/armosec/utils-go v0.0.58 h1:g9RnRkxZAmzTfPe2ruMo2OXSYLwVSegQSkSavOfma github.com/armosec/utils-go v0.0.58/go.mod h1:CdqKHKruVJMCxGcZXYW9J+5P9FZou8dMzVpcB0Xt8pk= github.com/armosec/utils-k8s-go v0.0.35 h1:CliNObhAca5UYl84m5OQecOTm9ZfMFI8648pYhQJiu4= github.com/armosec/utils-k8s-go v0.0.35/go.mod h1:iHwR/KhMFtdd8Px1oYexLZYOHqmdknfGTZ8b7sZS0Ms= -github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= -github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/becheran/wildmatch-go v1.0.0 h1:mE3dGGkTmpKtT4Z+88t8RStG40yN9T+kFEGj2PZFSzA= github.com/becheran/wildmatch-go v1.0.0/go.mod h1:gbMvj0NtVdJ15Mg/mH9uxk2R1QCistMyU7d9KFzroX4= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= @@ -117,8 +115,6 @@ github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBT github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s= -github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= -github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= @@ -305,6 +301,8 @@ github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7a github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/gohugoio/hashstructure v0.5.0 h1:G2fjSBU36RdwEJBWJ+919ERvOVqAg9tfcYp47K9swqg= github.com/gohugoio/hashstructure v0.5.0/go.mod h1:Ser0TniXuu/eauYmrwM4o64EBvySxNzITEOLlm4igec= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -363,6 +361,8 @@ github.com/google/go-containerregistry v0.20.6/go.mod h1:T0x8MuoAoKX/873bkeSfLD2 github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/licensecheck v0.3.1 h1:QoxgoDkaeC4nFrtGN1jV7IPmDCHFNIVh54e5hSt6sPs= github.com/google/licensecheck v0.3.1/go.mod h1:ORkR35t/JjW+emNKtfJDII0zlciG9JgbT7SmsohlHmY= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= @@ -446,8 +446,6 @@ github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1: github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= -github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1/go.mod h1:E0B/fFc00Y+Rasa88328GlI/XbtyysCtTHZS8h7IrBU= github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8= github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= @@ -493,10 +491,6 @@ github.com/mackerelio/go-osstat v0.2.6 h1:gs4U8BZeS1tjrL08tt5VUliVvSWP26Ai2Ob8Lr github.com/mackerelio/go-osstat v0.2.6/go.mod h1:lRy8V9ZuHpuRVZh+vyTkODeDPl3/d5MgXHtLSaqG8bA= github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= -github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= -github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= -github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I= -github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= @@ -534,6 +528,8 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg= github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= +github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= +github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -630,6 +626,10 @@ github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e/go.mod h1:DkpGd7 github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/seccomp/libseccomp-golang v0.10.0 h1:aA4bp+/Zzi0BnWZ2F1wgNBs5gTpm+na2rWM6M9YjLpY= github.com/seccomp/libseccomp-golang v0.10.0/go.mod h1:JA8cRccbGaA1s33RQf7Y1+q9gHmZX1yB/z9WDN1C6fg= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= @@ -735,8 +735,6 @@ github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651 h1:jIVmlAFIq github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651/go.mod h1:b26F2tHLqaoRQf8DywqzVaV1MQ9yvjb0OMcNl7Nxu20= github.com/wagoodman/go-progress v0.0.0-20230925121702-07e42b3cdba0 h1:0KGbf+0SMg+UFy4e1A/CPVvXn21f1qtWdeJwxZFoQG8= github.com/wagoodman/go-progress v0.0.0-20230925121702-07e42b3cdba0/go.mod h1:jLXFoL31zFaHKAAyZUh+sxiTDFe1L1ZHrcK2T1itVKA= -github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= -github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index cc7cf641..2840d618 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -8,6 +8,33 @@ import ( "github.com/stretchr/testify/assert" ) +func TestInvalidateHelpers(t *testing.T) { + // Seed each cache, then assert the type-specific invalidators clear it. + InitCaches() + for _, ct := range []CacheType{CacheTypeKubernetes, CacheTypeHelm, CacheTypeIstio, CacheTypeCommand} { + GetCacheByType(ct).Set("k", "v") + } + + InvalidateKubernetesCache() + InvalidateHelmCache() + InvalidateIstioCache() + InvalidateCommandCache() + + for _, ct := range []CacheType{CacheTypeKubernetes, CacheTypeHelm, CacheTypeIstio, CacheTypeCommand} { + if _, ok := GetCacheByType(ct).Get("k"); ok { + t.Errorf("expected %s cache to be invalidated", ct.String()) + } + } + + // Known command routes to its mapped cache; unknown falls back to command cache. + GetCacheByType(CacheTypeKubernetes).Set("k", "v") + InvalidateCacheForCommand("kubectl") + if _, ok := GetCacheByType(CacheTypeKubernetes).Get("k"); ok { + t.Error("expected kubectl command to invalidate kubernetes cache") + } + assert.NotPanics(t, func() { InvalidateCacheForCommand("totally-unknown-cmd") }) +} + func TestNewCache(t *testing.T) { cache := NewCache[string]("test-cache", 1*time.Minute, 100, 10*time.Second) diff --git a/internal/commands/builder_setters_test.go b/internal/commands/builder_setters_test.go new file mode 100644 index 00000000..1ed7ea84 --- /dev/null +++ b/internal/commands/builder_setters_test.go @@ -0,0 +1,51 @@ +package commands + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestBuilderSettersValidation exercises both the accept and reject branches of +// the validating setters so invalid input is silently dropped, not applied. +func TestBuilderSettersValidation(t *testing.T) { + t.Run("WithToken", func(t *testing.T) { + cb := NewCommandBuilder("kubectl").WithToken("secret") + assert.Equal(t, "secret", cb.token) + // Empty token is a no-op and keeps the previous value. + cb.WithToken("") + assert.Equal(t, "secret", cb.token) + }) + + t.Run("WithContext", func(t *testing.T) { + cb := NewCommandBuilder("kubectl").WithContext("prod-cluster") + assert.Equal(t, "prod-cluster", cb.context) + // Injection attempt is rejected, leaving the prior value intact. + cb.WithContext("ctx; rm -rf /") + assert.Equal(t, "prod-cluster", cb.context) + }) + + t.Run("WithKubeconfig", func(t *testing.T) { + cb := NewCommandBuilder("kubectl").WithKubeconfig("/home/user/.kube/config") + assert.Equal(t, "/home/user/.kube/config", cb.kubeconfig) + // Path traversal is rejected. + cb.WithKubeconfig("../../etc/passwd") + assert.Equal(t, "/home/user/.kube/config", cb.kubeconfig) + }) + + t.Run("WithLabel", func(t *testing.T) { + cb := NewCommandBuilder("kubectl").WithLabel("app", "nginx") + assert.Equal(t, "nginx", cb.labels["app"]) + // Empty key is invalid and must not be stored. + cb.WithLabel("", "x") + assert.NotContains(t, cb.labels, "") + }) + + t.Run("WithAnnotation", func(t *testing.T) { + cb := NewCommandBuilder("kubectl").WithAnnotation("team", "sre") + assert.Equal(t, "sre", cb.annotations["team"]) + // Invalid key format is rejected. + cb.WithAnnotation("bad key!", "v") + assert.NotContains(t, cb.annotations, "bad key!") + }) +} diff --git a/internal/errors/tool_errors.go b/internal/errors/tool_errors.go index 12a7fd9c..5b67252b 100644 --- a/internal/errors/tool_errors.go +++ b/internal/errors/tool_errors.go @@ -5,7 +5,7 @@ import ( "strings" "time" - "github.com/mark3labs/mcp-go/mcp" + mcp "github.com/kagent-dev/tools/internal/mcp" ) // ToolError represents a structured error with context and recovery suggestions diff --git a/internal/errors/tool_errors_branches_test.go b/internal/errors/tool_errors_branches_test.go new file mode 100644 index 00000000..1f2ca86b --- /dev/null +++ b/internal/errors/tool_errors_branches_test.go @@ -0,0 +1,93 @@ +package errors + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" +) + +// errorCodeCase exercises one keyword-driven branch of a component error +// constructor and asserts the resulting error code and retryability. +type errorCodeCase struct { + cause string + expectedCode string + expectedRetry bool +} + +func runErrorCodeCases(t *testing.T, component string, ctor func(string, error) *ToolError, cases []errorCodeCase) { + t.Helper() + for _, c := range cases { + t.Run(c.expectedCode, func(t *testing.T) { + err := ctor("op", errors.New(c.cause)) + assert.Equal(t, component, err.Component) + assert.Equal(t, c.expectedCode, err.ErrorCode) + assert.Equal(t, c.expectedRetry, err.IsRetryable) + assert.NotEmpty(t, err.Suggestions) + }) + } +} + +func TestNewIstioErrorBranches(t *testing.T) { + runErrorCodeCases(t, "Istio", NewIstioError, []errorCodeCase{ + {"resource not found", "ISTIO_RESOURCE_NOT_FOUND", false}, + {"connection refused", "ISTIO_CONNECTION_ERROR", true}, + {"boom", "ISTIO_GENERIC_ERROR", true}, + }) +} + +func TestNewPrometheusErrorBranches(t *testing.T) { + runErrorCodeCases(t, "Prometheus", NewPrometheusError, []errorCodeCase{ + {"connection refused", "PROMETHEUS_CONNECTION_ERROR", true}, + {"parse error", "PROMETHEUS_QUERY_ERROR", false}, + {"boom", "PROMETHEUS_GENERIC_ERROR", true}, + }) +} + +func TestNewArgoErrorBranches(t *testing.T) { + runErrorCodeCases(t, "Argo Rollouts", NewArgoError, []errorCodeCase{ + {"rollout not found", "ARGO_ROLLOUT_NOT_FOUND", false}, + {"plugin missing", "ARGO_PLUGIN_ERROR", true}, + {"boom", "ARGO_GENERIC_ERROR", true}, + }) +} + +func TestNewCiliumErrorBranches(t *testing.T) { + runErrorCodeCases(t, "Cilium", NewCiliumError, []errorCodeCase{ + {"cilium not found", "CILIUM_NOT_FOUND", false}, + {"connection lost", "CILIUM_CONNECTION_ERROR", true}, + {"boom", "CILIUM_GENERIC_ERROR", true}, + }) +} + +func TestNewKubescapeErrorBranches(t *testing.T) { + // "not found" branches further specialize by operation keyword. + notFoundOps := []string{"vulnerability scan", "sbom build", "configuration scan", "application_profile get", "network_neighborhood get", "other op"} + for _, op := range notFoundOps { + t.Run("not_found/"+op, func(t *testing.T) { + err := NewKubescapeError(op, errors.New("resource not found")) + assert.Equal(t, "Kubescape", err.Component) + assert.Equal(t, "KUBESCAPE_RESOURCE_NOT_FOUND", err.ErrorCode) + assert.False(t, err.IsRetryable) + assert.NotEmpty(t, err.Suggestions) + }) + } + + runErrorCodeCases(t, "Kubescape", NewKubescapeError, []errorCodeCase{ + {"connection refused", "KUBESCAPE_CONNECTION_ERROR", true}, + {"timeout exceeded", "KUBESCAPE_CONNECTION_ERROR", true}, + {"forbidden", "KUBESCAPE_PERMISSION_ERROR", false}, + {"boom", "KUBESCAPE_GENERIC_ERROR", true}, + }) +} + +// TestToMCPResultRendersAllSections ensures the optional resource/context +// sections of ToMCPResult are exercised. +func TestToMCPResultRendersAllSections(t *testing.T) { + res := NewKubernetesError("op", errors.New("not found")). + WithResource("Pod", "web"). + WithContext("namespace", "default"). + ToMCPResult() + assert.True(t, res.IsError) + assert.NotEmpty(t, res.Content) +} diff --git a/internal/logger/logger_test.go b/internal/logger/logger_test.go index f6befc5c..fc532a54 100644 --- a/internal/logger/logger_test.go +++ b/internal/logger/logger_test.go @@ -9,9 +9,24 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/trace" "go.opentelemetry.io/otel/trace/noop" ) +func TestWithContext(t *testing.T) { + // Without a span the base logger is returned unchanged. + assert.NotNil(t, WithContext(context.Background())) + + // With a valid span context, trace_id/span_id are attached (exercises the branch). + sc := trace.NewSpanContext(trace.SpanContextConfig{ + TraceID: trace.TraceID{0x01}, + SpanID: trace.SpanID{0x02}, + TraceFlags: trace.FlagsSampled, + }) + ctx := trace.ContextWithSpanContext(context.Background(), sc) + assert.NotNil(t, WithContext(ctx)) +} + func TestRedactArgsForLog(t *testing.T) { t.Run("redacts token value", func(t *testing.T) { args := []string{"get", "pods", "--token", "secret-token-123", "-n", "default"} diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go new file mode 100644 index 00000000..7b41e13f --- /dev/null +++ b/internal/mcp/mcp.go @@ -0,0 +1,128 @@ +// Package mcp adapts the modelcontextprotocol/go-sdk server to the kagent-tools +// providers. It re-exports the SDK types the providers need, supplies result +// constructors compatible with the previous mark3labs helpers, and centralizes +// tracing/metrics instrumentation as a single receiving middleware so provider +// packages register tools with one typed call and no per-tool wrapping. +package mcp + +import ( + "context" + "net/http" + "sync" + "time" + + "github.com/kagent-dev/tools/internal/metrics" + sdk "github.com/modelcontextprotocol/go-sdk/mcp" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" +) + +// Re-exported SDK types so provider packages depend on a single import. +type ( + // Server is the MCP server tools are registered on. + Server = sdk.Server + // Tool describes a tool's name, description and (inferred) input schema. + Tool = sdk.Tool + // CallToolRequest is the server-side request passed to a tool handler. + CallToolRequest = sdk.CallToolRequest + // CallToolResult is the result returned by a tool handler. + CallToolResult = sdk.CallToolResult + // Implementation identifies the server to clients. + Implementation = sdk.Implementation + // Content is a single piece of tool result content. + Content = sdk.Content + // TextContent is textual tool result content. + TextContent = sdk.TextContent + // RequestExtra carries transport-level extras (e.g. HTTP headers) on a request. + RequestExtra = sdk.RequestExtra +) + +// NewServer constructs a new MCP server. +var NewServer = sdk.NewServer + +// NewToolResultText returns a successful text result. +func NewToolResultText(text string) *sdk.CallToolResult { + return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: text}}} +} + +// NewToolResultError returns a tool-level error result (IsError=true). Handlers +// return this together with a nil Go error, per MCP convention. +func NewToolResultError(message string) *sdk.CallToolResult { + return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: message}}, IsError: true} +} + +// Header returns the HTTP headers carried with the request, or nil for stdio / +// in-process calls. Used for bearer-token passthrough. +func Header(req *sdk.CallToolRequest) http.Header { + if req != nil && req.Extra != nil { + return req.Extra.Header + } + return nil +} + +// providerByTool maps a registered tool name to its provider for metric labels. +var providerByTool sync.Map + +// AddTool registers a typed tool and records its provider for metrics. The input +// schema is inferred from In's json/jsonschema struct tags by the SDK. +func AddTool[In, Out any](s *sdk.Server, provider string, t *sdk.Tool, h sdk.ToolHandlerFor[In, Out]) { + providerByTool.Store(t.Name, provider) + metrics.KagentToolsMCPRegisteredTools.WithLabelValues(t.Name, provider).Set(1) + sdk.AddTool(s, t, h) +} + +func providerOf(tool string) string { + if v, ok := providerByTool.Load(tool); ok { + return v.(string) + } + return "" +} + +// ToolMiddleware instruments every tools/call with an OTel span and Prometheus +// invocation counters. Register once via server.AddReceivingMiddleware. +func ToolMiddleware() sdk.Middleware { + return func(next sdk.MethodHandler) sdk.MethodHandler { + return func(ctx context.Context, method string, req sdk.Request) (sdk.Result, error) { + if method != "tools/call" { + return next(ctx, method, req) + } + + toolName := "" + if ctr, ok := req.(*sdk.CallToolRequest); ok && ctr.Params != nil { + toolName = ctr.Params.Name + } + provider := providerOf(toolName) + + tracer := otel.Tracer("kagent-tools/mcp") + ctx, span := tracer.Start(ctx, "mcp.tool."+toolName) + defer span.End() + span.SetAttributes( + attribute.String("mcp.tool.name", toolName), + attribute.String("mcp.tool.provider", provider), + ) + + metrics.KagentToolsMCPInvocationsTotal.WithLabelValues(toolName, provider).Inc() + start := time.Now() + + res, err := next(ctx, method, req) + + span.SetAttributes(attribute.Float64("mcp.tool.duration_seconds", time.Since(start).Seconds())) + + failed := err != nil + if ctres, ok := res.(*sdk.CallToolResult); ok && ctres != nil && ctres.IsError { + failed = true + } + if failed { + metrics.KagentToolsMCPInvocationsFailureTotal.WithLabelValues(toolName, provider).Inc() + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } + } else { + span.SetStatus(codes.Ok, "ok") + } + return res, err + } + } +} diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go new file mode 100644 index 00000000..2289c776 --- /dev/null +++ b/internal/mcp/mcp_test.go @@ -0,0 +1,131 @@ +package mcp + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/kagent-dev/tools/internal/metrics" + sdk "github.com/modelcontextprotocol/go-sdk/mcp" + promtest "github.com/prometheus/client_golang/prometheus/testutil" +) + +// invokeMiddleware runs ToolMiddleware around next for a tools/call to toolName +// (registered to provider) and returns the result/error. +func invokeMiddleware(toolName, provider string, next sdk.MethodHandler) (sdk.Result, error) { + metrics.KagentToolsMCPInvocationsTotal.Reset() + metrics.KagentToolsMCPInvocationsFailureTotal.Reset() + providerByTool.Store(toolName, provider) + + h := ToolMiddleware()(next) + req := &sdk.CallToolRequest{Params: &sdk.CallToolParamsRaw{Name: toolName}} + return h(context.Background(), "tools/call", req) +} + +func TestHeader(t *testing.T) { + assert := func(cond bool, msg string) { + if !cond { + t.Fatal(msg) + } + } + + // nil request and request without Extra yield no headers. + assert(Header(nil) == nil, "nil request should give nil header") + assert(Header(&sdk.CallToolRequest{}) == nil, "request without Extra should give nil header") + + h := http.Header{"Authorization": []string{"Bearer t"}} + req := &sdk.CallToolRequest{Extra: &sdk.RequestExtra{Header: h}} + if got := Header(req).Get("Authorization"); got != "Bearer t" { + t.Fatalf("expected passthrough header, got %q", got) + } +} + +func TestAddToolRecordsProvider(t *testing.T) { + metrics.KagentToolsMCPRegisteredTools.Reset() + s := NewServer(&Implementation{Name: "t", Version: "v"}, nil) + + type in struct { + Name string `json:"name"` + } + AddTool(s, "myprovider", &Tool{Name: "my_tool"}, func(_ context.Context, _ *CallToolRequest, _ in) (*CallToolResult, any, error) { + return NewToolResultText("ok"), nil, nil + }) + + if got := providerOf("my_tool"); got != "myprovider" { + t.Errorf("providerOf: expected myprovider, got %q", got) + } + if got := providerOf("unknown_tool"); got != "" { + t.Errorf("providerOf unknown: expected empty, got %q", got) + } + if v := promtest.ToFloat64(metrics.KagentToolsMCPRegisteredTools.WithLabelValues("my_tool", "myprovider")); v != 1 { + t.Errorf("registered_tools metric: expected 1, got %v", v) + } +} + +// TestToolMiddleware_IsErrorIncrementsFailureCounter is the regression test for +// the bug identified in PR review: handlers signal tool-level failures via +// NewToolResultError(...) (IsError=true, Go error=nil), so checking only +// `err != nil` would never count these as failures. +func TestToolMiddleware_IsErrorIncrementsFailureCounter(t *testing.T) { + result, err := invokeMiddleware("failing_tool", "test", + func(_ context.Context, _ string, _ sdk.Request) (sdk.Result, error) { + return NewToolResultError("kubectl: resource not found"), nil + }, + ) + if err != nil { + t.Fatalf("expected nil Go error, got: %v", err) + } + if ctr, ok := result.(*sdk.CallToolResult); !ok || !ctr.IsError { + t.Fatal("expected result.IsError=true") + } + + total := promtest.ToFloat64(metrics.KagentToolsMCPInvocationsTotal.WithLabelValues("failing_tool", "test")) + if total != 1 { + t.Errorf("invocations_total: expected 1, got %v", total) + } + failures := promtest.ToFloat64(metrics.KagentToolsMCPInvocationsFailureTotal.WithLabelValues("failing_tool", "test")) + if failures != 1 { + t.Errorf("invocations_failure_total: expected 1, got %v (IsError=true was not counted)", failures) + } +} + +// TestToolMiddleware_SuccessDoesNotIncrementFailureCounter verifies a successful +// call leaves the failure counter untouched. +func TestToolMiddleware_SuccessDoesNotIncrementFailureCounter(t *testing.T) { + _, err := invokeMiddleware("success_tool", "test", + func(_ context.Context, _ string, _ sdk.Request) (sdk.Result, error) { + return NewToolResultText("all good"), nil + }, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + total := promtest.ToFloat64(metrics.KagentToolsMCPInvocationsTotal.WithLabelValues("success_tool", "test")) + if total != 1 { + t.Errorf("invocations_total: expected 1, got %v", total) + } + failures := promtest.ToFloat64(metrics.KagentToolsMCPInvocationsFailureTotal.WithLabelValues("success_tool", "test")) + if failures != 0 { + t.Errorf("invocations_failure_total: expected 0, got %v", failures) + } +} + +// TestToolMiddleware_GoErrorIncrementsFailureCounter verifies a real Go error is +// counted as a failure. +func TestToolMiddleware_GoErrorIncrementsFailureCounter(t *testing.T) { + _, err := invokeMiddleware("broken_tool", "test", + func(_ context.Context, _ string, _ sdk.Request) (sdk.Result, error) { + return nil, fmt.Errorf("connection refused") + }, + ) + if err == nil { + t.Fatal("expected a Go error, got nil") + } + + failures := promtest.ToFloat64(metrics.KagentToolsMCPInvocationsFailureTotal.WithLabelValues("broken_tool", "test")) + if failures != 1 { + t.Errorf("invocations_failure_total: expected 1, got %v", failures) + } +} diff --git a/internal/telemetry/config_test.go b/internal/telemetry/config_test.go index fe6454b5..e116a1e2 100644 --- a/internal/telemetry/config_test.go +++ b/internal/telemetry/config_test.go @@ -8,6 +8,18 @@ import ( "github.com/stretchr/testify/assert" ) +func TestGetEnvFloat(t *testing.T) { + const key = "KAGENT_TEST_ENV_FLOAT" + + assert.Equal(t, 1.5, getEnvFloat(key, 1.5)) // unset -> fallback + + t.Setenv(key, "0.25") + assert.Equal(t, 0.25, getEnvFloat(key, 1.5)) // parsed + + t.Setenv(key, "not-a-float") + assert.Equal(t, 1.5, getEnvFloat(key, 1.5)) // parse error -> fallback +} + func TestLoad(t *testing.T) { // Reset singleton for testing once = sync.Once{} diff --git a/internal/telemetry/middleware.go b/internal/telemetry/middleware.go index 720a99b8..3bc8f1a5 100644 --- a/internal/telemetry/middleware.go +++ b/internal/telemetry/middleware.go @@ -2,13 +2,8 @@ package telemetry import ( "context" - "encoding/json" - "fmt" "net/http" - "time" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" @@ -16,8 +11,6 @@ import ( "go.opentelemetry.io/otel/trace" ) -type ToolHandler func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) - // contextKey is used for storing HTTP context in the request context type contextKey string @@ -83,70 +76,6 @@ func ExtractTraceInfo(ctx context.Context) (traceID, spanID string) { return traceID, spanID } -func WithTracing(toolName string, handler ToolHandler) ToolHandler { - return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - tracer := otel.Tracer("kagent-tools/mcp") - - spanName := fmt.Sprintf("mcp.tool.%s", toolName) - ctx, span := tracer.Start(ctx, spanName) - defer span.End() - - // Extract HTTP headers from context and add as span attributes - headers := ExtractHTTPHeaders(ctx) - for key, value := range headers { - span.SetAttributes(attribute.String(fmt.Sprintf("http.header.%s", key), value)) - } - - // Extract parent trace information - parentTraceID, parentSpanID := ExtractTraceInfo(ctx) - if parentTraceID != "" { - span.SetAttributes( - attribute.String("http.parent_trace_id", parentTraceID), - attribute.String("http.parent_span_id", parentSpanID), - ) - } - - span.SetAttributes( - attribute.String("mcp.tool.name", toolName), - attribute.String("mcp.request.id", request.Params.Name), - ) - - if request.Params.Arguments != nil { - if argsJSON, err := json.Marshal(request.Params.Arguments); err == nil { - span.SetAttributes(attribute.String("mcp.request.arguments", string(argsJSON))) - } - } - - span.AddEvent("tool.execution.start") - startTime := time.Now() - - result, err := handler(ctx, request) - - duration := time.Since(startTime) - span.SetAttributes(attribute.Float64("mcp.tool.duration_seconds", duration.Seconds())) - - if err != nil { - span.RecordError(err) - span.SetStatus(codes.Error, err.Error()) - span.AddEvent("tool.execution.error", trace.WithAttributes( - attribute.String("error.message", err.Error()), - )) - } else { - span.SetStatus(codes.Ok, "tool execution completed successfully") - span.AddEvent("tool.execution.success") - - if result != nil { - span.SetAttributes(attribute.Bool("mcp.result.is_error", result.IsError)) - if result.Content != nil { - span.SetAttributes(attribute.Int("mcp.result.content_count", len(result.Content))) - } - } - } - - return result, err - } -} - func StartSpan(ctx context.Context, operationName string, attrs ...attribute.KeyValue) (context.Context, trace.Span) { tracer := otel.Tracer("kagent-tools") ctx, span := tracer.Start(ctx, operationName) @@ -170,10 +99,3 @@ func RecordSuccess(span trace.Span, message string) { func AddEvent(span trace.Span, name string, attrs ...attribute.KeyValue) { span.AddEvent(name, trace.WithAttributes(attrs...)) } - -// AdaptToolHandler adapts a telemetry.ToolHandler to a server.ToolHandlerFunc. -func AdaptToolHandler(th ToolHandler) server.ToolHandlerFunc { - return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return th(ctx, req) - } -} diff --git a/internal/telemetry/middleware_test.go b/internal/telemetry/middleware_test.go index bcbf494c..9d1a4517 100644 --- a/internal/telemetry/middleware_test.go +++ b/internal/telemetry/middleware_test.go @@ -3,19 +3,52 @@ package telemetry import ( "context" "errors" + "net/http" + "net/http/httptest" "testing" - "time" - "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/sdk/trace" - "go.opentelemetry.io/otel/trace/noop" ) +func TestHTTPMiddleware(t *testing.T) { + provider, _ := setupTracing() + defer func() { _ = provider.Shutdown(context.Background()) }() + + var gotHeaders map[string]string + var gotTraceID, gotSpanID string + next := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + gotHeaders = ExtractHTTPHeaders(r.Context()) + gotTraceID, gotSpanID = ExtractTraceInfo(r.Context()) + }) + + req := httptest.NewRequest(http.MethodGet, "/mcp", nil) + req.Header.Set("Authorization", "Bearer abc") + req.Header.Set("User-Agent", "agent/1.0") + req.Header.Set("X-Ignored", "nope") + + HTTPMiddleware(next).ServeHTTP(httptest.NewRecorder(), req) + + require.NotNil(t, gotHeaders) + assert.Equal(t, "Bearer abc", gotHeaders["Authorization"]) + assert.Equal(t, "agent/1.0", gotHeaders["User-Agent"]) + assert.NotContains(t, gotHeaders, "X-Ignored") + // No inbound trace context here, so trace/span IDs stay empty. + assert.Empty(t, gotTraceID) + assert.Empty(t, gotSpanID) +} + +func TestExtractHelpersDefaults(t *testing.T) { + assert.Empty(t, ExtractHTTPHeaders(context.Background())) + tid, sid := ExtractTraceInfo(context.Background()) + assert.Empty(t, tid) + assert.Empty(t, sid) +} + // InMemoryExporter is a simple in-memory exporter for testing type InMemoryExporter struct { spans []trace.ReadOnlySpan @@ -45,345 +78,7 @@ func setupTracing() (*trace.TracerProvider, *InMemoryExporter) { return provider, exporter } -func TestWithTracing(t *testing.T) { - // Initialize OpenTelemetry - provider, exporter := setupTracing() - defer func() { - if err := provider.Shutdown(context.Background()); err != nil { - t.Errorf("Failed to shutdown provider: %v", err) - } - }() - - // Create a test handler - testHandler := func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - textContent := mcp.NewTextContent("test response") - return &mcp.CallToolResult{ - IsError: false, - Content: []mcp.Content{textContent}, - }, nil - } - - // Wrap with tracing - tracedHandler := WithTracing("test-tool", testHandler) - - // Create test request - request := mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Name: "test-tool", - Arguments: map[string]interface{}{ - "param1": "value1", - "param2": 42, - }, - }, - } - - // Execute the handler - result, err := tracedHandler(context.Background(), request) - - // Force flush to ensure spans are exported - if err := provider.ForceFlush(context.Background()); err != nil { - t.Errorf("Failed to flush provider: %v", err) - } - - // Verify result - require.NoError(t, err) - assert.NotNil(t, result) - assert.False(t, result.IsError) - assert.Len(t, result.Content, 1) - textContent, ok := mcp.AsTextContent(result.Content[0]) - require.True(t, ok) - assert.Equal(t, "test response", textContent.Text) - - // Verify span was created - spans := exporter.GetSpans() - assert.Len(t, spans, 1) - - span := spans[0] - assert.Equal(t, "mcp.tool.test-tool", span.Name()) - assert.Equal(t, codes.Ok, span.Status().Code) - // Note: SDK may not preserve description in test environment - // assert.Equal(t, "tool execution completed successfully", span.Status().Description) - - // Verify attributes - attributes := span.Attributes() - hasToolName := false - hasRequestID := false - hasIsError := false - hasContentCount := false - - for _, attr := range attributes { - if attr.Key == "mcp.tool.name" && attr.Value.AsString() == "test-tool" { - hasToolName = true - } - if attr.Key == "mcp.request.id" && attr.Value.AsString() == "test-tool" { - hasRequestID = true - } - if attr.Key == "mcp.result.is_error" && attr.Value.AsBool() == false { - hasIsError = true - } - if attr.Key == "mcp.result.content_count" && attr.Value.AsInt64() == 1 { - hasContentCount = true - } - } - - assert.True(t, hasToolName) - assert.True(t, hasRequestID) - assert.True(t, hasIsError) - assert.True(t, hasContentCount) - - // Verify events - events := span.Events() - assert.Len(t, events, 2) - assert.Equal(t, "tool.execution.start", events[0].Name) - assert.Equal(t, "tool.execution.success", events[1].Name) -} - -func TestWithTracingError(t *testing.T) { - // Initialize OpenTelemetry - provider, exporter := setupTracing() - defer func() { - if err := provider.Shutdown(context.Background()); err != nil { - t.Errorf("Failed to shutdown provider: %v", err) - } - }() - - // Create a test handler that returns an error - testError := errors.New("test error") - testHandler := func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return nil, testError - } - - // Wrap with tracing - tracedHandler := WithTracing("test-tool", testHandler) - - // Create test request - request := mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Name: "test-tool", - }, - } - - // Execute the handler - result, err := tracedHandler(context.Background(), request) - - // Force flush to ensure spans are exported - if err := provider.ForceFlush(context.Background()); err != nil { - t.Errorf("Failed to flush provider: %v", err) - } - - // Verify result - assert.Error(t, err) - assert.Equal(t, testError, err) - assert.Nil(t, result) - - // Verify span was created with error - spans := exporter.GetSpans() - assert.Len(t, spans, 1) - - span := spans[0] - assert.Equal(t, "mcp.tool.test-tool", span.Name()) - assert.Equal(t, codes.Error, span.Status().Code) - // Note: SDK may not preserve description in test environment - // assert.Equal(t, "test error", span.Status().Description) - - // Verify events - span.RecordError() adds an "exception" event, plus our custom events - events := span.Events() - assert.Len(t, events, 3) - assert.Equal(t, "tool.execution.start", events[0].Name) - assert.Equal(t, "exception", events[1].Name) // Added by span.RecordError() - assert.Equal(t, "tool.execution.error", events[2].Name) -} - -func TestWithTracingErrorResult(t *testing.T) { - // Initialize OpenTelemetry - provider, exporter := setupTracing() - defer func() { - if err := provider.Shutdown(context.Background()); err != nil { - t.Errorf("Failed to shutdown provider: %v", err) - } - }() - - // Create a test handler that returns an error result - testHandler := func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - textContent := mcp.NewTextContent("error occurred") - return &mcp.CallToolResult{ - IsError: true, - Content: []mcp.Content{textContent}, - }, nil - } - - // Wrap with tracing - tracedHandler := WithTracing("test-tool", testHandler) - - // Create test request - request := mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Name: "test-tool", - }, - } - - // Execute the handler - result, err := tracedHandler(context.Background(), request) - - // Force flush to ensure spans are exported - if err := provider.ForceFlush(context.Background()); err != nil { - t.Errorf("Failed to flush provider: %v", err) - } - - // Verify result - require.NoError(t, err) - assert.NotNil(t, result) - assert.True(t, result.IsError) - - // Verify span was created successfully (no error from handler) - spans := exporter.GetSpans() - assert.Len(t, spans, 1) - - span := spans[0] - assert.Equal(t, "mcp.tool.test-tool", span.Name()) - assert.Equal(t, codes.Ok, span.Status().Code) - - // Verify attributes - attributes := span.Attributes() - hasIsError := false - hasContentCount := false - - for _, attr := range attributes { - if attr.Key == "mcp.result.is_error" && attr.Value.AsBool() == true { - hasIsError = true - } - if attr.Key == "mcp.result.content_count" && attr.Value.AsInt64() == 1 { - hasContentCount = true - } - } - - assert.True(t, hasIsError) - assert.True(t, hasContentCount) -} - -func TestWithTracingWithArguments(t *testing.T) { - // Initialize OpenTelemetry - provider, exporter := setupTracing() - defer func() { - if err := provider.Shutdown(context.Background()); err != nil { - t.Errorf("Failed to shutdown provider: %v", err) - } - }() - - // Create a test handler - testHandler := func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - textContent := mcp.NewTextContent("test response") - return &mcp.CallToolResult{ - IsError: false, - Content: []mcp.Content{textContent}, - }, nil - } - - // Wrap with tracing - tracedHandler := WithTracing("test-tool", testHandler) - - // Create test request with arguments - request := mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Name: "test-tool", - Arguments: map[string]interface{}{ - "string_param": "hello", - "number_param": 42, - "bool_param": true, - "array_param": []interface{}{"a", "b", "c"}, - "object_param": map[string]interface{}{ - "nested": "value", - }, - }, - }, - } - - // Execute the handler - result, err := tracedHandler(context.Background(), request) - - // Force flush to ensure spans are exported - if err := provider.ForceFlush(context.Background()); err != nil { - t.Errorf("Failed to flush provider: %v", err) - } - - // Verify result - require.NoError(t, err) - assert.NotNil(t, result) - assert.False(t, result.IsError) - - // Verify span was created - spans := exporter.GetSpans() - assert.Len(t, spans, 1) - - span := spans[0] - assert.Equal(t, "mcp.tool.test-tool", span.Name()) - - // Verify that arguments were added as an attribute (they are JSON-encoded) - attributes := span.Attributes() - hasArguments := false - - for _, attr := range attributes { - if attr.Key == "mcp.request.arguments" { - hasArguments = true - // Arguments should be JSON-encoded - assert.NotEmpty(t, attr.Value.AsString()) - } - } - - assert.True(t, hasArguments) -} - -func TestWithTracingNilArguments(t *testing.T) { - // Initialize OpenTelemetry - provider, exporter := setupTracing() - defer func() { - if err := provider.Shutdown(context.Background()); err != nil { - t.Errorf("Failed to shutdown provider: %v", err) - } - }() - - // Create a test handler - testHandler := func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - textContent := mcp.NewTextContent("test response") - return &mcp.CallToolResult{ - IsError: false, - Content: []mcp.Content{textContent}, - }, nil - } - - // Wrap with tracing - tracedHandler := WithTracing("test-tool", testHandler) - - // Create test request without arguments - request := mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Name: "test-tool", - }, - } - - // Execute the handler - result, err := tracedHandler(context.Background(), request) - - // Force flush to ensure spans are exported - if err := provider.ForceFlush(context.Background()); err != nil { - t.Errorf("Failed to flush provider: %v", err) - } - - // Verify result - require.NoError(t, err) - assert.NotNil(t, result) - assert.False(t, result.IsError) - - // Verify span was created - spans := exporter.GetSpans() - assert.Len(t, spans, 1) - - span := spans[0] - assert.Equal(t, "mcp.tool.test-tool", span.Name()) -} - func TestStartSpan(t *testing.T) { - // Initialize OpenTelemetry provider, exporter := setupTracing() defer func() { if err := provider.Shutdown(context.Background()); err != nil { @@ -391,30 +86,22 @@ func TestStartSpan(t *testing.T) { } }() - // Start a span _, span := StartSpan(context.Background(), "test-span", attribute.String("key1", "value1"), attribute.Int("key2", 42), ) - - // End the span span.End() - // Force flush to ensure spans are exported if err := provider.ForceFlush(context.Background()); err != nil { t.Errorf("Failed to flush provider: %v", err) } - // Verify span was created spans := exporter.GetSpans() assert.Len(t, spans, 1) - - resultSpan := spans[0] - assert.Equal(t, "test-span", resultSpan.Name()) + assert.Equal(t, "test-span", spans[0].Name()) } func TestStartSpanNoAttributes(t *testing.T) { - // Initialize OpenTelemetry provider, exporter := setupTracing() defer func() { if err := provider.Shutdown(context.Background()); err != nil { @@ -422,27 +109,19 @@ func TestStartSpanNoAttributes(t *testing.T) { } }() - // Start a span without attributes _, span := StartSpan(context.Background(), "test-span") - - // End the span span.End() - // Force flush to ensure spans are exported if err := provider.ForceFlush(context.Background()); err != nil { t.Errorf("Failed to flush provider: %v", err) } - // Verify span was created spans := exporter.GetSpans() assert.Len(t, spans, 1) - - resultSpan := spans[0] - assert.Equal(t, "test-span", resultSpan.Name()) + assert.Equal(t, "test-span", spans[0].Name()) } func TestRecordError(t *testing.T) { - // Initialize OpenTelemetry provider, exporter := setupTracing() defer func() { if err := provider.Shutdown(context.Background()); err != nil { @@ -450,33 +129,22 @@ func TestRecordError(t *testing.T) { } }() - // Start a span _, span := StartSpan(context.Background(), "test-span") - - // Record an error - testError := errors.New("test error") - RecordError(span, testError, "test error") - - // End the span + RecordError(span, errors.New("test error"), "test error") span.End() - // Force flush to ensure spans are exported if err := provider.ForceFlush(context.Background()); err != nil { t.Errorf("Failed to flush provider: %v", err) } - // Verify span was created with error spans := exporter.GetSpans() assert.Len(t, spans, 1) - - resultSpan := spans[0] - assert.Equal(t, "test-span", resultSpan.Name()) - assert.Equal(t, codes.Error, resultSpan.Status().Code) - assert.Equal(t, "test error", resultSpan.Status().Description) + assert.Equal(t, "test-span", spans[0].Name()) + assert.Equal(t, codes.Error, spans[0].Status().Code) + assert.Equal(t, "test error", spans[0].Status().Description) } func TestRecordSuccess(t *testing.T) { - // Initialize OpenTelemetry provider, exporter := setupTracing() defer func() { if err := provider.Shutdown(context.Background()); err != nil { @@ -484,33 +152,21 @@ func TestRecordSuccess(t *testing.T) { } }() - // Start a span _, span := StartSpan(context.Background(), "test-span") - - // Record success RecordSuccess(span, "operation completed successfully") - - // End the span span.End() - // Force flush to ensure spans are exported if err := provider.ForceFlush(context.Background()); err != nil { t.Errorf("Failed to flush provider: %v", err) } - // Verify span was created with success spans := exporter.GetSpans() assert.Len(t, spans, 1) - - resultSpan := spans[0] - assert.Equal(t, "test-span", resultSpan.Name()) - assert.Equal(t, codes.Ok, resultSpan.Status().Code) - // Note: SDK may not preserve description in test environment - // assert.Equal(t, "operation completed successfully", resultSpan.Status().Description) + assert.Equal(t, "test-span", spans[0].Name()) + assert.Equal(t, codes.Ok, spans[0].Status().Code) } func TestAddEvent(t *testing.T) { - // Initialize OpenTelemetry provider, exporter := setupTracing() defer func() { if err := provider.Shutdown(context.Background()); err != nil { @@ -518,38 +174,25 @@ func TestAddEvent(t *testing.T) { } }() - // Start a span _, span := StartSpan(context.Background(), "test-span") - - // Add an event AddEvent(span, "test-event", attribute.String("event_key", "event_value"), attribute.Int("event_num", 123), ) - - // End the span span.End() - // Force flush to ensure spans are exported if err := provider.ForceFlush(context.Background()); err != nil { t.Errorf("Failed to flush provider: %v", err) } - // Verify span was created with event spans := exporter.GetSpans() assert.Len(t, spans, 1) - - resultSpan := spans[0] - assert.Equal(t, "test-span", resultSpan.Name()) - - // Verify event - events := resultSpan.Events() + events := spans[0].Events() assert.Len(t, events, 1) assert.Equal(t, "test-event", events[0].Name) } func TestAddEventNoAttributes(t *testing.T) { - // Initialize OpenTelemetry provider, exporter := setupTracing() defer func() { if err := provider.Shutdown(context.Background()); err != nil { @@ -557,245 +200,17 @@ func TestAddEventNoAttributes(t *testing.T) { } }() - // Start a span _, span := StartSpan(context.Background(), "test-span") - - // Add an event without attributes AddEvent(span, "test-event") - - // End the span span.End() - // Force flush to ensure spans are exported if err := provider.ForceFlush(context.Background()); err != nil { t.Errorf("Failed to flush provider: %v", err) } - // Verify span was created with event spans := exporter.GetSpans() assert.Len(t, spans, 1) - - resultSpan := spans[0] - assert.Equal(t, "test-span", resultSpan.Name()) - - // Verify event - events := resultSpan.Events() + events := spans[0].Events() assert.Len(t, events, 1) assert.Equal(t, "test-event", events[0].Name) } - -func TestAdaptToolHandler(t *testing.T) { - // Create a test handler - testHandler := func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - textContent := mcp.NewTextContent("test response") - return &mcp.CallToolResult{ - IsError: false, - Content: []mcp.Content{textContent}, - }, nil - } - - // Adapt the handler - adapted := AdaptToolHandler(testHandler) - - // Create test request - request := mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Name: "test-tool", - }, - } - - // Execute the adapted handler - result, err := adapted(context.Background(), request) - - // Verify result - require.NoError(t, err) - assert.NotNil(t, result) - assert.False(t, result.IsError) - assert.Len(t, result.Content, 1) - textContent, ok := mcp.AsTextContent(result.Content[0]) - require.True(t, ok) - assert.Equal(t, "test response", textContent.Text) -} - -func TestWithTracingNilResult(t *testing.T) { - // Initialize OpenTelemetry - provider, exporter := setupTracing() - defer func() { - if err := provider.Shutdown(context.Background()); err != nil { - t.Errorf("Failed to shutdown provider: %v", err) - } - }() - - // Create a test handler that returns nil result - testHandler := func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return nil, nil - } - - // Wrap with tracing - tracedHandler := WithTracing("test-tool", testHandler) - - // Create test request - request := mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Name: "test-tool", - }, - } - - // Execute the handler - result, err := tracedHandler(context.Background(), request) - - // Force flush to ensure spans are exported - if err := provider.ForceFlush(context.Background()); err != nil { - t.Errorf("Failed to flush provider: %v", err) - } - - // Verify result - require.NoError(t, err) - assert.Nil(t, result) - - // Verify span was created - spans := exporter.GetSpans() - assert.Len(t, spans, 1) - - span := spans[0] - assert.Equal(t, "mcp.tool.test-tool", span.Name()) - assert.Equal(t, codes.Ok, span.Status().Code) -} - -func TestWithTracingNoContent(t *testing.T) { - // Initialize OpenTelemetry - provider, exporter := setupTracing() - defer func() { - if err := provider.Shutdown(context.Background()); err != nil { - t.Errorf("Failed to shutdown provider: %v", err) - } - }() - - // Create a test handler that returns result with no content - testHandler := func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return &mcp.CallToolResult{ - IsError: false, - Content: []mcp.Content{}, - }, nil - } - - // Wrap with tracing - tracedHandler := WithTracing("test-tool", testHandler) - - // Create test request - request := mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Name: "test-tool", - }, - } - - // Execute the handler - result, err := tracedHandler(context.Background(), request) - - // Force flush to ensure spans are exported - if err := provider.ForceFlush(context.Background()); err != nil { - t.Errorf("Failed to flush provider: %v", err) - } - - // Verify result - require.NoError(t, err) - assert.NotNil(t, result) - assert.False(t, result.IsError) - assert.Len(t, result.Content, 0) - - // Verify span was created - spans := exporter.GetSpans() - assert.Len(t, spans, 1) - - span := spans[0] - assert.Equal(t, "mcp.tool.test-tool", span.Name()) - assert.Equal(t, codes.Ok, span.Status().Code) - - // Verify attributes - attributes := span.Attributes() - hasContentCount := false - - for _, attr := range attributes { - if attr.Key == "mcp.result.content_count" && attr.Value.AsInt64() == 0 { - hasContentCount = true - } - } - - assert.True(t, hasContentCount) -} - -func TestWithTracingNoopTracer(t *testing.T) { - // Set up noop tracer provider - otel.SetTracerProvider(noop.NewTracerProvider()) - - // Create a test handler - testHandler := func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - textContent := mcp.NewTextContent("test response") - return &mcp.CallToolResult{ - IsError: false, - Content: []mcp.Content{textContent}, - }, nil - } - - // Wrap with tracing - tracedHandler := WithTracing("test-tool", testHandler) - - // Create test request - request := mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Name: "test-tool", - }, - } - - // Execute the handler - result, err := tracedHandler(context.Background(), request) - - // Verify result (should work normally with noop tracer) - require.NoError(t, err) - assert.NotNil(t, result) - assert.False(t, result.IsError) - assert.Len(t, result.Content, 1) - textContent, ok := mcp.AsTextContent(result.Content[0]) - require.True(t, ok) - assert.Equal(t, "test response", textContent.Text) -} - -func TestWithTracingPerformance(t *testing.T) { - // Initialize OpenTelemetry - provider, _ := setupTracing() - defer func() { - if err := provider.Shutdown(context.Background()); err != nil { - t.Errorf("Failed to shutdown provider: %v", err) - } - }() - - // Create a test handler - testHandler := func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - textContent := mcp.NewTextContent("test response") - return &mcp.CallToolResult{ - IsError: false, - Content: []mcp.Content{textContent}, - }, nil - } - - // Wrap with tracing - tracedHandler := WithTracing("test-tool", testHandler) - - // Create test request - request := mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Name: "test-tool", - }, - } - - // Time execution - start := time.Now() - for i := 0; i < 100; i++ { - _, err := tracedHandler(context.Background(), request) - require.NoError(t, err) - } - duration := time.Since(start) - - // Verify performance is reasonable (should complete in less than 1 second) - assert.Less(t, duration, time.Second) -} diff --git a/pkg/argo/argo.go b/pkg/argo/argo.go index 758a4fb2..01aeaa0e 100644 --- a/pkg/argo/argo.go +++ b/pkg/argo/argo.go @@ -14,36 +14,43 @@ import ( "time" "github.com/kagent-dev/tools/internal/commands" - "github.com/kagent-dev/tools/internal/telemetry" + mcp "github.com/kagent-dev/tools/internal/mcp" "github.com/kagent-dev/tools/pkg/utils" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" ) -// Argo Rollouts tools +type verifyArgoRolloutsControllerInstallInput struct { + Namespace string `json:"namespace" jsonschema:"The namespace where Argo Rollouts is installed"` + Label string `json:"label" jsonschema:"The label of the Argo Rollouts controller pods"` +} -func handleVerifyArgoRolloutsControllerInstall(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - ns := mcp.ParseString(request, "namespace", "argo-rollouts") - label := mcp.ParseString(request, "label", "app.kubernetes.io/component=rollouts-controller") +func handleVerifyArgoRolloutsControllerInstall(ctx context.Context, request *mcp.CallToolRequest, in verifyArgoRolloutsControllerInstallInput) (*mcp.CallToolResult, any, error) { + ns := in.Namespace + if ns == "" { + ns = "argo-rollouts" + } + label := in.Label + if label == "" { + label = "app.kubernetes.io/component=rollouts-controller" + } cmd := []string{"get", "pods", "-n", ns, "-l", label, "-o", "jsonpath={.items[*].status.phase}"} output, err := runArgoRolloutCommand(ctx, cmd) if err != nil { - return mcp.NewToolResultError("Error: " + err.Error()), nil + return mcp.NewToolResultError("Error: " + err.Error()), nil, nil } output = strings.TrimSpace(output) if output == "" { - return mcp.NewToolResultText("Error: No pods found"), nil + return mcp.NewToolResultText("Error: No pods found"), nil, nil } if strings.HasPrefix(output, "Error") { - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } podStatuses := strings.Fields(output) if len(podStatuses) == 0 { - return mcp.NewToolResultText("Error: No pod statuses returned"), nil + return mcp.NewToolResultText("Error: No pod statuses returned"), nil, nil } allRunning := true @@ -55,24 +62,25 @@ func handleVerifyArgoRolloutsControllerInstall(ctx context.Context, request mcp. } if allRunning { - return mcp.NewToolResultText("All pods are running"), nil - } else { - return mcp.NewToolResultText("Error: Not all pods are running (" + strings.Join(podStatuses, " ") + ")"), nil + return mcp.NewToolResultText("All pods are running"), nil, nil } + return mcp.NewToolResultText("Error: Not all pods are running (" + strings.Join(podStatuses, " ") + ")"), nil, nil } -func handleVerifyKubectlPluginInstall(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +type verifyKubectlPluginInstallInput struct{} + +func handleVerifyKubectlPluginInstall(ctx context.Context, request *mcp.CallToolRequest, in verifyKubectlPluginInstallInput) (*mcp.CallToolResult, any, error) { args := []string{"argo", "rollouts", "version"} output, err := runArgoRolloutCommand(ctx, args) if err != nil { - return mcp.NewToolResultText("Kubectl Argo Rollouts plugin is not installed: " + err.Error()), nil + return mcp.NewToolResultText("Kubectl Argo Rollouts plugin is not installed: " + err.Error()), nil, nil } if strings.HasPrefix(output, "Error") { - return mcp.NewToolResultText("Kubectl Argo Rollouts plugin is not installed: " + output), nil + return mcp.NewToolResultText("Kubectl Argo Rollouts plugin is not installed: " + output), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } func runArgoRolloutCommand(ctx context.Context, args []string) (string, error) { @@ -83,81 +91,86 @@ func runArgoRolloutCommand(ctx context.Context, args []string) (string, error) { Execute(ctx) } -func handlePromoteRollout(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - rolloutName := mcp.ParseString(request, "rollout_name", "") - ns := mcp.ParseString(request, "namespace", "") - fullStr := mcp.ParseString(request, "full", "false") - full := fullStr == "true" +type promoteRolloutInput struct { + RolloutName string `json:"rollout_name" jsonschema:"The name of the rollout to promote"` + Namespace string `json:"namespace" jsonschema:"The namespace of the rollout"` + Full bool `json:"full" jsonschema:"Promote the rollout to the final step"` +} - if rolloutName == "" { - return mcp.NewToolResultError("rollout_name parameter is required"), nil +func handlePromoteRollout(ctx context.Context, request *mcp.CallToolRequest, in promoteRolloutInput) (*mcp.CallToolResult, any, error) { + if in.RolloutName == "" { + return mcp.NewToolResultError("rollout_name parameter is required"), nil, nil } cmd := []string{"argo", "rollouts", "promote"} - if ns != "" { - cmd = append(cmd, "-n", ns) + if in.Namespace != "" { + cmd = append(cmd, "-n", in.Namespace) } - cmd = append(cmd, rolloutName) - if full { + cmd = append(cmd, in.RolloutName) + if in.Full { cmd = append(cmd, "--full") } output, err := runArgoRolloutCommand(ctx, cmd) if err != nil { - return mcp.NewToolResultError("Error promoting rollout: " + err.Error()), nil + return mcp.NewToolResultError("Error promoting rollout: " + err.Error()), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handlePauseRollout(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - rolloutName := mcp.ParseString(request, "rollout_name", "") - ns := mcp.ParseString(request, "namespace", "") +type pauseRolloutInput struct { + RolloutName string `json:"rollout_name" jsonschema:"The name of the rollout to pause"` + Namespace string `json:"namespace" jsonschema:"The namespace of the rollout"` +} - if rolloutName == "" { - return mcp.NewToolResultError("rollout_name parameter is required"), nil +func handlePauseRollout(ctx context.Context, request *mcp.CallToolRequest, in pauseRolloutInput) (*mcp.CallToolResult, any, error) { + if in.RolloutName == "" { + return mcp.NewToolResultError("rollout_name parameter is required"), nil, nil } cmd := []string{"argo", "rollouts", "pause"} - if ns != "" { - cmd = append(cmd, "-n", ns) + if in.Namespace != "" { + cmd = append(cmd, "-n", in.Namespace) } - cmd = append(cmd, rolloutName) + cmd = append(cmd, in.RolloutName) output, err := runArgoRolloutCommand(ctx, cmd) if err != nil { - return mcp.NewToolResultError("Error pausing rollout: " + err.Error()), nil + return mcp.NewToolResultError("Error pausing rollout: " + err.Error()), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleSetRolloutImage(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - rolloutName := mcp.ParseString(request, "rollout_name", "") - containerImage := mcp.ParseString(request, "container_image", "") - ns := mcp.ParseString(request, "namespace", "") +type setRolloutImageInput struct { + RolloutName string `json:"rollout_name" jsonschema:"The name of the rollout to set the image for"` + ContainerImage string `json:"container_image" jsonschema:"The container image to set for the rollout"` + Namespace string `json:"namespace" jsonschema:"The namespace of the rollout"` +} - if rolloutName == "" { - return mcp.NewToolResultError("rollout_name parameter is required"), nil +func handleSetRolloutImage(ctx context.Context, request *mcp.CallToolRequest, in setRolloutImageInput) (*mcp.CallToolResult, any, error) { + if in.RolloutName == "" { + return mcp.NewToolResultError("rollout_name parameter is required"), nil, nil } - if containerImage == "" { - return mcp.NewToolResultError("container_image parameter is required"), nil + if in.ContainerImage == "" { + return mcp.NewToolResultError("container_image parameter is required"), nil, nil } - cmd := []string{"argo", "rollouts", "set", "image", rolloutName, containerImage} - if ns != "" { - cmd = append(cmd, "-n", ns) + cmd := []string{"argo", "rollouts", "set", "image", in.RolloutName, in.ContainerImage} + if in.Namespace != "" { + cmd = append(cmd, "-n", in.Namespace) } output, err := runArgoRolloutCommand(ctx, cmd) if err != nil { - return mcp.NewToolResultError("Error setting rollout image: " + err.Error()), nil + return mcp.NewToolResultError("Error setting rollout image: " + err.Error()), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -// Gateway Plugin Status struct +// GatewayPluginStatus struct type GatewayPluginStatus struct { Installed bool `json:"installed"` Version string `json:"version,omitempty"` @@ -284,11 +297,22 @@ data: } } -func handleVerifyGatewayPlugin(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - version := mcp.ParseString(request, "version", "") - namespace := mcp.ParseString(request, "namespace", "argo-rollouts") - shouldInstallStr := mcp.ParseString(request, "should_install", "true") - shouldInstall := shouldInstallStr == "true" +type verifyGatewayPluginInput struct { + Version string `json:"version" jsonschema:"The version of the plugin to check"` + Namespace string `json:"namespace" jsonschema:"The namespace for the plugin resources"` + ShouldInstall *bool `json:"should_install" jsonschema:"Whether to install the plugin if not found"` +} + +func handleVerifyGatewayPlugin(ctx context.Context, request *mcp.CallToolRequest, in verifyGatewayPluginInput) (*mcp.CallToolResult, any, error) { + version := in.Version + namespace := in.Namespace + if namespace == "" { + namespace = "argo-rollouts" + } + shouldInstall := true + if in.ShouldInstall != nil { + shouldInstall = *in.ShouldInstall + } // Check if ConfigMap exists and is configured cmd := []string{"get", "configmap", "argo-rollouts-config", "-n", namespace, "-o", "yaml"} @@ -298,7 +322,7 @@ func handleVerifyGatewayPlugin(ctx context.Context, request mcp.CallToolRequest) Installed: true, ErrorMessage: "Gateway API plugin is already configured", } - return mcp.NewToolResultText(status.String()), nil + return mcp.NewToolResultText(status.String()), nil, nil } if !shouldInstall { @@ -306,18 +330,29 @@ func handleVerifyGatewayPlugin(ctx context.Context, request mcp.CallToolRequest) Installed: false, ErrorMessage: "Gateway API plugin is not configured and installation is disabled", } - return mcp.NewToolResultText(status.String()), nil + return mcp.NewToolResultText(status.String()), nil, nil } // Configure plugin status := configureGatewayPlugin(ctx, version, namespace) - return mcp.NewToolResultText(status.String()), nil + return mcp.NewToolResultText(status.String()), nil, nil +} + +type checkPluginLogsInput struct { + Namespace string `json:"namespace" jsonschema:"The namespace of the plugin resources"` + Timeout int `json:"timeout" jsonschema:"Timeout for log collection in seconds"` } -func handleCheckPluginLogs(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - namespace := mcp.ParseString(request, "namespace", "argo-rollouts") +func handleCheckPluginLogs(ctx context.Context, request *mcp.CallToolRequest, in checkPluginLogsInput) (*mcp.CallToolResult, any, error) { + namespace := in.Namespace + if namespace == "" { + namespace = "argo-rollouts" + } // timeout parameter is parsed but not used currently - _ = mcp.ParseString(request, "timeout", "60") + if in.Timeout == 0 { + in.Timeout = 60 + } + _ = in.Timeout cmd := []string{"logs", "-n", namespace, "-l", "app.kubernetes.io/name=argo-rollouts", "--tail", "100"} output, err := runArgoRolloutCommand(ctx, cmd) @@ -326,7 +361,7 @@ func handleCheckPluginLogs(ctx context.Context, request mcp.CallToolRequest) (*m Installed: false, ErrorMessage: err.Error(), } - return mcp.NewToolResultText(status.String()), nil + return mcp.NewToolResultText(status.String()), nil, nil } // Parse download information @@ -344,19 +379,30 @@ func handleCheckPluginLogs(ctx context.Context, request mcp.CallToolRequest) (*m Architecture: versionMatches[2], DownloadTime: downloadTime, } - return mcp.NewToolResultText(status.String()), nil + return mcp.NewToolResultText(status.String()), nil, nil } status := GatewayPluginStatus{ Installed: false, ErrorMessage: "Plugin installation not found in logs", } - return mcp.NewToolResultText(status.String()), nil + return mcp.NewToolResultText(status.String()), nil, nil +} + +type listRolloutsInput struct { + Namespace string `json:"namespace" jsonschema:"The namespace of the rollout"` + Type string `json:"type" jsonschema:"What to list: rollouts or experiments"` } -func handleListRollouts(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - ns := mcp.ParseString(request, "namespace", "argo-rollouts") - tt := mcp.ParseString(request, "type", "rollouts") +func handleListRollouts(ctx context.Context, request *mcp.CallToolRequest, in listRolloutsInput) (*mcp.CallToolResult, any, error) { + ns := in.Namespace + if ns == "" { + ns = "argo-rollouts" + } + tt := in.Type + if tt == "" { + tt = "rollouts" + } cmd := []string{"argo", "rollouts", "list", tt} if ns != "" { @@ -365,67 +411,58 @@ func handleListRollouts(ctx context.Context, request mcp.CallToolRequest) (*mcp. output, err := runArgoRolloutCommand(ctx, cmd) if err != nil { - return mcp.NewToolResultError("Error listing rollouts: " + err.Error()), nil + return mcp.NewToolResultError("Error listing rollouts: " + err.Error()), nil, nil } if strings.HasPrefix(output, "Error") { - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func RegisterTools(s *server.MCPServer, readOnly bool) { +func RegisterTools(s *mcp.Server, readOnly bool) { // Read-only tools - always registered - s.AddTool(mcp.NewTool("argo_verify_argo_rollouts_controller_install", - mcp.WithDescription("Verify that the Argo Rollouts controller is installed and running"), - mcp.WithString("namespace", mcp.Description("The namespace where Argo Rollouts is installed")), - mcp.WithString("label", mcp.Description("The label of the Argo Rollouts controller pods")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("argo_verify_argo_rollouts_controller_install", handleVerifyArgoRolloutsControllerInstall))) - - s.AddTool(mcp.NewTool("argo_verify_kubectl_plugin_install", - mcp.WithDescription("Verify that the kubectl Argo Rollouts plugin is installed"), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("argo_verify_kubectl_plugin_install", handleVerifyKubectlPluginInstall))) - - s.AddTool(mcp.NewTool("argo_rollouts_list", - mcp.WithDescription("List rollouts or experiments"), - mcp.WithString("namespace", mcp.Description("The namespace of the rollout")), - mcp.WithString("type", mcp.Description("What to list: rollouts or experiments"), mcp.DefaultString("rollouts")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("argo_rollouts_list", handleListRollouts))) - - s.AddTool(mcp.NewTool("argo_check_plugin_logs", - mcp.WithDescription("Check the logs of the Argo Rollouts Gateway API plugin"), - mcp.WithString("namespace", mcp.Description("The namespace of the plugin resources")), - mcp.WithString("timeout", mcp.Description("Timeout for log collection in seconds")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("argo_check_plugin_logs", handleCheckPluginLogs))) + mcp.AddTool(s, "argo", &mcp.Tool{ + Name: "argo_verify_argo_rollouts_controller_install", + Description: "Verify that the Argo Rollouts controller is installed and running", + }, handleVerifyArgoRolloutsControllerInstall) + + mcp.AddTool(s, "argo", &mcp.Tool{ + Name: "argo_verify_kubectl_plugin_install", + Description: "Verify that the kubectl Argo Rollouts plugin is installed", + }, handleVerifyKubectlPluginInstall) + + mcp.AddTool(s, "argo", &mcp.Tool{ + Name: "argo_rollouts_list", + Description: "List rollouts or experiments", + }, handleListRollouts) + + mcp.AddTool(s, "argo", &mcp.Tool{ + Name: "argo_check_plugin_logs", + Description: "Check the logs of the Argo Rollouts Gateway API plugin", + }, handleCheckPluginLogs) // Write tools - only registered when not in read-only mode if !readOnly { - s.AddTool(mcp.NewTool("argo_promote_rollout", - mcp.WithDescription("Promote a paused rollout to the next step"), - mcp.WithString("rollout_name", mcp.Description("The name of the rollout to promote"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("The namespace of the rollout")), - mcp.WithString("full", mcp.Description("Promote the rollout to the final step")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("argo_promote_rollout", handlePromoteRollout))) - - s.AddTool(mcp.NewTool("argo_pause_rollout", - mcp.WithDescription("Pause a rollout"), - mcp.WithString("rollout_name", mcp.Description("The name of the rollout to pause"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("The namespace of the rollout")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("argo_pause_rollout", handlePauseRollout))) - - s.AddTool(mcp.NewTool("argo_set_rollout_image", - mcp.WithDescription("Set the image of a rollout"), - mcp.WithString("rollout_name", mcp.Description("The name of the rollout to set the image for"), mcp.Required()), - mcp.WithString("container_image", mcp.Description("The container image to set for the rollout"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("The namespace of the rollout")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("argo_set_rollout_image", handleSetRolloutImage))) - - s.AddTool(mcp.NewTool("argo_verify_gateway_plugin", - mcp.WithDescription("Verify the installation status of the Argo Rollouts Gateway API plugin"), - mcp.WithString("version", mcp.Description("The version of the plugin to check")), - mcp.WithString("namespace", mcp.Description("The namespace for the plugin resources")), - mcp.WithString("should_install", mcp.Description("Whether to install the plugin if not found")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("argo_verify_gateway_plugin", handleVerifyGatewayPlugin))) + mcp.AddTool(s, "argo", &mcp.Tool{ + Name: "argo_promote_rollout", + Description: "Promote a paused rollout to the next step", + }, handlePromoteRollout) + + mcp.AddTool(s, "argo", &mcp.Tool{ + Name: "argo_pause_rollout", + Description: "Pause a rollout", + }, handlePauseRollout) + + mcp.AddTool(s, "argo", &mcp.Tool{ + Name: "argo_set_rollout_image", + Description: "Set the image of a rollout", + }, handleSetRolloutImage) + + mcp.AddTool(s, "argo", &mcp.Tool{ + Name: "argo_verify_gateway_plugin", + Description: "Verify the installation status of the Argo Rollouts Gateway API plugin", + }, handleVerifyGatewayPlugin) } } diff --git a/pkg/argo/argo_test.go b/pkg/argo/argo_test.go index ce00d7b8..148ef1b2 100644 --- a/pkg/argo/argo_test.go +++ b/pkg/argo/argo_test.go @@ -6,19 +6,18 @@ import ( "testing" "github.com/kagent-dev/tools/internal/cmd" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" + mcp "github.com/kagent-dev/tools/internal/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestRegisterTools(t *testing.T) { t.Run("read-write", func(t *testing.T) { - s := server.NewMCPServer("test", "v0.0.1") + s := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "v0.0.1"}, nil) RegisterTools(s, false) }) t.Run("read-only", func(t *testing.T) { - s := server.NewMCPServer("test", "v0.0.1") + s := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "v0.0.1"}, nil) RegisterTools(s, true) }) } @@ -29,7 +28,7 @@ func TestHandleListRollouts(t *testing.T) { mock.AddCommandString("kubectl", []string{"argo", "rollouts", "list", "rollouts", "-n", "argo-rollouts"}, "NAME STATUS\nmyapp Healthy", nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, err := handleListRollouts(ctx, mcp.CallToolRequest{}) + result, _, err := handleListRollouts(ctx, &mcp.CallToolRequest{}, listRolloutsInput{}) assert.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "myapp") @@ -40,9 +39,7 @@ func TestHandleListRollouts(t *testing.T) { mock.AddCommandString("kubectl", []string{"argo", "rollouts", "list", "experiments", "-n", "prod"}, "NAME", nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{"type": "experiments", "namespace": "prod"} - result, err := handleListRollouts(ctx, req) + result, _, err := handleListRollouts(ctx, &mcp.CallToolRequest{}, listRolloutsInput{Type: "experiments", Namespace: "prod"}) assert.NoError(t, err) assert.False(t, result.IsError) }) @@ -52,7 +49,7 @@ func TestHandleListRollouts(t *testing.T) { mock.AddCommandString("kubectl", []string{"argo", "rollouts", "list", "rollouts", "-n", "argo-rollouts"}, "", assert.AnError) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, err := handleListRollouts(ctx, mcp.CallToolRequest{}) + result, _, err := handleListRollouts(ctx, &mcp.CallToolRequest{}, listRolloutsInput{}) assert.NoError(t, err) assert.True(t, result.IsError) assert.Contains(t, getResultText(result), "Error listing rollouts") @@ -67,7 +64,7 @@ Download complete, it took 1.5s` mock.AddCommandString("kubectl", []string{"logs", "-n", "argo-rollouts", "-l", "app.kubernetes.io/name=argo-rollouts", "--tail", "100"}, logs, nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, err := handleCheckPluginLogs(ctx, mcp.CallToolRequest{}) + result, _, err := handleCheckPluginLogs(ctx, &mcp.CallToolRequest{}, checkPluginLogsInput{}) assert.NoError(t, err) assert.Contains(t, getResultText(result), "0.5.0") assert.Contains(t, getResultText(result), `"installed": true`) @@ -78,7 +75,7 @@ Download complete, it took 1.5s` mock.AddCommandString("kubectl", []string{"logs", "-n", "argo-rollouts", "-l", "app.kubernetes.io/name=argo-rollouts", "--tail", "100"}, "no plugin here", nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, err := handleCheckPluginLogs(ctx, mcp.CallToolRequest{}) + result, _, err := handleCheckPluginLogs(ctx, &mcp.CallToolRequest{}, checkPluginLogsInput{}) assert.NoError(t, err) assert.Contains(t, getResultText(result), "Plugin installation not found") }) @@ -88,7 +85,7 @@ Download complete, it took 1.5s` mock.AddCommandString("kubectl", []string{"logs", "-n", "argo-rollouts", "-l", "app.kubernetes.io/name=argo-rollouts", "--tail", "100"}, "", assert.AnError) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, err := handleCheckPluginLogs(ctx, mcp.CallToolRequest{}) + result, _, err := handleCheckPluginLogs(ctx, &mcp.CallToolRequest{}, checkPluginLogsInput{}) assert.NoError(t, err) assert.Contains(t, getResultText(result), `"installed": false`) }) @@ -122,7 +119,7 @@ func TestHandleVerifyGatewayPluginAlreadyConfigured(t *testing.T) { mock.AddCommandString("kubectl", []string{"get", "configmap", "argo-rollouts-config", "-n", "argo-rollouts", "-o", "yaml"}, "data:\n trafficRouterPlugins: argoproj-labs/gatewayAPI", nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, err := handleVerifyGatewayPlugin(ctx, mcp.CallToolRequest{}) + result, _, err := handleVerifyGatewayPlugin(ctx, &mcp.CallToolRequest{}, verifyGatewayPluginInput{}) assert.NoError(t, err) assert.Contains(t, getResultText(result), "already configured") } @@ -134,7 +131,7 @@ func TestHandleVerifyArgoRolloutsControllerInstallStatuses(t *testing.T) { mock := cmd.NewMockShellExecutor() mock.AddCommandString("kubectl", baseCmd, "Running Running", nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, err := handleVerifyArgoRolloutsControllerInstall(ctx, mcp.CallToolRequest{}) + result, _, err := handleVerifyArgoRolloutsControllerInstall(ctx, &mcp.CallToolRequest{}, verifyArgoRolloutsControllerInstallInput{}) assert.NoError(t, err) assert.Contains(t, getResultText(result), "All pods are running") }) @@ -143,7 +140,7 @@ func TestHandleVerifyArgoRolloutsControllerInstallStatuses(t *testing.T) { mock := cmd.NewMockShellExecutor() mock.AddCommandString("kubectl", baseCmd, "Running Pending", nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, err := handleVerifyArgoRolloutsControllerInstall(ctx, mcp.CallToolRequest{}) + result, _, err := handleVerifyArgoRolloutsControllerInstall(ctx, &mcp.CallToolRequest{}, verifyArgoRolloutsControllerInstallInput{}) assert.NoError(t, err) assert.Contains(t, getResultText(result), "Not all pods are running") }) @@ -152,7 +149,7 @@ func TestHandleVerifyArgoRolloutsControllerInstallStatuses(t *testing.T) { mock := cmd.NewMockShellExecutor() mock.AddCommandString("kubectl", baseCmd, "", nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, err := handleVerifyArgoRolloutsControllerInstall(ctx, mcp.CallToolRequest{}) + result, _, err := handleVerifyArgoRolloutsControllerInstall(ctx, &mcp.CallToolRequest{}, verifyArgoRolloutsControllerInstallInput{}) assert.NoError(t, err) assert.Contains(t, getResultText(result), "No pods found") }) @@ -161,7 +158,7 @@ func TestHandleVerifyArgoRolloutsControllerInstallStatuses(t *testing.T) { mock := cmd.NewMockShellExecutor() mock.AddCommandString("kubectl", baseCmd, "", assert.AnError) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, err := handleVerifyArgoRolloutsControllerInstall(ctx, mcp.CallToolRequest{}) + result, _, err := handleVerifyArgoRolloutsControllerInstall(ctx, &mcp.CallToolRequest{}, verifyArgoRolloutsControllerInstallInput{}) assert.NoError(t, err) assert.True(t, result.IsError) }) @@ -172,7 +169,7 @@ func getResultText(result *mcp.CallToolResult) string { if result == nil || len(result.Content) == 0 { return "" } - if textContent, ok := result.Content[0].(mcp.TextContent); ok { + if textContent, ok := result.Content[0].(*mcp.TextContent); ok { return textContent.Text } return "" @@ -189,12 +186,7 @@ func TestHandlePromoteRollout(t *testing.T) { mock.AddCommandString("kubectl", []string{"argo", "rollouts", "promote", "myapp"}, expectedOutput, nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "rollout_name": "myapp", - } - - result, err := handlePromoteRollout(ctx, request) + result, _, err := handlePromoteRollout(ctx, &mcp.CallToolRequest{}, promoteRolloutInput{RolloutName: "myapp"}) assert.NoError(t, err) assert.NotNil(t, result) @@ -215,13 +207,7 @@ func TestHandlePromoteRollout(t *testing.T) { mock.AddCommandString("kubectl", []string{"argo", "rollouts", "promote", "-n", "production", "myapp"}, expectedOutput, nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "rollout_name": "myapp", - "namespace": "production", - } - - result, err := handlePromoteRollout(ctx, request) + result, _, err := handlePromoteRollout(ctx, &mcp.CallToolRequest{}, promoteRolloutInput{RolloutName: "myapp", Namespace: "production"}) assert.NoError(t, err) assert.False(t, result.IsError) @@ -240,13 +226,7 @@ func TestHandlePromoteRollout(t *testing.T) { mock.AddCommandString("kubectl", []string{"argo", "rollouts", "promote", "myapp", "--full"}, expectedOutput, nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "rollout_name": "myapp", - "full": "true", - } - - result, err := handlePromoteRollout(ctx, request) + result, _, err := handlePromoteRollout(ctx, &mcp.CallToolRequest{}, promoteRolloutInput{RolloutName: "myapp", Full: true}) assert.NoError(t, err) assert.False(t, result.IsError) @@ -262,12 +242,7 @@ func TestHandlePromoteRollout(t *testing.T) { mock := cmd.NewMockShellExecutor() ctx := cmd.WithShellExecutor(context.Background(), mock) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - // Missing rollout_name - } - - result, err := handlePromoteRollout(ctx, request) + result, _, err := handlePromoteRollout(ctx, &mcp.CallToolRequest{}, promoteRolloutInput{}) assert.NoError(t, err) assert.True(t, result.IsError) assert.Contains(t, getResultText(result), "rollout_name parameter is required") @@ -282,12 +257,7 @@ func TestHandlePromoteRollout(t *testing.T) { mock.AddCommandString("kubectl", []string{"argo", "rollouts", "promote", "myapp"}, "", assert.AnError) ctx := cmd.WithShellExecutor(context.Background(), mock) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "rollout_name": "myapp", - } - - result, err := handlePromoteRollout(ctx, request) + result, _, err := handlePromoteRollout(ctx, &mcp.CallToolRequest{}, promoteRolloutInput{RolloutName: "myapp"}) assert.NoError(t, err) // MCP handlers should not return Go errors assert.True(t, result.IsError) @@ -304,12 +274,7 @@ func TestHandlePauseRollout(t *testing.T) { mock.AddCommandString("kubectl", []string{"argo", "rollouts", "pause", "myapp"}, expectedOutput, nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "rollout_name": "myapp", - } - - result, err := handlePauseRollout(ctx, request) + result, _, err := handlePauseRollout(ctx, &mcp.CallToolRequest{}, pauseRolloutInput{RolloutName: "myapp"}) assert.NoError(t, err) assert.NotNil(t, result) @@ -333,13 +298,7 @@ func TestHandlePauseRollout(t *testing.T) { mock.AddCommandString("kubectl", []string{"argo", "rollouts", "pause", "-n", "production", "myapp"}, expectedOutput, nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "rollout_name": "myapp", - "namespace": "production", - } - - result, err := handlePauseRollout(ctx, request) + result, _, err := handlePauseRollout(ctx, &mcp.CallToolRequest{}, pauseRolloutInput{RolloutName: "myapp", Namespace: "production"}) assert.NoError(t, err) assert.False(t, result.IsError) @@ -355,12 +314,7 @@ func TestHandlePauseRollout(t *testing.T) { mock := cmd.NewMockShellExecutor() ctx := cmd.WithShellExecutor(context.Background(), mock) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - // Missing rollout_name - } - - result, err := handlePauseRollout(ctx, request) + result, _, err := handlePauseRollout(ctx, &mcp.CallToolRequest{}, pauseRolloutInput{}) assert.NoError(t, err) assert.True(t, result.IsError) assert.Contains(t, getResultText(result), "rollout_name parameter is required") @@ -380,13 +334,7 @@ func TestHandleSetRolloutImage(t *testing.T) { mock.AddCommandString("kubectl", []string{"argo", "rollouts", "set", "image", "myapp", "nginx:latest"}, expectedOutput, nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "rollout_name": "myapp", - "container_image": "nginx:latest", - } - - result, err := handleSetRolloutImage(ctx, request) + result, _, err := handleSetRolloutImage(ctx, &mcp.CallToolRequest{}, setRolloutImageInput{RolloutName: "myapp", ContainerImage: "nginx:latest"}) assert.NoError(t, err) assert.NotNil(t, result) @@ -410,14 +358,7 @@ func TestHandleSetRolloutImage(t *testing.T) { mock.AddCommandString("kubectl", []string{"argo", "rollouts", "set", "image", "myapp", "nginx:1.20", "-n", "production"}, expectedOutput, nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "rollout_name": "myapp", - "container_image": "nginx:1.20", - "namespace": "production", - } - - result, err := handleSetRolloutImage(ctx, request) + result, _, err := handleSetRolloutImage(ctx, &mcp.CallToolRequest{}, setRolloutImageInput{RolloutName: "myapp", ContainerImage: "nginx:1.20", Namespace: "production"}) assert.NoError(t, err) assert.False(t, result.IsError) @@ -433,13 +374,7 @@ func TestHandleSetRolloutImage(t *testing.T) { mock := cmd.NewMockShellExecutor() ctx := cmd.WithShellExecutor(context.Background(), mock) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "container_image": "nginx:latest", - // Missing rollout_name - } - - result, err := handleSetRolloutImage(ctx, request) + result, _, err := handleSetRolloutImage(ctx, &mcp.CallToolRequest{}, setRolloutImageInput{ContainerImage: "nginx:latest"}) assert.NoError(t, err) assert.True(t, result.IsError) assert.Contains(t, getResultText(result), "rollout_name parameter is required") @@ -453,13 +388,7 @@ func TestHandleSetRolloutImage(t *testing.T) { mock := cmd.NewMockShellExecutor() ctx := cmd.WithShellExecutor(context.Background(), mock) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "rollout_name": "myapp", - // Missing container_image - } - - result, err := handleSetRolloutImage(ctx, request) + result, _, err := handleSetRolloutImage(ctx, &mcp.CallToolRequest{}, setRolloutImageInput{RolloutName: "myapp"}) assert.NoError(t, err) assert.True(t, result.IsError) assert.Contains(t, getResultText(result), "container_image parameter is required") @@ -526,12 +455,8 @@ func TestHandleVerifyGatewayPlugin(t *testing.T) { mock.AddCommandString("kubectl", []string{"get", "configmap", "argo-rollouts-config", "-n", "argo-rollouts", "-o", "yaml"}, expectedOutput, nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "should_install": "false", - } - - result, err := handleVerifyGatewayPlugin(ctx, request) + shouldInstall := false + result, _, err := handleVerifyGatewayPlugin(ctx, &mcp.CallToolRequest{}, verifyGatewayPluginInput{ShouldInstall: &shouldInstall}) assert.NoError(t, err) assert.NotNil(t, result) @@ -553,13 +478,8 @@ func TestHandleVerifyGatewayPlugin(t *testing.T) { mock.AddCommandString("kubectl", []string{"get", "configmap", "argo-rollouts-config", "-n", "custom-namespace", "-o", "yaml"}, expectedOutput, nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "should_install": "false", - "namespace": "custom-namespace", - } - - result, err := handleVerifyGatewayPlugin(ctx, request) + shouldInstall := false + result, _, err := handleVerifyGatewayPlugin(ctx, &mcp.CallToolRequest{}, verifyGatewayPluginInput{ShouldInstall: &shouldInstall, Namespace: "custom-namespace"}) assert.NoError(t, err) assert.NotNil(t, result) @@ -582,8 +502,7 @@ func TestHandleVerifyArgoRolloutsControllerInstall(t *testing.T) { mock.AddCommandString("kubectl", []string{"get", "pods", "-l", "app.kubernetes.io/name=argo-rollouts", "-n", "argo-rollouts", "-o", "jsonpath={.items[*].metadata.name}"}, expectedOutput, nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - request := mcp.CallToolRequest{} - result, err := handleVerifyArgoRolloutsControllerInstall(ctx, request) + result, _, err := handleVerifyArgoRolloutsControllerInstall(ctx, &mcp.CallToolRequest{}, verifyArgoRolloutsControllerInstallInput{}) assert.NoError(t, err) assert.NotNil(t, result) @@ -603,12 +522,7 @@ func TestHandleVerifyArgoRolloutsControllerInstall(t *testing.T) { mock.AddCommandString("kubectl", []string{"get", "pods", "-l", "app.kubernetes.io/name=argo-rollouts", "-n", "custom-argo", "-o", "jsonpath={.items[*].metadata.name}"}, expectedOutput, nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "namespace": "custom-argo", - } - - result, err := handleVerifyArgoRolloutsControllerInstall(ctx, request) + result, _, err := handleVerifyArgoRolloutsControllerInstall(ctx, &mcp.CallToolRequest{}, verifyArgoRolloutsControllerInstallInput{Namespace: "custom-argo"}) assert.NoError(t, err) assert.NotNil(t, result) @@ -628,12 +542,7 @@ func TestHandleVerifyArgoRolloutsControllerInstall(t *testing.T) { mock.AddCommandString("kubectl", []string{"get", "pods", "-l", "app=custom-rollouts", "-n", "argo-rollouts", "-o", "jsonpath={.items[*].metadata.name}"}, expectedOutput, nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "label": "app=custom-rollouts", - } - - result, err := handleVerifyArgoRolloutsControllerInstall(ctx, request) + result, _, err := handleVerifyArgoRolloutsControllerInstall(ctx, &mcp.CallToolRequest{}, verifyArgoRolloutsControllerInstallInput{Label: "app=custom-rollouts"}) assert.NoError(t, err) assert.NotNil(t, result) @@ -656,8 +565,7 @@ func TestHandleVerifyKubectlPluginInstall(t *testing.T) { mock.AddCommandString("kubectl", []string{"argo", "rollouts", "version"}, expectedOutput, nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - request := mcp.CallToolRequest{} - result, err := handleVerifyKubectlPluginInstall(ctx, request) + result, _, err := handleVerifyKubectlPluginInstall(ctx, &mcp.CallToolRequest{}, verifyKubectlPluginInstallInput{}) assert.NoError(t, err) assert.False(t, result.IsError) @@ -674,8 +582,7 @@ func TestHandleVerifyKubectlPluginInstall(t *testing.T) { mock.AddCommandString("kubectl", []string{"plugin", "list"}, "", assert.AnError) ctx := cmd.WithShellExecutor(context.Background(), mock) - request := mcp.CallToolRequest{} - result, err := handleVerifyKubectlPluginInstall(ctx, request) + result, _, err := handleVerifyKubectlPluginInstall(ctx, &mcp.CallToolRequest{}, verifyKubectlPluginInstallInput{}) assert.NoError(t, err) // MCP handlers should not return Go errors assert.NotNil(t, result) diff --git a/pkg/cilium/cilium.go b/pkg/cilium/cilium.go index b92a8f1c..af146b31 100644 --- a/pkg/cilium/cilium.go +++ b/pkg/cilium/cilium.go @@ -6,13 +6,211 @@ import ( "strings" "github.com/kagent-dev/tools/internal/commands" - "github.com/kagent-dev/tools/internal/telemetry" + mcp "github.com/kagent-dev/tools/internal/mcp" "github.com/kagent-dev/tools/pkg/utils" - - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" ) +type noInput struct{} + +type nodeNameInput struct { + NodeName string `json:"node_name" jsonschema:"The name of the node to run the command on"` +} + +type upgradeCiliumInput struct { + ClusterName string `json:"cluster_name" jsonschema:"The name of the cluster to upgrade Cilium on"` + DatapathMode string `json:"datapath_mode" jsonschema:"The datapath mode to use for Cilium (tunnel, native, aws-eni, gke, azure, aks-byocni)"` +} + +type installCiliumInput struct { + ClusterName string `json:"cluster_name" jsonschema:"The name of the cluster to install Cilium on"` + ClusterID string `json:"cluster_id" jsonschema:"The ID of the cluster to install Cilium on"` + DatapathMode string `json:"datapath_mode" jsonschema:"The datapath mode to use for Cilium (tunnel, native, aws-eni, gke, azure, aks-byocni)"` +} + +type connectToRemoteClusterInput struct { + ClusterName string `json:"cluster_name" jsonschema:"The name of the destination cluster"` + Context string `json:"context" jsonschema:"The kubectl context for the destination cluster"` +} + +type disconnectRemoteClusterInput struct { + ClusterName string `json:"cluster_name" jsonschema:"The name of the destination cluster"` +} + +type enableToggleInput struct { + Enable *bool `json:"enable" jsonschema:"Set to true to enable, false to disable"` +} + +type getDaemonStatusInput struct { + ShowAllAddresses bool `json:"show_all_addresses" jsonschema:"Whether to show all addresses"` + ShowAllClusters bool `json:"show_all_clusters" jsonschema:"Whether to show all clusters"` + ShowAllControllers bool `json:"show_all_controllers" jsonschema:"Whether to show all controllers"` + ShowHealth bool `json:"show_health" jsonschema:"Whether to show health"` + ShowAllNodes bool `json:"show_all_nodes" jsonschema:"Whether to show all nodes"` + ShowAllRedirects bool `json:"show_all_redirects" jsonschema:"Whether to show all redirects"` + Brief bool `json:"brief" jsonschema:"Whether to show a brief status"` + NodeName string `json:"node_name" jsonschema:"The name of the node to get the daemon status for"` +} + +type getEndpointDetailsInput struct { + EndpointID string `json:"endpoint_id" jsonschema:"The ID of the endpoint to get details for"` + Labels string `json:"labels" jsonschema:"The labels of the endpoint to get details for"` + OutputFormat string `json:"output_format" jsonschema:"The output format of the endpoint details (json, yaml, jsonpath)"` + NodeName string `json:"node_name" jsonschema:"The name of the node to get the endpoint details for"` +} + +type getEndpointLogsInput struct { + EndpointID string `json:"endpoint_id" jsonschema:"The ID of the endpoint to get logs for"` + NodeName string `json:"node_name" jsonschema:"The name of the node to get the endpoint logs for"` +} + +type getEndpointHealthInput struct { + EndpointID string `json:"endpoint_id" jsonschema:"The ID of the endpoint to get health for"` + NodeName string `json:"node_name" jsonschema:"The name of the node to get the endpoint health for"` +} + +type manageEndpointLabelsInput struct { + EndpointID string `json:"endpoint_id" jsonschema:"The ID of the endpoint to manage labels for"` + Labels string `json:"labels" jsonschema:"Space-separated labels to manage (e.g., 'key1=value1 key2=value2')"` + Action string `json:"action" jsonschema:"The action to perform on the labels (add or delete)"` + NodeName string `json:"node_name" jsonschema:"The name of the node to manage the endpoint labels on"` +} + +type manageEndpointConfigurationInput struct { + EndpointID string `json:"endpoint_id" jsonschema:"The ID of the endpoint to manage configuration for"` + Config string `json:"config" jsonschema:"The configuration to manage for the endpoint provided as a space-separated list of key-value pairs (e.g. 'DropNotification=false TraceNotification=false')"` + NodeName string `json:"node_name" jsonschema:"The name of the node to manage the endpoint configuration on"` +} + +type disconnectEndpointInput struct { + EndpointID string `json:"endpoint_id" jsonschema:"The ID of the endpoint to disconnect"` + NodeName string `json:"node_name" jsonschema:"The name of the node to disconnect the endpoint from"` +} + +type showConfigurationOptionsInput struct { + ListAll bool `json:"list_all" jsonschema:"Whether to list all configuration options"` + ListReadOnly bool `json:"list_read_only" jsonschema:"Whether to list read-only configuration options"` + ListOptions bool `json:"list_options" jsonschema:"Whether to list options"` + NodeName string `json:"node_name" jsonschema:"The name of the node to show the configuration options for"` +} + +type toggleConfigurationOptionInput struct { + Option string `json:"option" jsonschema:"The option to toggle"` + Value *bool `json:"value" jsonschema:"The value to set the option to (true/false)"` + NodeName string `json:"node_name" jsonschema:"The name of the node to toggle the configuration option for"` +} + +type getIdentityDetailsInput struct { + IdentityID string `json:"identity_id" jsonschema:"The ID of the identity to get details for"` + NodeName string `json:"node_name" jsonschema:"The name of the node to get the identity details for"` +} + +type listEnvoyConfigInput struct { + ResourceName string `json:"resource_name" jsonschema:"The name of the resource to get the Envoy configuration for"` + NodeName string `json:"node_name" jsonschema:"The name of the node to get the Envoy configuration for"` +} + +type fqdnCacheInput struct { + Command string `json:"command" jsonschema:"The command to perform on the FQDN cache (list, clean, or a specific command)"` + NodeName string `json:"node_name" jsonschema:"The name of the node to manage the FQDN cache for"` +} + +type showIPCacheInformationInput struct { + CIDR string `json:"cidr" jsonschema:"The CIDR of the IP to get cache information for"` + Labels string `json:"labels" jsonschema:"The labels of the IP to get cache information for"` + NodeName string `json:"node_name" jsonschema:"The name of the node to get the IP cache information for"` +} + +type kvStoreKeyInput struct { + Key string `json:"key" jsonschema:"The key in the kvstore"` + NodeName string `json:"node_name" jsonschema:"The name of the node to run the kvstore command on"` +} + +type setKVStoreKeyInput struct { + Key string `json:"key" jsonschema:"The key to set in the kvstore"` + Value string `json:"value" jsonschema:"The value to set in the kvstore"` + NodeName string `json:"node_name" jsonschema:"The name of the node to set the key in"` +} + +type bpfMapInput struct { + MapName string `json:"map_name" jsonschema:"The name of the BPF map"` + NodeName string `json:"node_name" jsonschema:"The name of the node to run the BPF map command on"` +} + +type listMetricsInput struct { + MatchPattern string `json:"match_pattern" jsonschema:"The match pattern to filter metrics by"` + NodeName string `json:"node_name" jsonschema:"The name of the node to get the metrics for"` +} + +type displayPolicyNodeInformationInput struct { + Labels string `json:"labels" jsonschema:"The labels to get policy node information for"` + NodeName string `json:"node_name" jsonschema:"The name of the node to get policy node information for"` +} + +type deletePolicyRulesInput struct { + Labels string `json:"labels" jsonschema:"The labels to delete policy rules for"` + All bool `json:"all" jsonschema:"Whether to delete all policy rules"` + NodeName string `json:"node_name" jsonschema:"The name of the node to delete policy rules for"` +} + +type xdpCIDRFiltersInput struct { + CIDRPrefixes string `json:"cidr_prefixes" jsonschema:"The CIDR prefixes for the XDP filters"` + Revision string `json:"revision" jsonschema:"The revision of the XDP filters"` + NodeName string `json:"node_name" jsonschema:"The name of the node to run the XDP filter command on"` +} + +type validateCiliumNetworkPoliciesInput struct { + EnableK8s bool `json:"enable_k8s" jsonschema:"Whether to enable k8s API discovery"` + EnableK8sAPIDiscovery bool `json:"enable_k8s_api_discovery" jsonschema:"Whether to enable k8s API discovery"` + NodeName string `json:"node_name" jsonschema:"The name of the node to validate the Cilium network policies for"` +} + +type pcapRecorderIDInput struct { + RecorderID string `json:"recorder_id" jsonschema:"The ID of the PCAP recorder"` + NodeName string `json:"node_name" jsonschema:"The name of the node to run the PCAP recorder command on"` +} + +type updatePCAPRecorderInput struct { + RecorderID string `json:"recorder_id" jsonschema:"The ID of the PCAP recorder to update"` + Filters string `json:"filters" jsonschema:"The filters to update the PCAP recorder with"` + Caplen string `json:"caplen" jsonschema:"The caplen to update the PCAP recorder with"` + ID string `json:"id" jsonschema:"The id to update the PCAP recorder with"` + NodeName string `json:"node_name" jsonschema:"The name of the node to update the PCAP recorder on"` +} + +type listServicesInput struct { + ShowClusterMeshAffinity bool `json:"show_cluster_mesh_affinity" jsonschema:"Whether to show cluster mesh affinity"` + NodeName string `json:"node_name" jsonschema:"The name of the node to get the services for"` +} + +type getServiceInformationInput struct { + ServiceID string `json:"service_id" jsonschema:"The ID of the service to get information about"` + NodeName string `json:"node_name" jsonschema:"The name of the node to get the service information for"` +} + +type deleteServiceInput struct { + ServiceID string `json:"service_id" jsonschema:"The ID of the service to delete"` + All bool `json:"all" jsonschema:"Whether to delete all services"` + NodeName string `json:"node_name" jsonschema:"The name of the node to delete the service from"` +} + +type updateServiceInput struct { + BackendWeights string `json:"backend_weights" jsonschema:"The backend weights to update the service with"` + Backends string `json:"backends" jsonschema:"The backends to update the service with"` + Frontend string `json:"frontend" jsonschema:"The frontend to update the service with"` + ID string `json:"id" jsonschema:"The ID of the service to update"` + K8sClusterInternal bool `json:"k8s_cluster_internal" jsonschema:"Whether to update the k8s cluster internal flag"` + K8sExtTrafficPolicy string `json:"k8s_ext_traffic_policy" jsonschema:"The k8s ext traffic policy to update the service with"` + K8sExternal bool `json:"k8s_external" jsonschema:"Whether to update the k8s external flag"` + K8sHostPort bool `json:"k8s_host_port" jsonschema:"Whether to update the k8s host port flag"` + K8sIntTrafficPolicy string `json:"k8s_int_traffic_policy" jsonschema:"The k8s int traffic policy to update the service with"` + K8sLoadBalancer bool `json:"k8s_load_balancer" jsonschema:"Whether to update the k8s load balancer flag"` + K8sNodePort bool `json:"k8s_node_port" jsonschema:"Whether to update the k8s node port flag"` + LocalRedirect bool `json:"local_redirect" jsonschema:"Whether to update the local redirect flag"` + Protocol string `json:"protocol" jsonschema:"The protocol to update the service with"` + States string `json:"states" jsonschema:"The states to update the service with"` + NodeName string `json:"node_name" jsonschema:"The name of the node to update the service on"` +} + func runCiliumCliWithContext(ctx context.Context, args ...string) (string, error) { kubeconfigPath := utils.GetKubeconfig() return commands.NewCommandBuilder("cilium"). @@ -21,24 +219,24 @@ func runCiliumCliWithContext(ctx context.Context, args ...string) (string, error Execute(ctx) } -func handleCiliumStatusAndVersion(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func handleCiliumStatusAndVersion(ctx context.Context, request *mcp.CallToolRequest, in noInput) (*mcp.CallToolResult, any, error) { status, err := runCiliumCliWithContext(ctx, "status") if err != nil { - return mcp.NewToolResultError("Error getting Cilium status: " + err.Error()), nil + return mcp.NewToolResultError("Error getting Cilium status: " + err.Error()), nil, nil } version, err := runCiliumCliWithContext(ctx, "version") if err != nil { - return mcp.NewToolResultError("Error getting Cilium version: " + err.Error()), nil + return mcp.NewToolResultError("Error getting Cilium version: " + err.Error()), nil, nil } result := status + "\n" + version - return mcp.NewToolResultText(result), nil + return mcp.NewToolResultText(result), nil, nil } -func handleUpgradeCilium(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - clusterName := mcp.ParseString(request, "cluster_name", "") - datapathMode := mcp.ParseString(request, "datapath_mode", "") +func handleUpgradeCilium(ctx context.Context, request *mcp.CallToolRequest, in upgradeCiliumInput) (*mcp.CallToolResult, any, error) { + clusterName := in.ClusterName + datapathMode := in.DatapathMode args := []string{"upgrade"} if clusterName != "" { @@ -50,16 +248,16 @@ func handleUpgradeCilium(ctx context.Context, request mcp.CallToolRequest) (*mcp output, err := runCiliumCliWithContext(ctx, args...) if err != nil { - return mcp.NewToolResultError("Error upgrading Cilium: " + err.Error()), nil + return mcp.NewToolResultError("Error upgrading Cilium: " + err.Error()), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleInstallCilium(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - clusterName := mcp.ParseString(request, "cluster_name", "") - clusterID := mcp.ParseString(request, "cluster_id", "") - datapathMode := mcp.ParseString(request, "datapath_mode", "") +func handleInstallCilium(ctx context.Context, request *mcp.CallToolRequest, in installCiliumInput) (*mcp.CallToolResult, any, error) { + clusterName := in.ClusterName + clusterID := in.ClusterID + datapathMode := in.DatapathMode args := []string{"install"} if clusterName != "" { @@ -74,99 +272,100 @@ func handleInstallCilium(ctx context.Context, request mcp.CallToolRequest) (*mcp output, err := runCiliumCliWithContext(ctx, args...) if err != nil { - return mcp.NewToolResultError("Error installing Cilium: " + err.Error()), nil + return mcp.NewToolResultError("Error installing Cilium: " + err.Error()), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleUninstallCilium(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func handleUninstallCilium(ctx context.Context, request *mcp.CallToolRequest, in noInput) (*mcp.CallToolResult, any, error) { output, err := runCiliumCliWithContext(ctx, "uninstall") if err != nil { - return mcp.NewToolResultError("Error uninstalling Cilium: " + err.Error()), nil + return mcp.NewToolResultError("Error uninstalling Cilium: " + err.Error()), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleConnectToRemoteCluster(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - clusterName := mcp.ParseString(request, "cluster_name", "") - context := mcp.ParseString(request, "context", "") +func handleConnectToRemoteCluster(ctx context.Context, request *mcp.CallToolRequest, in connectToRemoteClusterInput) (*mcp.CallToolResult, any, error) { + clusterName := in.ClusterName + destContext := in.Context if clusterName == "" { - return mcp.NewToolResultError("cluster_name parameter is required"), nil + return mcp.NewToolResultError("cluster_name parameter is required"), nil, nil } args := []string{"clustermesh", "connect", "--destination-cluster", clusterName} - if context != "" { - args = append(args, "--destination-context", context) + if destContext != "" { + args = append(args, "--destination-context", destContext) } output, err := runCiliumCliWithContext(ctx, args...) if err != nil { - return mcp.NewToolResultError("Error connecting to remote cluster: " + err.Error()), nil + return mcp.NewToolResultError("Error connecting to remote cluster: " + err.Error()), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleDisconnectRemoteCluster(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - clusterName := mcp.ParseString(request, "cluster_name", "") +func handleDisconnectRemoteCluster(ctx context.Context, request *mcp.CallToolRequest, in disconnectRemoteClusterInput) (*mcp.CallToolResult, any, error) { + clusterName := in.ClusterName if clusterName == "" { - return mcp.NewToolResultError("cluster_name parameter is required"), nil + return mcp.NewToolResultError("cluster_name parameter is required"), nil, nil } args := []string{"clustermesh", "disconnect", "--destination-cluster", clusterName} output, err := runCiliumCliWithContext(ctx, args...) if err != nil { - return mcp.NewToolResultError("Error disconnecting from remote cluster: " + err.Error()), nil + return mcp.NewToolResultError("Error disconnecting from remote cluster: " + err.Error()), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleListBGPPeers(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func handleListBGPPeers(ctx context.Context, request *mcp.CallToolRequest, in noInput) (*mcp.CallToolResult, any, error) { output, err := runCiliumCliWithContext(ctx, "bgp", "peers") if err != nil { - return mcp.NewToolResultError("Error listing BGP peers: " + err.Error()), nil + return mcp.NewToolResultError("Error listing BGP peers: " + err.Error()), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleListBGPRoutes(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func handleListBGPRoutes(ctx context.Context, request *mcp.CallToolRequest, in noInput) (*mcp.CallToolResult, any, error) { output, err := runCiliumCliWithContext(ctx, "bgp", "routes") if err != nil { - return mcp.NewToolResultError("Error listing BGP routes: " + err.Error()), nil + return mcp.NewToolResultError("Error listing BGP routes: " + err.Error()), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleShowClusterMeshStatus(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func handleShowClusterMeshStatus(ctx context.Context, request *mcp.CallToolRequest, in noInput) (*mcp.CallToolResult, any, error) { output, err := runCiliumCliWithContext(ctx, "clustermesh", "status") if err != nil { - return mcp.NewToolResultError("Error getting cluster mesh status: " + err.Error()), nil + return mcp.NewToolResultError("Error getting cluster mesh status: " + err.Error()), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleShowFeaturesStatus(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func handleShowFeaturesStatus(ctx context.Context, request *mcp.CallToolRequest, in noInput) (*mcp.CallToolResult, any, error) { output, err := runCiliumCliWithContext(ctx, "features", "status") if err != nil { - return mcp.NewToolResultError("Error getting features status: " + err.Error()), nil + return mcp.NewToolResultError("Error getting features status: " + err.Error()), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleToggleHubble(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - enableStr := mcp.ParseString(request, "enable", "true") - enable := enableStr == "true" - +func handleToggleHubble(ctx context.Context, request *mcp.CallToolRequest, in enableToggleInput) (*mcp.CallToolResult, any, error) { + enable := true + if in.Enable != nil { + enable = *in.Enable + } var action string if enable { action = "enable" @@ -176,16 +375,17 @@ func handleToggleHubble(ctx context.Context, request mcp.CallToolRequest) (*mcp. output, err := runCiliumCliWithContext(ctx, "hubble", action) if err != nil { - return mcp.NewToolResultError("Error toggling Hubble: " + err.Error()), nil + return mcp.NewToolResultError("Error toggling Hubble: " + err.Error()), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleToggleClusterMesh(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - enableStr := mcp.ParseString(request, "enable", "true") - enable := enableStr == "true" - +func handleToggleClusterMesh(ctx context.Context, request *mcp.CallToolRequest, in enableToggleInput) (*mcp.CallToolResult, any, error) { + enable := true + if in.Enable != nil { + enable = *in.Enable + } var action string if enable { action = "enable" @@ -195,408 +395,111 @@ func handleToggleClusterMesh(ctx context.Context, request mcp.CallToolRequest) ( output, err := runCiliumCliWithContext(ctx, "clustermesh", action) if err != nil { - return mcp.NewToolResultError("Error toggling cluster mesh: " + err.Error()), nil + return mcp.NewToolResultError("Error toggling cluster mesh: " + err.Error()), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func RegisterTools(s *server.MCPServer, readOnly bool) { +func RegisterTools(s *mcp.Server, readOnly bool) { // Read-only tools - always registered - s.AddTool(mcp.NewTool("cilium_status_and_version", - mcp.WithDescription("Get the status and version of Cilium installation"), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_status_and_version", handleCiliumStatusAndVersion))) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_status_and_version", Description: "Get the status and version of Cilium installation"}, handleCiliumStatusAndVersion) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_list_bgp_peers", Description: "List BGP peers"}, handleListBGPPeers) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_list_bgp_routes", Description: "List BGP routes"}, handleListBGPRoutes) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_show_cluster_mesh_status", Description: "Show cluster mesh status"}, handleShowClusterMeshStatus) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_show_features_status", Description: "Show Cilium features status"}, handleShowFeaturesStatus) - s.AddTool(mcp.NewTool("cilium_list_bgp_peers", - mcp.WithDescription("List BGP peers"), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_list_bgp_peers", handleListBGPPeers))) + if !readOnly { + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_upgrade_cilium", Description: "Upgrade Cilium on the cluster"}, handleUpgradeCilium) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_install_cilium", Description: "Install Cilium on the cluster"}, handleInstallCilium) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_uninstall_cilium", Description: "Uninstall Cilium from the cluster"}, handleUninstallCilium) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_connect_to_remote_cluster", Description: "Connect to a remote cluster for cluster mesh"}, handleConnectToRemoteCluster) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_disconnect_remote_cluster", Description: "Disconnect from a remote cluster"}, handleDisconnectRemoteCluster) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_toggle_hubble", Description: "Enable or disable Hubble"}, handleToggleHubble) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_toggle_cluster_mesh", Description: "Enable or disable cluster mesh"}, handleToggleClusterMesh) + } - s.AddTool(mcp.NewTool("cilium_list_bgp_routes", - mcp.WithDescription("List BGP routes"), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_list_bgp_routes", handleListBGPRoutes))) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_get_daemon_status", Description: "Get the status of the Cilium daemon for the cluster"}, handleGetDaemonStatus) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_get_endpoints_list", Description: "Get the list of all endpoints in the cluster"}, handleGetEndpointsList) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_get_endpoint_details", Description: "List the details of an endpoint in the cluster"}, handleGetEndpointDetails) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_show_configuration_options", Description: "Show Cilium configuration options"}, handleShowConfigurationOptions) - s.AddTool(mcp.NewTool("cilium_show_cluster_mesh_status", - mcp.WithDescription("Show cluster mesh status"), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_show_cluster_mesh_status", handleShowClusterMeshStatus))) + if !readOnly { + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_toggle_configuration_option", Description: "Toggle a Cilium configuration option"}, handleToggleConfigurationOption) + } - s.AddTool(mcp.NewTool("cilium_show_features_status", - mcp.WithDescription("Show Cilium features status"), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_show_features_status", handleShowFeaturesStatus))) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_list_services", Description: "List services for the cluster"}, handleListServices) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_get_service_information", Description: "Get information about a service in the cluster"}, handleGetServiceInformation) - // Write tools - only registered when write operations are enabled - if !readOnly { - s.AddTool(mcp.NewTool("cilium_upgrade_cilium", - mcp.WithDescription("Upgrade Cilium on the cluster"), - mcp.WithString("cluster_name", mcp.Description("The name of the cluster to upgrade Cilium on")), - mcp.WithString("datapath_mode", mcp.Description("The datapath mode to use for Cilium (tunnel, native, aws-eni, gke, azure, aks-byocni)")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_upgrade_cilium", handleUpgradeCilium))) - - s.AddTool(mcp.NewTool("cilium_install_cilium", - mcp.WithDescription("Install Cilium on the cluster"), - mcp.WithString("cluster_name", mcp.Description("The name of the cluster to install Cilium on")), - mcp.WithString("cluster_id", mcp.Description("The ID of the cluster to install Cilium on")), - mcp.WithString("datapath_mode", mcp.Description("The datapath mode to use for Cilium (tunnel, native, aws-eni, gke, azure, aks-byocni)")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_install_cilium", handleInstallCilium))) - - s.AddTool(mcp.NewTool("cilium_uninstall_cilium", - mcp.WithDescription("Uninstall Cilium from the cluster"), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_uninstall_cilium", handleUninstallCilium))) - - s.AddTool(mcp.NewTool("cilium_connect_to_remote_cluster", - mcp.WithDescription("Connect to a remote cluster for cluster mesh"), - mcp.WithString("cluster_name", mcp.Description("The name of the destination cluster"), mcp.Required()), - mcp.WithString("context", mcp.Description("The kubectl context for the destination cluster")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_connect_to_remote_cluster", handleConnectToRemoteCluster))) - - s.AddTool(mcp.NewTool("cilium_disconnect_remote_cluster", - mcp.WithDescription("Disconnect from a remote cluster"), - mcp.WithString("cluster_name", mcp.Description("The name of the destination cluster"), mcp.Required()), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_disconnect_remote_cluster", handleDisconnectRemoteCluster))) - - s.AddTool(mcp.NewTool("cilium_toggle_hubble", - mcp.WithDescription("Enable or disable Hubble"), - mcp.WithString("enable", mcp.Description("Set to 'true' to enable, 'false' to disable")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_toggle_hubble", handleToggleHubble))) - - s.AddTool(mcp.NewTool("cilium_toggle_cluster_mesh", - mcp.WithDescription("Enable or disable cluster mesh"), - mcp.WithString("enable", mcp.Description("Set to 'true' to enable, 'false' to disable")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_toggle_cluster_mesh", handleToggleClusterMesh))) - } - - // Add tools that are also needed by cilium-manager agent - s.AddTool(mcp.NewTool("cilium_get_daemon_status", - mcp.WithDescription("Get the status of the Cilium daemon for the cluster"), - mcp.WithString("show_all_addresses", mcp.Description("Whether to show all addresses")), - mcp.WithString("show_all_clusters", mcp.Description("Whether to show all clusters")), - mcp.WithString("show_all_controllers", mcp.Description("Whether to show all controllers")), - mcp.WithString("show_health", mcp.Description("Whether to show health")), - mcp.WithString("show_all_nodes", mcp.Description("Whether to show all nodes")), - mcp.WithString("show_all_redirects", mcp.Description("Whether to show all redirects")), - mcp.WithString("brief", mcp.Description("Whether to show a brief status")), - mcp.WithString("node_name", mcp.Description("The name of the node to get the daemon status for")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_get_daemon_status", handleGetDaemonStatus))) - - s.AddTool(mcp.NewTool("cilium_get_endpoints_list", - mcp.WithDescription("Get the list of all endpoints in the cluster"), - mcp.WithString("node_name", mcp.Description("The name of the node to get the endpoints list for")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_get_endpoints_list", handleGetEndpointsList))) - - s.AddTool(mcp.NewTool("cilium_get_endpoint_details", - mcp.WithDescription("List the details of an endpoint in the cluster"), - mcp.WithString("endpoint_id", mcp.Description("The ID of the endpoint to get details for")), - mcp.WithString("labels", mcp.Description("The labels of the endpoint to get details for")), - mcp.WithString("output_format", mcp.Description("The output format of the endpoint details (json, yaml, jsonpath)")), - mcp.WithString("node_name", mcp.Description("The name of the node to get the endpoint details for")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_get_endpoint_details", handleGetEndpointDetails))) - - s.AddTool(mcp.NewTool("cilium_show_configuration_options", - mcp.WithDescription("Show Cilium configuration options"), - mcp.WithString("list_all", mcp.Description("Whether to list all configuration options")), - mcp.WithString("list_read_only", mcp.Description("Whether to list read-only configuration options")), - mcp.WithString("list_options", mcp.Description("Whether to list options")), - mcp.WithString("node_name", mcp.Description("The name of the node to show the configuration options for")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_show_configuration_options", handleShowConfigurationOptions))) - - // Write tool - toggle_configuration_option - if !readOnly { - s.AddTool(mcp.NewTool("cilium_toggle_configuration_option", - mcp.WithDescription("Toggle a Cilium configuration option"), - mcp.WithString("option", mcp.Description("The option to toggle"), mcp.Required()), - mcp.WithString("value", mcp.Description("The value to set the option to (true/false)"), mcp.Required()), - mcp.WithString("node_name", mcp.Description("The name of the node to toggle the configuration option for")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_toggle_configuration_option", handleToggleConfigurationOption))) - } - - s.AddTool(mcp.NewTool("cilium_list_services", - mcp.WithDescription("List services for the cluster"), - mcp.WithString("show_cluster_mesh_affinity", mcp.Description("Whether to show cluster mesh affinity")), - mcp.WithString("node_name", mcp.Description("The name of the node to get the services for")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_list_services", handleListServices))) - - s.AddTool(mcp.NewTool("cilium_get_service_information", - mcp.WithDescription("Get information about a service in the cluster"), - mcp.WithString("service_id", mcp.Description("The ID of the service to get information about"), mcp.Required()), - mcp.WithString("node_name", mcp.Description("The name of the node to get the service information for")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_get_service_information", handleGetServiceInformation))) - - // Write tools - service management if !readOnly { - s.AddTool(mcp.NewTool("cilium_update_service", - mcp.WithDescription("Update a service in the cluster"), - mcp.WithString("backend_weights", mcp.Description("The backend weights to update the service with")), - mcp.WithString("backends", mcp.Description("The backends to update the service with"), mcp.Required()), - mcp.WithString("frontend", mcp.Description("The frontend to update the service with"), mcp.Required()), - mcp.WithString("id", mcp.Description("The ID of the service to update"), mcp.Required()), - mcp.WithString("k8s_cluster_internal", mcp.Description("Whether to update the k8s cluster internal flag")), - mcp.WithString("k8s_ext_traffic_policy", mcp.Description("The k8s ext traffic policy to update the service with")), - mcp.WithString("k8s_external", mcp.Description("Whether to update the k8s external flag")), - mcp.WithString("k8s_host_port", mcp.Description("Whether to update the k8s host port flag")), - mcp.WithString("k8s_int_traffic_policy", mcp.Description("The k8s int traffic policy to update the service with")), - mcp.WithString("k8s_load_balancer", mcp.Description("Whether to update the k8s load balancer flag")), - mcp.WithString("k8s_node_port", mcp.Description("Whether to update the k8s node port flag")), - mcp.WithString("local_redirect", mcp.Description("Whether to update the local redirect flag")), - mcp.WithString("protocol", mcp.Description("The protocol to update the service with")), - mcp.WithString("states", mcp.Description("The states to update the service with")), - mcp.WithString("node_name", mcp.Description("The name of the node to update the service on")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_update_service", handleUpdateService))) - - s.AddTool(mcp.NewTool("cilium_delete_service", - mcp.WithDescription("Delete a service from the cluster"), - mcp.WithString("service_id", mcp.Description("The ID of the service to delete")), - mcp.WithString("all", mcp.Description("Whether to delete all services (true/false)")), - mcp.WithString("node_name", mcp.Description("The name of the node to delete the service from")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_delete_service", handleDeleteService))) - } - - // Debug tools (previously in RegisterCiliumDbgTools) - s.AddTool(mcp.NewTool("cilium_get_endpoint_details", - mcp.WithDescription("List the details of an endpoint in the cluster"), - mcp.WithString("endpoint_id", mcp.Description("The ID of the endpoint to get details for")), - mcp.WithString("labels", mcp.Description("The labels of the endpoint to get details for")), - mcp.WithString("output_format", mcp.Description("The output format of the endpoint details (json, yaml, jsonpath)")), - mcp.WithString("node_name", mcp.Description("The name of the node to get the endpoint details for")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_get_endpoint_details", handleGetEndpointDetails))) - - s.AddTool(mcp.NewTool("cilium_get_endpoint_logs", - mcp.WithDescription("Get the logs of an endpoint in the cluster"), - mcp.WithString("endpoint_id", mcp.Description("The ID of the endpoint to get logs for"), mcp.Required()), - mcp.WithString("node_name", mcp.Description("The name of the node to get the endpoint logs for")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_get_endpoint_logs", handleGetEndpointLogs))) - - s.AddTool(mcp.NewTool("cilium_get_endpoint_health", - mcp.WithDescription("Get the health of an endpoint in the cluster"), - mcp.WithString("endpoint_id", mcp.Description("The ID of the endpoint to get health for"), mcp.Required()), - mcp.WithString("node_name", mcp.Description("The name of the node to get the endpoint health for")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_get_endpoint_health", handleGetEndpointHealth))) - - // Write tools - endpoint management - if !readOnly { - s.AddTool(mcp.NewTool("cilium_manage_endpoint_labels", - mcp.WithDescription("Manage the labels (add or delete) of an endpoint in the cluster"), - mcp.WithString("endpoint_id", mcp.Description("The ID of the endpoint to manage labels for"), mcp.Required()), - mcp.WithString("labels", mcp.Description("Space-separated labels to manage (e.g., 'key1=value1 key2=value2')"), mcp.Required()), - mcp.WithString("action", mcp.Description("The action to perform on the labels (add or delete)"), mcp.Required()), - mcp.WithString("node_name", mcp.Description("The name of the node to manage the endpoint labels on")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_manage_endpoint_labels", handleManageEndpointLabels))) - - s.AddTool(mcp.NewTool("cilium_manage_endpoint_config", - mcp.WithDescription("Manage the configuration of an endpoint in the cluster"), - mcp.WithString("endpoint_id", mcp.Description("The ID of the endpoint to manage configuration for"), mcp.Required()), - mcp.WithString("config", mcp.Description("The configuration to manage for the endpoint provided as a space-separated list of key-value pairs (e.g. 'DropNotification=false TraceNotification=false')"), mcp.Required()), - mcp.WithString("node_name", mcp.Description("The name of the node to manage the endpoint configuration on")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_manage_endpoint_config", handleManageEndpointConfiguration))) - - s.AddTool(mcp.NewTool("cilium_disconnect_endpoint", - mcp.WithDescription("Disconnect an endpoint from the network"), - mcp.WithString("endpoint_id", mcp.Description("The ID of the endpoint to disconnect"), mcp.Required()), - mcp.WithString("node_name", mcp.Description("The name of the node to disconnect the endpoint from")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_disconnect_endpoint", handleDisconnectEndpoint))) - } - - s.AddTool(mcp.NewTool("cilium_list_identities", - mcp.WithDescription("List all identities in the cluster"), - mcp.WithString("node_name", mcp.Description("The name of the node to list the identities for")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_list_identities", handleListIdentities))) - - s.AddTool(mcp.NewTool("cilium_get_identity_details", - mcp.WithDescription("Get the details of an identity in the cluster"), - mcp.WithString("identity_id", mcp.Description("The ID of the identity to get details for"), mcp.Required()), - mcp.WithString("node_name", mcp.Description("The name of the node to get the identity details for")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_get_identity_details", handleGetIdentityDetails))) - - s.AddTool(mcp.NewTool("cilium_request_debugging_information", - mcp.WithDescription("Request debugging information for the cluster"), - mcp.WithString("node_name", mcp.Description("The name of the node to get the debugging information for")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_request_debugging_information", handleRequestDebuggingInformation))) - - s.AddTool(mcp.NewTool("cilium_display_encryption_state", - mcp.WithDescription("Display the encryption state for the cluster"), - mcp.WithString("node_name", mcp.Description("The name of the node to get the encryption state for")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_display_encryption_state", handleDisplayEncryptionState))) - - // Write tool - flush_ipsec_state - if !readOnly { - s.AddTool(mcp.NewTool("cilium_flush_ipsec_state", - mcp.WithDescription("Flush the IPsec state for the cluster"), - mcp.WithString("node_name", mcp.Description("The name of the node to flush the IPsec state for")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_flush_ipsec_state", handleFlushIPsecState))) - } - - s.AddTool(mcp.NewTool("cilium_list_envoy_config", - mcp.WithDescription("List the Envoy configuration for a resource in the cluster"), - mcp.WithString("resource_name", mcp.Description("The name of the resource to get the Envoy configuration for"), mcp.Required()), - mcp.WithString("node_name", mcp.Description("The name of the node to get the Envoy configuration for")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_list_envoy_config", handleListEnvoyConfig))) - - s.AddTool(mcp.NewTool("cilium_fqdn_cache", - mcp.WithDescription("Manage the FQDN cache for the cluster"), - mcp.WithString("command", mcp.Description("The command to perform on the FQDN cache (list, clean, or a specific command)"), mcp.Required()), - mcp.WithString("node_name", mcp.Description("The name of the node to manage the FQDN cache for")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_fqdn_cache", handleFQDNCache))) - - s.AddTool(mcp.NewTool("cilium_show_dns_names", - mcp.WithDescription("Show the DNS names for the cluster"), - mcp.WithString("node_name", mcp.Description("The name of the node to get the DNS names for")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_show_dns_names", handleShowDNSNames))) - - s.AddTool(mcp.NewTool("cilium_list_ip_addresses", - mcp.WithDescription("List the IP addresses for the cluster"), - mcp.WithString("node_name", mcp.Description("The name of the node to get the IP addresses for")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_list_ip_addresses", handleListIPAddresses))) - - s.AddTool(mcp.NewTool("cilium_show_ip_cache_information", - mcp.WithDescription("Show the IP cache information for the cluster"), - mcp.WithString("cidr", mcp.Description("The CIDR of the IP to get cache information for")), - mcp.WithString("labels", mcp.Description("The labels of the IP to get cache information for")), - mcp.WithString("node_name", mcp.Description("The name of the node to get the IP cache information for")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_show_ip_cache_information", handleShowIPCacheInformation))) - - // Write tool - delete_key_from_kv_store + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_update_service", Description: "Update a service in the cluster"}, handleUpdateService) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_delete_service", Description: "Delete a service from the cluster"}, handleDeleteService) + } + + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_get_endpoint_details", Description: "List the details of an endpoint in the cluster"}, handleGetEndpointDetails) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_get_endpoint_logs", Description: "Get the logs of an endpoint in the cluster"}, handleGetEndpointLogs) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_get_endpoint_health", Description: "Get the health of an endpoint in the cluster"}, handleGetEndpointHealth) + if !readOnly { - s.AddTool(mcp.NewTool("cilium_delete_key_from_kv_store", - mcp.WithDescription("Delete a key from the kvstore for the cluster"), - mcp.WithString("key", mcp.Description("The key to delete from the kvstore"), mcp.Required()), - mcp.WithString("node_name", mcp.Description("The name of the node to delete the key from")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_delete_key_from_kv_store", handleDeleteKeyFromKVStore))) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_manage_endpoint_labels", Description: "Manage the labels (add or delete) of an endpoint in the cluster"}, handleManageEndpointLabels) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_manage_endpoint_config", Description: "Manage the configuration of an endpoint in the cluster"}, handleManageEndpointConfiguration) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_disconnect_endpoint", Description: "Disconnect an endpoint from the network"}, handleDisconnectEndpoint) } - s.AddTool(mcp.NewTool("cilium_get_kv_store_key", - mcp.WithDescription("Get a key from the kvstore for the cluster"), - mcp.WithString("key", mcp.Description("The key to get from the kvstore"), mcp.Required()), - mcp.WithString("node_name", mcp.Description("The name of the node to get the key from")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_get_kv_store_key", handleGetKVStoreKey))) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_list_identities", Description: "List all identities in the cluster"}, handleListIdentities) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_get_identity_details", Description: "Get the details of an identity in the cluster"}, handleGetIdentityDetails) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_request_debugging_information", Description: "Request debugging information for the cluster"}, handleRequestDebuggingInformation) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_display_encryption_state", Description: "Display the encryption state for the cluster"}, handleDisplayEncryptionState) - // Write tool - set_kv_store_key if !readOnly { - s.AddTool(mcp.NewTool("cilium_set_kv_store_key", - mcp.WithDescription("Set a key in the kvstore for the cluster"), - mcp.WithString("key", mcp.Description("The key to set in the kvstore"), mcp.Required()), - mcp.WithString("value", mcp.Description("The value to set in the kvstore"), mcp.Required()), - mcp.WithString("node_name", mcp.Description("The name of the node to set the key in")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_set_kv_store_key", handleSetKVStoreKey))) - } - - s.AddTool(mcp.NewTool("cilium_show_load_information", - mcp.WithDescription("Show load information for the cluster"), - mcp.WithString("node_name", mcp.Description("The name of the node to get the load information for")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_show_load_information", handleShowLoadInformation))) - - s.AddTool(mcp.NewTool("cilium_list_local_redirect_policies", - mcp.WithDescription("List local redirect policies for the cluster"), - mcp.WithString("node_name", mcp.Description("The name of the node to get the local redirect policies for")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_list_local_redirect_policies", handleListLocalRedirectPolicies))) - - s.AddTool(mcp.NewTool("cilium_list_bpf_map_events", - mcp.WithDescription("List BPF map events for the cluster"), - mcp.WithString("map_name", mcp.Description("The name of the BPF map to get events for"), mcp.Required()), - mcp.WithString("node_name", mcp.Description("The name of the node to get the BPF map events for")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_list_bpf_map_events", handleListBPFMapEvents))) - - s.AddTool(mcp.NewTool("cilium_get_bpf_map", - mcp.WithDescription("Get BPF map for the cluster"), - mcp.WithString("map_name", mcp.Description("The name of the BPF map to get"), mcp.Required()), - mcp.WithString("node_name", mcp.Description("The name of the node to get the BPF map for")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_get_bpf_map", handleGetBPFMap))) - - s.AddTool(mcp.NewTool("cilium_list_bpf_maps", - mcp.WithDescription("List BPF maps for the cluster"), - mcp.WithString("node_name", mcp.Description("The name of the node to get the BPF maps for")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_list_bpf_maps", handleListBPFMaps))) - - s.AddTool(mcp.NewTool("cilium_list_metrics", - mcp.WithDescription("List metrics for the cluster"), - mcp.WithString("match_pattern", mcp.Description("The match pattern to filter metrics by")), - mcp.WithString("node_name", mcp.Description("The name of the node to get the metrics for")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_list_metrics", handleListMetrics))) - - s.AddTool(mcp.NewTool("cilium_list_cluster_nodes", - mcp.WithDescription("List cluster nodes for the cluster"), - mcp.WithString("node_name", mcp.Description("The name of the node to get the cluster nodes for")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_list_cluster_nodes", handleListClusterNodes))) - - s.AddTool(mcp.NewTool("cilium_list_node_ids", - mcp.WithDescription("List node IDs for the cluster"), - mcp.WithString("node_name", mcp.Description("The name of the node to get the node IDs for")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_list_node_ids", handleListNodeIds))) - - s.AddTool(mcp.NewTool("cilium_display_policy_node_information", - mcp.WithDescription("Display policy node information for the cluster"), - mcp.WithString("labels", mcp.Description("The labels to get policy node information for")), - mcp.WithString("node_name", mcp.Description("The name of the node to get policy node information for")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_display_policy_node_information", handleDisplayPolicyNodeInformation))) - - // Write tool - delete_policy_rules + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_flush_ipsec_state", Description: "Flush the IPsec state for the cluster"}, handleFlushIPsecState) + } + + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_list_envoy_config", Description: "List the Envoy configuration for a resource in the cluster"}, handleListEnvoyConfig) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_fqdn_cache", Description: "Manage the FQDN cache for the cluster"}, handleFQDNCache) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_show_dns_names", Description: "Show the DNS names for the cluster"}, handleShowDNSNames) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_list_ip_addresses", Description: "List the IP addresses for the cluster"}, handleListIPAddresses) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_show_ip_cache_information", Description: "Show the IP cache information for the cluster"}, handleShowIPCacheInformation) + if !readOnly { - s.AddTool(mcp.NewTool("cilium_delete_policy_rules", - mcp.WithDescription("Delete policy rules for the cluster"), - mcp.WithString("labels", mcp.Description("The labels to delete policy rules for")), - mcp.WithString("all", mcp.Description("Whether to delete all policy rules")), - mcp.WithString("node_name", mcp.Description("The name of the node to delete policy rules for")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_delete_policy_rules", handleDeletePolicyRules))) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_delete_key_from_kv_store", Description: "Delete a key from the kvstore for the cluster"}, handleDeleteKeyFromKVStore) } - s.AddTool(mcp.NewTool("cilium_display_selectors", - mcp.WithDescription("Display selectors for the cluster"), - mcp.WithString("node_name", mcp.Description("The name of the node to get selectors for")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_display_selectors", handleDisplaySelectors))) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_get_kv_store_key", Description: "Get a key from the kvstore for the cluster"}, handleGetKVStoreKey) + + if !readOnly { + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_set_kv_store_key", Description: "Set a key in the kvstore for the cluster"}, handleSetKVStoreKey) + } - s.AddTool(mcp.NewTool("cilium_list_xdp_cidr_filters", - mcp.WithDescription("List XDP CIDR filters for the cluster"), - mcp.WithString("node_name", mcp.Description("The name of the node to get the XDP CIDR filters for")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_list_xdp_cidr_filters", handleListXDPCIDRFilters))) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_show_load_information", Description: "Show load information for the cluster"}, handleShowLoadInformation) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_list_local_redirect_policies", Description: "List local redirect policies for the cluster"}, handleListLocalRedirectPolicies) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_list_bpf_map_events", Description: "List BPF map events for the cluster"}, handleListBPFMapEvents) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_get_bpf_map", Description: "Get BPF map for the cluster"}, handleGetBPFMap) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_list_bpf_maps", Description: "List BPF maps for the cluster"}, handleListBPFMaps) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_list_metrics", Description: "List metrics for the cluster"}, handleListMetrics) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_list_cluster_nodes", Description: "List cluster nodes for the cluster"}, handleListClusterNodes) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_list_node_ids", Description: "List node IDs for the cluster"}, handleListNodeIds) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_display_policy_node_information", Description: "Display policy node information for the cluster"}, handleDisplayPolicyNodeInformation) - // Write tools - XDP CIDR filters if !readOnly { - s.AddTool(mcp.NewTool("cilium_update_xdp_cidr_filters", - mcp.WithDescription("Update XDP CIDR filters for the cluster"), - mcp.WithString("cidr_prefixes", mcp.Description("The CIDR prefixes to update the XDP filters for"), mcp.Required()), - mcp.WithString("revision", mcp.Description("The revision of the XDP filters to update")), - mcp.WithString("node_name", mcp.Description("The name of the node to update the XDP filters for")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_update_xdp_cidr_filters", handleUpdateXDPCIDRFilters))) - - s.AddTool(mcp.NewTool("cilium_delete_xdp_cidr_filters", - mcp.WithDescription("Delete XDP CIDR filters for the cluster"), - mcp.WithString("cidr_prefixes", mcp.Description("The CIDR prefixes to delete the XDP filters for"), mcp.Required()), - mcp.WithString("revision", mcp.Description("The revision of the XDP filters to delete")), - mcp.WithString("node_name", mcp.Description("The name of the node to delete the XDP filters for")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_delete_xdp_cidr_filters", handleDeleteXDPCIDRFilters))) - } - - s.AddTool(mcp.NewTool("cilium_validate_cilium_network_policies", - mcp.WithDescription("Validate Cilium network policies for the cluster"), - mcp.WithString("enable_k8s", mcp.Description("Whether to enable k8s API discovery")), - mcp.WithString("enable_k8s_api_discovery", mcp.Description("Whether to enable k8s API discovery")), - mcp.WithString("node_name", mcp.Description("The name of the node to validate the Cilium network policies for")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_validate_cilium_network_policies", handleValidateCiliumNetworkPolicies))) - - s.AddTool(mcp.NewTool("cilium_list_pcap_recorders", - mcp.WithDescription("List PCAP recorders for the cluster"), - mcp.WithString("node_name", mcp.Description("The name of the node to get the PCAP recorders for")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_list_pcap_recorders", handleListPCAPRecorders))) - - s.AddTool(mcp.NewTool("cilium_get_pcap_recorder", - mcp.WithDescription("Get a PCAP recorder for the cluster"), - mcp.WithString("recorder_id", mcp.Description("The ID of the PCAP recorder to get"), mcp.Required()), - mcp.WithString("node_name", mcp.Description("The name of the node to get the PCAP recorder for")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_get_pcap_recorder", handleGetPCAPRecorder))) - - // Write tools - PCAP recorder management + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_delete_policy_rules", Description: "Delete policy rules for the cluster"}, handleDeletePolicyRules) + } + + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_display_selectors", Description: "Display selectors for the cluster"}, handleDisplaySelectors) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_list_xdp_cidr_filters", Description: "List XDP CIDR filters for the cluster"}, handleListXDPCIDRFilters) + if !readOnly { - s.AddTool(mcp.NewTool("cilium_delete_pcap_recorder", - mcp.WithDescription("Delete a PCAP recorder for the cluster"), - mcp.WithString("recorder_id", mcp.Description("The ID of the PCAP recorder to delete"), mcp.Required()), - mcp.WithString("node_name", mcp.Description("The name of the node to delete the PCAP recorder from")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_delete_pcap_recorder", handleDeletePCAPRecorder))) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_update_xdp_cidr_filters", Description: "Update XDP CIDR filters for the cluster"}, handleUpdateXDPCIDRFilters) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_delete_xdp_cidr_filters", Description: "Delete XDP CIDR filters for the cluster"}, handleDeleteXDPCIDRFilters) + } - s.AddTool(mcp.NewTool("cilium_update_pcap_recorder", - mcp.WithDescription("Update a PCAP recorder for the cluster"), - mcp.WithString("recorder_id", mcp.Description("The ID of the PCAP recorder to update"), mcp.Required()), - mcp.WithString("filters", mcp.Description("The filters to update the PCAP recorder with"), mcp.Required()), - mcp.WithString("caplen", mcp.Description("The caplen to update the PCAP recorder with")), - mcp.WithString("id", mcp.Description("The id to update the PCAP recorder with")), - mcp.WithString("node_name", mcp.Description("The name of the node to update the PCAP recorder on")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_update_pcap_recorder", handleUpdatePCAPRecorder))) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_validate_cilium_network_policies", Description: "Validate Cilium network policies for the cluster"}, handleValidateCiliumNetworkPolicies) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_list_pcap_recorders", Description: "List PCAP recorders for the cluster"}, handleListPCAPRecorders) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_get_pcap_recorder", Description: "Get a PCAP recorder for the cluster"}, handleGetPCAPRecorder) + + if !readOnly { + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_delete_pcap_recorder", Description: "Delete a PCAP recorder for the cluster"}, handleDeletePCAPRecorder) + mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_update_pcap_recorder", Description: "Update a PCAP recorder for the cluster"}, handleUpdatePCAPRecorder) } } @@ -629,11 +532,14 @@ func runCiliumDbgCommandWithContext(ctx context.Context, command, nodeName strin Execute(ctx) } -func handleGetEndpointDetails(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - endpointID := mcp.ParseString(request, "endpoint_id", "") - labels := mcp.ParseString(request, "labels", "") - outputFormat := mcp.ParseString(request, "output_format", "json") - nodeName := mcp.ParseString(request, "node_name", "") +func handleGetEndpointDetails(ctx context.Context, request *mcp.CallToolRequest, in getEndpointDetailsInput) (*mcp.CallToolResult, any, error) { + if in.OutputFormat == "" { + in.OutputFormat = "json" + } + endpointID := in.EndpointID + labels := in.Labels + outputFormat := in.OutputFormat + nodeName := in.NodeName var cmd string if labels != "" { @@ -641,144 +547,147 @@ func handleGetEndpointDetails(ctx context.Context, request mcp.CallToolRequest) } else if endpointID != "" { cmd = fmt.Sprintf("endpoint get %s -o %s", endpointID, outputFormat) } else { - return mcp.NewToolResultError("either endpoint_id or labels must be provided"), nil + return mcp.NewToolResultError("either endpoint_id or labels must be provided"), nil, nil } output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to get endpoint details: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to get endpoint details: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleGetEndpointLogs(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - endpointID := mcp.ParseString(request, "endpoint_id", "") - nodeName := mcp.ParseString(request, "node_name", "") +func handleGetEndpointLogs(ctx context.Context, request *mcp.CallToolRequest, in getEndpointLogsInput) (*mcp.CallToolResult, any, error) { + endpointID := in.EndpointID + nodeName := in.NodeName if endpointID == "" { - return mcp.NewToolResultError("endpoint_id parameter is required"), nil + return mcp.NewToolResultError("endpoint_id parameter is required"), nil, nil } cmd := fmt.Sprintf("endpoint logs %s", endpointID) output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to get endpoint logs: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to get endpoint logs: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleGetEndpointHealth(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - endpointID := mcp.ParseString(request, "endpoint_id", "") - nodeName := mcp.ParseString(request, "node_name", "") +func handleGetEndpointHealth(ctx context.Context, request *mcp.CallToolRequest, in getEndpointHealthInput) (*mcp.CallToolResult, any, error) { + endpointID := in.EndpointID + nodeName := in.NodeName if endpointID == "" { - return mcp.NewToolResultError("endpoint_id parameter is required"), nil + return mcp.NewToolResultError("endpoint_id parameter is required"), nil, nil } cmd := fmt.Sprintf("endpoint health %s", endpointID) output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to get endpoint health: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to get endpoint health: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleManageEndpointLabels(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - endpointID := mcp.ParseString(request, "endpoint_id", "") - labels := mcp.ParseString(request, "labels", "") - action := mcp.ParseString(request, "action", "add") // Default to add - nodeName := mcp.ParseString(request, "node_name", "") +func handleManageEndpointLabels(ctx context.Context, request *mcp.CallToolRequest, in manageEndpointLabelsInput) (*mcp.CallToolResult, any, error) { + if in.Action == "" { + in.Action = "add" + } + endpointID := in.EndpointID + labels := in.Labels + action := in.Action + nodeName := in.NodeName if endpointID == "" || labels == "" { - return mcp.NewToolResultError("endpoint_id and labels parameters are required"), nil + return mcp.NewToolResultError("endpoint_id and labels parameters are required"), nil, nil } cmd := fmt.Sprintf("endpoint labels %s --%s %s", endpointID, action, labels) output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to manage endpoint labels: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to manage endpoint labels: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleManageEndpointConfiguration(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - endpointID := mcp.ParseString(request, "endpoint_id", "") - config := mcp.ParseString(request, "config", "") - nodeName := mcp.ParseString(request, "node_name", "") +func handleManageEndpointConfiguration(ctx context.Context, request *mcp.CallToolRequest, in manageEndpointConfigurationInput) (*mcp.CallToolResult, any, error) { + endpointID := in.EndpointID + config := in.Config + nodeName := in.NodeName if endpointID == "" { - return mcp.NewToolResultError("endpoint_id parameter is required"), nil + return mcp.NewToolResultError("endpoint_id parameter is required"), nil, nil } if config == "" { - return mcp.NewToolResultError("config parameter is required"), nil + return mcp.NewToolResultError("config parameter is required"), nil, nil } command := fmt.Sprintf("endpoint config %s %s", endpointID, config) output, err := runCiliumDbgCommand(ctx, command, nodeName) if err != nil { - return mcp.NewToolResultError("Error managing endpoint configuration: " + err.Error()), nil + return mcp.NewToolResultError("Error managing endpoint configuration: " + err.Error()), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleDisconnectEndpoint(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - endpointID := mcp.ParseString(request, "endpoint_id", "") - nodeName := mcp.ParseString(request, "node_name", "") +func handleDisconnectEndpoint(ctx context.Context, request *mcp.CallToolRequest, in disconnectEndpointInput) (*mcp.CallToolResult, any, error) { + endpointID := in.EndpointID + nodeName := in.NodeName if endpointID == "" { - return mcp.NewToolResultError("endpoint_id parameter is required"), nil + return mcp.NewToolResultError("endpoint_id parameter is required"), nil, nil } cmd := fmt.Sprintf("endpoint disconnect %s", endpointID) output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to disconnect endpoint: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to disconnect endpoint: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleGetEndpointsList(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - nodeName := mcp.ParseString(request, "node_name", "") +func handleGetEndpointsList(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, any, error) { + nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "endpoint list", nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to get endpoints list: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to get endpoints list: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleListIdentities(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - nodeName := mcp.ParseString(request, "node_name", "") +func handleListIdentities(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, any, error) { + nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "identity list", nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to list identities: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to list identities: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleGetIdentityDetails(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - identityID := mcp.ParseString(request, "identity_id", "") - nodeName := mcp.ParseString(request, "node_name", "") +func handleGetIdentityDetails(ctx context.Context, request *mcp.CallToolRequest, in getIdentityDetailsInput) (*mcp.CallToolResult, any, error) { + identityID := in.IdentityID + nodeName := in.NodeName if identityID == "" { - return mcp.NewToolResultError("identity_id parameter is required"), nil + return mcp.NewToolResultError("identity_id parameter is required"), nil, nil } cmd := fmt.Sprintf("identity get %s", identityID) output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to get identity details: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to get identity details: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleShowConfigurationOptions(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - listAll := mcp.ParseString(request, "list_all", "") == "true" - listReadOnly := mcp.ParseString(request, "list_read_only", "") == "true" - listOptions := mcp.ParseString(request, "list_options", "") == "true" - nodeName := mcp.ParseString(request, "node_name", "") +func handleShowConfigurationOptions(ctx context.Context, request *mcp.CallToolRequest, in showConfigurationOptionsInput) (*mcp.CallToolResult, any, error) { + listAll := in.ListAll + listReadOnly := in.ListReadOnly + listOptions := in.ListOptions + nodeName := in.NodeName var cmd string if listAll { @@ -793,18 +702,21 @@ func handleShowConfigurationOptions(ctx context.Context, request mcp.CallToolReq output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to show configuration options: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to show configuration options: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleToggleConfigurationOption(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - option := mcp.ParseString(request, "option", "") - value := mcp.ParseString(request, "value", "true") == "true" - nodeName := mcp.ParseString(request, "node_name", "") +func handleToggleConfigurationOption(ctx context.Context, request *mcp.CallToolRequest, in toggleConfigurationOptionInput) (*mcp.CallToolResult, any, error) { + option := in.Option + value := true + if in.Value != nil { + value = *in.Value + } + nodeName := in.NodeName if option == "" { - return mcp.NewToolResultError("option parameter is required"), nil + return mcp.NewToolResultError("option parameter is required"), nil, nil } valueStr := "enable" @@ -815,60 +727,63 @@ func handleToggleConfigurationOption(ctx context.Context, request mcp.CallToolRe cmd := fmt.Sprintf("config %s=%s", option, valueStr) output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to toggle configuration option: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to toggle configuration option: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleRequestDebuggingInformation(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - nodeName := mcp.ParseString(request, "node_name", "") +func handleRequestDebuggingInformation(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, any, error) { + nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "debuginfo", nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to request debugging information: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to request debugging information: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleDisplayEncryptionState(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - nodeName := mcp.ParseString(request, "node_name", "") +func handleDisplayEncryptionState(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, any, error) { + nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "encrypt status", nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to display encryption state: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to display encryption state: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleFlushIPsecState(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - nodeName := mcp.ParseString(request, "node_name", "") +func handleFlushIPsecState(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, any, error) { + nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "encrypt flush -f", nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to flush IPsec state: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to flush IPsec state: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleListEnvoyConfig(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - resourceName := mcp.ParseString(request, "resource_name", "") - nodeName := mcp.ParseString(request, "node_name", "") +func handleListEnvoyConfig(ctx context.Context, request *mcp.CallToolRequest, in listEnvoyConfigInput) (*mcp.CallToolResult, any, error) { + resourceName := in.ResourceName + nodeName := in.NodeName if resourceName == "" { - return mcp.NewToolResultError("resource_name parameter is required"), nil + return mcp.NewToolResultError("resource_name parameter is required"), nil, nil } cmd := fmt.Sprintf("envoy admin %s", resourceName) output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to list Envoy config: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to list Envoy config: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleFQDNCache(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - command := mcp.ParseString(request, "command", "list") - nodeName := mcp.ParseString(request, "node_name", "") +func handleFQDNCache(ctx context.Context, request *mcp.CallToolRequest, in fqdnCacheInput) (*mcp.CallToolResult, any, error) { + if in.Command == "" { + in.Command = "list" + } + command := in.Command + nodeName := in.NodeName var cmd string if command == "clean" { @@ -879,35 +794,35 @@ func handleFQDNCache(ctx context.Context, request mcp.CallToolRequest) (*mcp.Cal output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to manage FQDN cache: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to manage FQDN cache: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleShowDNSNames(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - nodeName := mcp.ParseString(request, "node_name", "") +func handleShowDNSNames(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, any, error) { + nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "fqdn names", nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to show DNS names: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to show DNS names: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleListIPAddresses(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - nodeName := mcp.ParseString(request, "node_name", "") +func handleListIPAddresses(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, any, error) { + nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "ip list", nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to list IP addresses: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to list IP addresses: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleShowIPCacheInformation(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - cidr := mcp.ParseString(request, "cidr", "") - labels := mcp.ParseString(request, "labels", "") - nodeName := mcp.ParseString(request, "node_name", "") +func handleShowIPCacheInformation(ctx context.Context, request *mcp.CallToolRequest, in showIPCacheInformationInput) (*mcp.CallToolResult, any, error) { + cidr := in.CIDR + labels := in.Labels + nodeName := in.NodeName var cmd string if labels != "" { @@ -915,130 +830,130 @@ func handleShowIPCacheInformation(ctx context.Context, request mcp.CallToolReque } else if cidr != "" { cmd = fmt.Sprintf("ip get %s", cidr) } else { - return mcp.NewToolResultError("either cidr or labels must be provided"), nil + return mcp.NewToolResultError("either cidr or labels must be provided"), nil, nil } output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to show IP cache information: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to show IP cache information: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleDeleteKeyFromKVStore(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - key := mcp.ParseString(request, "key", "") - nodeName := mcp.ParseString(request, "node_name", "") +func handleDeleteKeyFromKVStore(ctx context.Context, request *mcp.CallToolRequest, in kvStoreKeyInput) (*mcp.CallToolResult, any, error) { + key := in.Key + nodeName := in.NodeName if key == "" { - return mcp.NewToolResultError("key parameter is required"), nil + return mcp.NewToolResultError("key parameter is required"), nil, nil } cmd := fmt.Sprintf("kvstore delete %s", key) output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to delete key from kvstore: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to delete key from kvstore: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleGetKVStoreKey(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - key := mcp.ParseString(request, "key", "") - nodeName := mcp.ParseString(request, "node_name", "") +func handleGetKVStoreKey(ctx context.Context, request *mcp.CallToolRequest, in kvStoreKeyInput) (*mcp.CallToolResult, any, error) { + key := in.Key + nodeName := in.NodeName if key == "" { - return mcp.NewToolResultError("key parameter is required"), nil + return mcp.NewToolResultError("key parameter is required"), nil, nil } cmd := fmt.Sprintf("kvstore get %s", key) output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to get key from kvstore: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to get key from kvstore: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleSetKVStoreKey(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - key := mcp.ParseString(request, "key", "") - value := mcp.ParseString(request, "value", "") - nodeName := mcp.ParseString(request, "node_name", "") +func handleSetKVStoreKey(ctx context.Context, request *mcp.CallToolRequest, in setKVStoreKeyInput) (*mcp.CallToolResult, any, error) { + key := in.Key + value := in.Value + nodeName := in.NodeName if key == "" || value == "" { - return mcp.NewToolResultError("key and value parameters are required"), nil + return mcp.NewToolResultError("key and value parameters are required"), nil, nil } cmd := fmt.Sprintf("kvstore set %s=%s", key, value) output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to set key in kvstore: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to set key in kvstore: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleShowLoadInformation(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - nodeName := mcp.ParseString(request, "node_name", "") +func handleShowLoadInformation(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, any, error) { + nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "loadinfo", nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to show load information: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to show load information: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleListLocalRedirectPolicies(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - nodeName := mcp.ParseString(request, "node_name", "") +func handleListLocalRedirectPolicies(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, any, error) { + nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "lrp list", nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to list local redirect policies: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to list local redirect policies: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleListBPFMapEvents(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - mapName := mcp.ParseString(request, "map_name", "") - nodeName := mcp.ParseString(request, "node_name", "") +func handleListBPFMapEvents(ctx context.Context, request *mcp.CallToolRequest, in bpfMapInput) (*mcp.CallToolResult, any, error) { + mapName := in.MapName + nodeName := in.NodeName if mapName == "" { - return mcp.NewToolResultError("map_name parameter is required"), nil + return mcp.NewToolResultError("map_name parameter is required"), nil, nil } cmd := fmt.Sprintf("map events %s", mapName) output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to list BPF map events: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to list BPF map events: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleGetBPFMap(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - mapName := mcp.ParseString(request, "map_name", "") - nodeName := mcp.ParseString(request, "node_name", "") +func handleGetBPFMap(ctx context.Context, request *mcp.CallToolRequest, in bpfMapInput) (*mcp.CallToolResult, any, error) { + mapName := in.MapName + nodeName := in.NodeName if mapName == "" { - return mcp.NewToolResultError("map_name parameter is required"), nil + return mcp.NewToolResultError("map_name parameter is required"), nil, nil } cmd := fmt.Sprintf("map get %s", mapName) output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to get BPF map: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to get BPF map: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleListBPFMaps(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - nodeName := mcp.ParseString(request, "node_name", "") +func handleListBPFMaps(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, any, error) { + nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "map list", nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to list BPF maps: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to list BPF maps: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleListMetrics(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - matchPattern := mcp.ParseString(request, "match_pattern", "") - nodeName := mcp.ParseString(request, "node_name", "") +func handleListMetrics(ctx context.Context, request *mcp.CallToolRequest, in listMetricsInput) (*mcp.CallToolResult, any, error) { + matchPattern := in.MatchPattern + nodeName := in.NodeName var cmd string if matchPattern != "" { @@ -1049,34 +964,34 @@ func handleListMetrics(ctx context.Context, request mcp.CallToolRequest) (*mcp.C output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to list metrics: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to list metrics: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleListClusterNodes(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - nodeName := mcp.ParseString(request, "node_name", "") +func handleListClusterNodes(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, any, error) { + nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "node list", nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to list cluster nodes: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to list cluster nodes: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleListNodeIds(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - nodeName := mcp.ParseString(request, "node_name", "") +func handleListNodeIds(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, any, error) { + nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "nodeid list", nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to list node IDs: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to list node IDs: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleDisplayPolicyNodeInformation(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - labels := mcp.ParseString(request, "labels", "") - nodeName := mcp.ParseString(request, "node_name", "") +func handleDisplayPolicyNodeInformation(ctx context.Context, request *mcp.CallToolRequest, in displayPolicyNodeInformationInput) (*mcp.CallToolResult, any, error) { + labels := in.Labels + nodeName := in.NodeName var cmd string if labels != "" { @@ -1087,15 +1002,15 @@ func handleDisplayPolicyNodeInformation(ctx context.Context, request mcp.CallToo output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to display policy node information: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to display policy node information: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleDeletePolicyRules(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - labels := mcp.ParseString(request, "labels", "") - all := mcp.ParseString(request, "all", "") == "true" - nodeName := mcp.ParseString(request, "node_name", "") +func handleDeletePolicyRules(ctx context.Context, request *mcp.CallToolRequest, in deletePolicyRulesInput) (*mcp.CallToolResult, any, error) { + labels := in.Labels + all := in.All + nodeName := in.NodeName var cmd string if all { @@ -1103,43 +1018,43 @@ func handleDeletePolicyRules(ctx context.Context, request mcp.CallToolRequest) ( } else if labels != "" { cmd = fmt.Sprintf("policy delete %s", labels) } else { - return mcp.NewToolResultError("either labels or all=true must be provided"), nil + return mcp.NewToolResultError("either labels or all=true must be provided"), nil, nil } output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to delete policy rules: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to delete policy rules: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleDisplaySelectors(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - nodeName := mcp.ParseString(request, "node_name", "") +func handleDisplaySelectors(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, any, error) { + nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "policy selectors", nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to display selectors: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to display selectors: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleListXDPCIDRFilters(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - nodeName := mcp.ParseString(request, "node_name", "") +func handleListXDPCIDRFilters(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, any, error) { + nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "prefilter list", nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to list XDP CIDR filters: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to list XDP CIDR filters: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleUpdateXDPCIDRFilters(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - cidrPrefixes := mcp.ParseString(request, "cidr_prefixes", "") - revision := mcp.ParseString(request, "revision", "") - nodeName := mcp.ParseString(request, "node_name", "") +func handleUpdateXDPCIDRFilters(ctx context.Context, request *mcp.CallToolRequest, in xdpCIDRFiltersInput) (*mcp.CallToolResult, any, error) { + cidrPrefixes := in.CIDRPrefixes + revision := in.Revision + nodeName := in.NodeName if cidrPrefixes == "" { - return mcp.NewToolResultError("cidr_prefixes parameter is required"), nil + return mcp.NewToolResultError("cidr_prefixes parameter is required"), nil, nil } var cmd string @@ -1151,18 +1066,18 @@ func handleUpdateXDPCIDRFilters(ctx context.Context, request mcp.CallToolRequest output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to update XDP CIDR filters: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to update XDP CIDR filters: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleDeleteXDPCIDRFilters(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - cidrPrefixes := mcp.ParseString(request, "cidr_prefixes", "") - revision := mcp.ParseString(request, "revision", "") - nodeName := mcp.ParseString(request, "node_name", "") +func handleDeleteXDPCIDRFilters(ctx context.Context, request *mcp.CallToolRequest, in xdpCIDRFiltersInput) (*mcp.CallToolResult, any, error) { + cidrPrefixes := in.CIDRPrefixes + revision := in.Revision + nodeName := in.NodeName if cidrPrefixes == "" { - return mcp.NewToolResultError("cidr_prefixes parameter is required"), nil + return mcp.NewToolResultError("cidr_prefixes parameter is required"), nil, nil } var cmd string @@ -1174,15 +1089,15 @@ func handleDeleteXDPCIDRFilters(ctx context.Context, request mcp.CallToolRequest output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to delete XDP CIDR filters: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to delete XDP CIDR filters: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleValidateCiliumNetworkPolicies(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - enableK8s := mcp.ParseString(request, "enable_k8s", "") == "true" - enableK8sAPIDiscovery := mcp.ParseString(request, "enable_k8s_api_discovery", "") == "true" - nodeName := mcp.ParseString(request, "node_name", "") +func handleValidateCiliumNetworkPolicies(ctx context.Context, request *mcp.CallToolRequest, in validateCiliumNetworkPoliciesInput) (*mcp.CallToolResult, any, error) { + enableK8s := in.EnableK8s + enableK8sAPIDiscovery := in.EnableK8sAPIDiscovery + nodeName := in.NodeName cmd := "preflight validate-cnp" if enableK8s { @@ -1194,75 +1109,81 @@ func handleValidateCiliumNetworkPolicies(ctx context.Context, request mcp.CallTo output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to validate Cilium network policies: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to validate Cilium network policies: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleListPCAPRecorders(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - nodeName := mcp.ParseString(request, "node_name", "") +func handleListPCAPRecorders(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, any, error) { + nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "recorder list", nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to list PCAP recorders: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to list PCAP recorders: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleGetPCAPRecorder(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - recorderID := mcp.ParseString(request, "recorder_id", "") - nodeName := mcp.ParseString(request, "node_name", "") +func handleGetPCAPRecorder(ctx context.Context, request *mcp.CallToolRequest, in pcapRecorderIDInput) (*mcp.CallToolResult, any, error) { + recorderID := in.RecorderID + nodeName := in.NodeName if recorderID == "" { - return mcp.NewToolResultError("recorder_id parameter is required"), nil + return mcp.NewToolResultError("recorder_id parameter is required"), nil, nil } cmd := fmt.Sprintf("recorder get %s", recorderID) output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to get PCAP recorder: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to get PCAP recorder: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleDeletePCAPRecorder(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - recorderID := mcp.ParseString(request, "recorder_id", "") - nodeName := mcp.ParseString(request, "node_name", "") +func handleDeletePCAPRecorder(ctx context.Context, request *mcp.CallToolRequest, in pcapRecorderIDInput) (*mcp.CallToolResult, any, error) { + recorderID := in.RecorderID + nodeName := in.NodeName if recorderID == "" { - return mcp.NewToolResultError("recorder_id parameter is required"), nil + return mcp.NewToolResultError("recorder_id parameter is required"), nil, nil } cmd := fmt.Sprintf("recorder delete %s", recorderID) output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to delete PCAP recorder: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to delete PCAP recorder: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleUpdatePCAPRecorder(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - recorderID := mcp.ParseString(request, "recorder_id", "") - filters := mcp.ParseString(request, "filters", "") - caplen := mcp.ParseString(request, "caplen", "0") - id := mcp.ParseString(request, "id", "0") - nodeName := mcp.ParseString(request, "node_name", "") +func handleUpdatePCAPRecorder(ctx context.Context, request *mcp.CallToolRequest, in updatePCAPRecorderInput) (*mcp.CallToolResult, any, error) { + if in.Caplen == "" { + in.Caplen = "0" + } + if in.ID == "" { + in.ID = "0" + } + recorderID := in.RecorderID + filters := in.Filters + caplen := in.Caplen + id := in.ID + nodeName := in.NodeName if recorderID == "" || filters == "" { - return mcp.NewToolResultError("recorder_id and filters parameters are required"), nil + return mcp.NewToolResultError("recorder_id and filters parameters are required"), nil, nil } cmd := fmt.Sprintf("recorder update %s --filters %s --caplen %s --id %s", recorderID, filters, caplen, id) output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to update PCAP recorder: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to update PCAP recorder: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleListServices(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - showClusterMeshAffinity := mcp.ParseString(request, "show_cluster_mesh_affinity", "") == "true" - nodeName := mcp.ParseString(request, "node_name", "") +func handleListServices(ctx context.Context, request *mcp.CallToolRequest, in listServicesInput) (*mcp.CallToolResult, any, error) { + showClusterMeshAffinity := in.ShowClusterMeshAffinity + nodeName := in.NodeName var cmd string if showClusterMeshAffinity { @@ -1273,31 +1194,31 @@ func handleListServices(ctx context.Context, request mcp.CallToolRequest) (*mcp. output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to list services: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to list services: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleGetServiceInformation(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - serviceID := mcp.ParseString(request, "service_id", "") - nodeName := mcp.ParseString(request, "node_name", "") +func handleGetServiceInformation(ctx context.Context, request *mcp.CallToolRequest, in getServiceInformationInput) (*mcp.CallToolResult, any, error) { + serviceID := in.ServiceID + nodeName := in.NodeName if serviceID == "" { - return mcp.NewToolResultError("service_id parameter is required"), nil + return mcp.NewToolResultError("service_id parameter is required"), nil, nil } cmd := fmt.Sprintf("service get %s", serviceID) output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to get service information: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to get service information: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleDeleteService(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - serviceID := mcp.ParseString(request, "service_id", "") - all := mcp.ParseString(request, "all", "") == "true" - nodeName := mcp.ParseString(request, "node_name", "") +func handleDeleteService(ctx context.Context, request *mcp.CallToolRequest, in deleteServiceInput) (*mcp.CallToolResult, any, error) { + serviceID := in.ServiceID + all := in.All + nodeName := in.NodeName var cmd string if all { @@ -1305,35 +1226,47 @@ func handleDeleteService(ctx context.Context, request mcp.CallToolRequest) (*mcp } else if serviceID != "" { cmd = fmt.Sprintf("service delete %s", serviceID) } else { - return mcp.NewToolResultError("either service_id or all=true must be provided"), nil + return mcp.NewToolResultError("either service_id or all=true must be provided"), nil, nil } output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to delete service: %v", err)), nil - } - return mcp.NewToolResultText(output), nil -} - -func handleUpdateService(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - backendWeights := mcp.ParseString(request, "backend_weights", "") - backends := mcp.ParseString(request, "backends", "") - frontend := mcp.ParseString(request, "frontend", "") - id := mcp.ParseString(request, "id", "") - k8sClusterInternal := mcp.ParseString(request, "k8s_cluster_internal", "") == "true" - k8sExtTrafficPolicy := mcp.ParseString(request, "k8s_ext_traffic_policy", "Cluster") - k8sExternal := mcp.ParseString(request, "k8s_external", "") == "true" - k8sHostPort := mcp.ParseString(request, "k8s_host_port", "") == "true" - k8sIntTrafficPolicy := mcp.ParseString(request, "k8s_int_traffic_policy", "Cluster") - k8sLoadBalancer := mcp.ParseString(request, "k8s_load_balancer", "") == "true" - k8sNodePort := mcp.ParseString(request, "k8s_node_port", "") == "true" - localRedirect := mcp.ParseString(request, "local_redirect", "") == "true" - protocol := mcp.ParseString(request, "protocol", "TCP") - states := mcp.ParseString(request, "states", "active") - nodeName := mcp.ParseString(request, "node_name", "") + return mcp.NewToolResultError(fmt.Sprintf("Failed to delete service: %v", err)), nil, nil + } + return mcp.NewToolResultText(output), nil, nil +} + +func handleUpdateService(ctx context.Context, request *mcp.CallToolRequest, in updateServiceInput) (*mcp.CallToolResult, any, error) { + if in.K8sExtTrafficPolicy == "" { + in.K8sExtTrafficPolicy = "Cluster" + } + if in.K8sIntTrafficPolicy == "" { + in.K8sIntTrafficPolicy = "Cluster" + } + if in.Protocol == "" { + in.Protocol = "TCP" + } + if in.States == "" { + in.States = "active" + } + backendWeights := in.BackendWeights + backends := in.Backends + frontend := in.Frontend + id := in.ID + k8sClusterInternal := in.K8sClusterInternal + k8sExtTrafficPolicy := in.K8sExtTrafficPolicy + k8sExternal := in.K8sExternal + k8sHostPort := in.K8sHostPort + k8sIntTrafficPolicy := in.K8sIntTrafficPolicy + k8sLoadBalancer := in.K8sLoadBalancer + k8sNodePort := in.K8sNodePort + localRedirect := in.LocalRedirect + protocol := in.Protocol + states := in.States + nodeName := in.NodeName if backends == "" || frontend == "" || id == "" { - return mcp.NewToolResultError("backends, frontend, and id parameters are required"), nil + return mcp.NewToolResultError("backends, frontend, and id parameters are required"), nil, nil } cmd := fmt.Sprintf("service update %s --backends %s --frontend %s --protocol %s --states %s", @@ -1369,20 +1302,20 @@ func handleUpdateService(ctx context.Context, request mcp.CallToolRequest) (*mcp output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to update service: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to update service: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } -func handleGetDaemonStatus(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - showAllAddresses := mcp.ParseString(request, "show_all_addresses", "") == "true" - showAllClusters := mcp.ParseString(request, "show_all_clusters", "") == "true" - showAllControllers := mcp.ParseString(request, "show_all_controllers", "") == "true" - showHealth := mcp.ParseString(request, "show_health", "") == "true" - showAllNodes := mcp.ParseString(request, "show_all_nodes", "") == "true" - showAllRedirects := mcp.ParseString(request, "show_all_redirects", "") == "true" - brief := mcp.ParseString(request, "brief", "") == "true" - nodeName := mcp.ParseString(request, "node_name", "") +func handleGetDaemonStatus(ctx context.Context, request *mcp.CallToolRequest, in getDaemonStatusInput) (*mcp.CallToolResult, any, error) { + showAllAddresses := in.ShowAllAddresses + showAllClusters := in.ShowAllClusters + showAllControllers := in.ShowAllControllers + showHealth := in.ShowHealth + showAllNodes := in.ShowAllNodes + showAllRedirects := in.ShowAllRedirects + brief := in.Brief + nodeName := in.NodeName cmd := "status" if showAllAddresses { @@ -1409,7 +1342,7 @@ func handleGetDaemonStatus(ctx context.Context, request mcp.CallToolRequest) (*m output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to get daemon status: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to get daemon status: %v", err)), nil, nil } - return mcp.NewToolResultText(output), nil + return mcp.NewToolResultText(output), nil, nil } diff --git a/pkg/cilium/cilium_test.go b/pkg/cilium/cilium_test.go index 84313e77..bde7ce68 100644 --- a/pkg/cilium/cilium_test.go +++ b/pkg/cilium/cilium_test.go @@ -8,14 +8,15 @@ import ( "testing" "github.com/kagent-dev/tools/internal/cmd" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" + mcp "github.com/kagent-dev/tools/internal/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func boolPtr(b bool) *bool { return &b } + func TestRegisterCiliumTools(t *testing.T) { - s := server.NewMCPServer("test-server", "v0.0.1") + s := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) RegisterTools(s, false) // false = enable all tools including write operations // We can't directly check the tools, but we can ensure the call doesn't panic } @@ -28,22 +29,14 @@ func TestHandleCiliumStatusAndVersion(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) - result, err := handleCiliumStatusAndVersion(ctx, mcp.CallToolRequest{}) + result, _, err := handleCiliumStatusAndVersion(ctx, &mcp.CallToolRequest{}, noInput{}) require.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) - var textContent mcp.TextContent - var ok bool - for _, content := range result.Content { - if textContent, ok = content.(mcp.TextContent); ok { - break - } - } - require.True(t, ok, "no text content in result") - - assert.Contains(t, textContent.Text, "Cilium status: OK") - assert.Contains(t, textContent.Text, "cilium version 1.14.0") + text := getResultText(result) + assert.Contains(t, text, "Cilium status: OK") + assert.Contains(t, text, "cilium version 1.14.0") } func TestHandleCiliumStatusAndVersionError(t *testing.T) { @@ -54,7 +47,7 @@ func TestHandleCiliumStatusAndVersionError(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) - result, err := handleCiliumStatusAndVersion(ctx, mcp.CallToolRequest{}) + result, _, err := handleCiliumStatusAndVersion(ctx, &mcp.CallToolRequest{}, noInput{}) require.NoError(t, err) assert.NotNil(t, result) assert.True(t, result.IsError) @@ -68,7 +61,7 @@ func TestHandleInstallCilium(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) - result, err := handleInstallCilium(ctx, mcp.CallToolRequest{}) + result, _, err := handleInstallCilium(ctx, &mcp.CallToolRequest{}, installCiliumInput{}) require.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -82,7 +75,7 @@ func TestHandleUninstallCilium(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) - result, err := handleUninstallCilium(ctx, mcp.CallToolRequest{}) + result, _, err := handleUninstallCilium(ctx, &mcp.CallToolRequest{}, noInput{}) require.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -96,7 +89,7 @@ func TestHandleUpgradeCilium(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) - result, err := handleUpgradeCilium(ctx, mcp.CallToolRequest{}) + result, _, err := handleUpgradeCilium(ctx, &mcp.CallToolRequest{}, upgradeCiliumInput{}) require.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -110,15 +103,7 @@ func TestHandleConnectToRemoteCluster(t *testing.T) { mock := cmd.NewMockShellExecutor() mock.AddCommandString("cilium", []string{"clustermesh", "connect", "--destination-cluster", "my-cluster"}, "✓ Connected to cluster my-cluster!", nil) ctx = cmd.WithShellExecutor(ctx, mock) - req := mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Arguments: map[string]any{ - "cluster_name": "my-cluster", - }, - }, - } - - result, err := handleConnectToRemoteCluster(ctx, req) + result, _, err := handleConnectToRemoteCluster(ctx, &mcp.CallToolRequest{}, connectToRemoteClusterInput{ClusterName: "my-cluster"}) require.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -126,12 +111,7 @@ func TestHandleConnectToRemoteCluster(t *testing.T) { }) t.Run("missing cluster_name", func(t *testing.T) { - req := mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Arguments: map[string]any{}, - }, - } - result, err := handleConnectToRemoteCluster(ctx, req) + result, _, err := handleConnectToRemoteCluster(ctx, &mcp.CallToolRequest{}, connectToRemoteClusterInput{}) require.NoError(t, err) assert.NotNil(t, result) assert.True(t, result.IsError) @@ -146,15 +126,7 @@ func TestHandleDisconnectFromRemoteCluster(t *testing.T) { mock := cmd.NewMockShellExecutor() mock.AddCommandString("cilium", []string{"clustermesh", "disconnect", "--destination-cluster", "my-cluster"}, "✓ Disconnected from cluster my-cluster!", nil) ctx = cmd.WithShellExecutor(ctx, mock) - req := mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Arguments: map[string]any{ - "cluster_name": "my-cluster", - }, - }, - } - - result, err := handleDisconnectRemoteCluster(ctx, req) + result, _, err := handleDisconnectRemoteCluster(ctx, &mcp.CallToolRequest{}, disconnectRemoteClusterInput{ClusterName: "my-cluster"}) require.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -162,12 +134,7 @@ func TestHandleDisconnectFromRemoteCluster(t *testing.T) { }) t.Run("missing cluster_name", func(t *testing.T) { - req := mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Arguments: map[string]any{}, - }, - } - result, err := handleDisconnectRemoteCluster(ctx, req) + result, _, err := handleDisconnectRemoteCluster(ctx, &mcp.CallToolRequest{}, disconnectRemoteClusterInput{}) require.NoError(t, err) assert.NotNil(t, result) assert.True(t, result.IsError) @@ -180,15 +147,7 @@ func TestHandleEnableHubble(t *testing.T) { mock := cmd.NewMockShellExecutor() mock.AddCommandString("cilium", []string{"hubble", "enable"}, "✓ Hubble was successfully enabled!", nil) ctx = cmd.WithShellExecutor(ctx, mock) - req := mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Arguments: map[string]any{ - "enable": true, - }, - }, - } - - result, err := handleToggleHubble(ctx, req) + result, _, err := handleToggleHubble(ctx, &mcp.CallToolRequest{}, enableToggleInput{Enable: boolPtr(true)}) require.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -200,14 +159,7 @@ func TestHandleDisableHubble(t *testing.T) { mock := cmd.NewMockShellExecutor() mock.AddCommandString("cilium", []string{"hubble", "disable"}, "✓ Hubble was successfully disabled!", nil) ctx = cmd.WithShellExecutor(ctx, mock) - req := mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Arguments: map[string]any{ - "enable": false, - }, - }, - } - result, err := handleToggleHubble(ctx, req) + result, _, err := handleToggleHubble(ctx, &mcp.CallToolRequest{}, enableToggleInput{Enable: boolPtr(false)}) require.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -219,7 +171,7 @@ func TestHandleListBGPPeers(t *testing.T) { mock := cmd.NewMockShellExecutor() mock.AddCommandString("cilium", []string{"bgp", "peers"}, "listing BGP peers", nil) ctx = cmd.WithShellExecutor(ctx, mock) - result, err := handleListBGPPeers(ctx, mcp.CallToolRequest{}) + result, _, err := handleListBGPPeers(ctx, &mcp.CallToolRequest{}, noInput{}) require.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -231,7 +183,7 @@ func TestHandleListBGPRoutes(t *testing.T) { mock := cmd.NewMockShellExecutor() mock.AddCommandString("cilium", []string{"bgp", "routes"}, "listing BGP routes", nil) ctx = cmd.WithShellExecutor(ctx, mock) - result, err := handleListBGPRoutes(ctx, mcp.CallToolRequest{}) + result, _, err := handleListBGPRoutes(ctx, &mcp.CallToolRequest{}, noInput{}) require.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -275,22 +227,13 @@ func mockCiliumDbgCommand(mock *cmd.MockShellExecutor, dbgArgs []string, output mock.AddCommandString("kubectl", execArgs, output, err) } -func newRequestWithArgs(args map[string]any) mcp.CallToolRequest { - return mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Arguments: args, - }, - } -} - func TestHandleGetEndpointsList(t *testing.T) { ctx := context.Background() mock := cmd.NewMockShellExecutor() mockCiliumDbgCommand(mock, []string{"endpoint", "list"}, "ENDPOINT POLICY\n34 Disabled", nil) ctx = cmd.WithShellExecutor(ctx, mock) - req := newRequestWithArgs(map[string]any{"node_name": "test-node"}) - result, err := handleGetEndpointsList(ctx, req) + result, _, err := handleGetEndpointsList(ctx, &mcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "ENDPOINT") @@ -302,8 +245,7 @@ func TestHandleGetEndpointDetails(t *testing.T) { mockCiliumDbgCommand(mock, []string{"endpoint", "get", "34", "-o", "json"}, `{"id": 34}`, nil) ctx = cmd.WithShellExecutor(ctx, mock) - req := newRequestWithArgs(map[string]any{"endpoint_id": "34", "node_name": "test-node"}) - result, err := handleGetEndpointDetails(ctx, req) + result, _, err := handleGetEndpointDetails(ctx, &mcp.CallToolRequest{}, getEndpointDetailsInput{EndpointID: "34", NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), `"id": 34`) @@ -315,8 +257,7 @@ func TestHandleGetEndpointLogs(t *testing.T) { mockCiliumDbgCommand(mock, []string{"endpoint", "logs", "34"}, "endpoint log output", nil) ctx = cmd.WithShellExecutor(ctx, mock) - req := newRequestWithArgs(map[string]any{"endpoint_id": "34", "node_name": "test-node"}) - result, err := handleGetEndpointLogs(ctx, req) + result, _, err := handleGetEndpointLogs(ctx, &mcp.CallToolRequest{}, getEndpointLogsInput{EndpointID: "34", NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "endpoint log output") @@ -328,8 +269,7 @@ func TestHandleGetEndpointHealth(t *testing.T) { mockCiliumDbgCommand(mock, []string{"endpoint", "health", "34"}, "endpoint health OK", nil) ctx = cmd.WithShellExecutor(ctx, mock) - req := newRequestWithArgs(map[string]any{"endpoint_id": "34", "node_name": "test-node"}) - result, err := handleGetEndpointHealth(ctx, req) + result, _, err := handleGetEndpointHealth(ctx, &mcp.CallToolRequest{}, getEndpointHealthInput{EndpointID: "34", NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "endpoint health OK") @@ -342,8 +282,7 @@ func TestHandleShowConfigurationOptions(t *testing.T) { mockCiliumDbgCommand(mock, []string{"config"}, "PolicyEnforcement=default", nil) ctx = cmd.WithShellExecutor(ctx, mock) - req := newRequestWithArgs(map[string]any{"node_name": "test-node"}) - result, err := handleShowConfigurationOptions(ctx, req) + result, _, err := handleShowConfigurationOptions(ctx, &mcp.CallToolRequest{}, showConfigurationOptionsInput{NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "PolicyEnforcement") @@ -355,8 +294,7 @@ func TestHandleShowConfigurationOptions(t *testing.T) { mockCiliumDbgCommand(mock, []string{"config", "--all"}, "all config options", nil) ctx = cmd.WithShellExecutor(ctx, mock) - req := newRequestWithArgs(map[string]any{"node_name": "test-node", "list_all": "true"}) - result, err := handleShowConfigurationOptions(ctx, req) + result, _, err := handleShowConfigurationOptions(ctx, &mcp.CallToolRequest{}, showConfigurationOptionsInput{NodeName: "test-node", ListAll: true}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "all config options") @@ -368,8 +306,7 @@ func TestHandleShowConfigurationOptions(t *testing.T) { mockCiliumDbgCommand(mock, []string{"config", "-r"}, "read only config", nil) ctx = cmd.WithShellExecutor(ctx, mock) - req := newRequestWithArgs(map[string]any{"node_name": "test-node", "list_read_only": "true"}) - result, err := handleShowConfigurationOptions(ctx, req) + result, _, err := handleShowConfigurationOptions(ctx, &mcp.CallToolRequest{}, showConfigurationOptionsInput{NodeName: "test-node", ListReadOnly: true}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "read only config") @@ -382,8 +319,7 @@ func TestHandleToggleConfigurationOption(t *testing.T) { mockCiliumDbgCommand(mock, []string{"config", "PolicyEnforcement=enable"}, "option toggled", nil) ctx = cmd.WithShellExecutor(ctx, mock) - req := newRequestWithArgs(map[string]any{"option": "PolicyEnforcement", "value": "true", "node_name": "test-node"}) - result, err := handleToggleConfigurationOption(ctx, req) + result, _, err := handleToggleConfigurationOption(ctx, &mcp.CallToolRequest{}, toggleConfigurationOptionInput{Option: "PolicyEnforcement", Value: boolPtr(true), NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "option toggled") @@ -395,8 +331,7 @@ func TestHandleListIdentities(t *testing.T) { mockCiliumDbgCommand(mock, []string{"identity", "list"}, "ID LABELS\n1 reserved:host", nil) ctx = cmd.WithShellExecutor(ctx, mock) - req := newRequestWithArgs(map[string]any{"node_name": "test-node"}) - result, err := handleListIdentities(ctx, req) + result, _, err := handleListIdentities(ctx, &mcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "reserved:host") @@ -408,8 +343,7 @@ func TestHandleGetDaemonStatus(t *testing.T) { mockCiliumDbgCommand(mock, []string{"status"}, "KVStore: Ok\nKubernetes: Ok", nil) ctx = cmd.WithShellExecutor(ctx, mock) - req := newRequestWithArgs(map[string]any{"node_name": "test-node"}) - result, err := handleGetDaemonStatus(ctx, req) + result, _, err := handleGetDaemonStatus(ctx, &mcp.CallToolRequest{}, getDaemonStatusInput{NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "KVStore: Ok") @@ -421,8 +355,7 @@ func TestHandleDisplayEncryptionState(t *testing.T) { mockCiliumDbgCommand(mock, []string{"encrypt", "status"}, "Encryption: Disabled", nil) ctx = cmd.WithShellExecutor(ctx, mock) - req := newRequestWithArgs(map[string]any{"node_name": "test-node"}) - result, err := handleDisplayEncryptionState(ctx, req) + result, _, err := handleDisplayEncryptionState(ctx, &mcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "Encryption: Disabled") @@ -434,8 +367,7 @@ func TestHandleShowDNSNames(t *testing.T) { mockCiliumDbgCommand(mock, []string{"fqdn", "names"}, "DNS names output", nil) ctx = cmd.WithShellExecutor(ctx, mock) - req := newRequestWithArgs(map[string]any{"node_name": "test-node"}) - result, err := handleShowDNSNames(ctx, req) + result, _, err := handleShowDNSNames(ctx, &mcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "DNS names output") @@ -447,8 +379,7 @@ func TestHandleFQDNCache(t *testing.T) { mockCiliumDbgCommand(mock, []string{"fqdn", "cache", "list"}, "FQDN cache entries", nil) ctx = cmd.WithShellExecutor(ctx, mock) - req := newRequestWithArgs(map[string]any{"node_name": "test-node"}) - result, err := handleFQDNCache(ctx, req) + result, _, err := handleFQDNCache(ctx, &mcp.CallToolRequest{}, fqdnCacheInput{NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "FQDN cache entries") @@ -460,8 +391,7 @@ func TestHandleListClusterNodes(t *testing.T) { mockCiliumDbgCommand(mock, []string{"node", "list"}, "Name IPv4 Address\nnode1 10.0.0.1", nil) ctx = cmd.WithShellExecutor(ctx, mock) - req := newRequestWithArgs(map[string]any{"node_name": "test-node"}) - result, err := handleListClusterNodes(ctx, req) + result, _, err := handleListClusterNodes(ctx, &mcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "node1") @@ -473,8 +403,7 @@ func TestHandleListNodeIds(t *testing.T) { mockCiliumDbgCommand(mock, []string{"nodeid", "list"}, "ID IP\n1 10.0.0.1", nil) ctx = cmd.WithShellExecutor(ctx, mock) - req := newRequestWithArgs(map[string]any{"node_name": "test-node"}) - result, err := handleListNodeIds(ctx, req) + result, _, err := handleListNodeIds(ctx, &mcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "10.0.0.1") @@ -486,8 +415,7 @@ func TestHandleListBPFMaps(t *testing.T) { mockCiliumDbgCommand(mock, []string{"map", "list"}, "Name Num entries\ncilium_lb4 22", nil) ctx = cmd.WithShellExecutor(ctx, mock) - req := newRequestWithArgs(map[string]any{"node_name": "test-node"}) - result, err := handleListBPFMaps(ctx, req) + result, _, err := handleListBPFMaps(ctx, &mcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "cilium_lb4") @@ -499,8 +427,7 @@ func TestHandleGetBPFMap(t *testing.T) { mockCiliumDbgCommand(mock, []string{"map", "get", "cilium_lb4"}, "map contents", nil) ctx = cmd.WithShellExecutor(ctx, mock) - req := newRequestWithArgs(map[string]any{"map_name": "cilium_lb4", "node_name": "test-node"}) - result, err := handleGetBPFMap(ctx, req) + result, _, err := handleGetBPFMap(ctx, &mcp.CallToolRequest{}, bpfMapInput{MapName: "cilium_lb4", NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "map contents") @@ -512,8 +439,7 @@ func TestHandleListBPFMapEvents(t *testing.T) { mockCiliumDbgCommand(mock, []string{"map", "events", "cilium_lb4"}, "map events", nil) ctx = cmd.WithShellExecutor(ctx, mock) - req := newRequestWithArgs(map[string]any{"map_name": "cilium_lb4", "node_name": "test-node"}) - result, err := handleListBPFMapEvents(ctx, req) + result, _, err := handleListBPFMapEvents(ctx, &mcp.CallToolRequest{}, bpfMapInput{MapName: "cilium_lb4", NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "map events") @@ -525,8 +451,7 @@ func TestHandleListMetrics(t *testing.T) { mockCiliumDbgCommand(mock, []string{"metrics", "list"}, "Metric Value\ncilium_endpoint_count 4", nil) ctx = cmd.WithShellExecutor(ctx, mock) - req := newRequestWithArgs(map[string]any{"node_name": "test-node"}) - result, err := handleListMetrics(ctx, req) + result, _, err := handleListMetrics(ctx, &mcp.CallToolRequest{}, listMetricsInput{NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "cilium_endpoint_count") @@ -538,8 +463,7 @@ func TestHandleListServices(t *testing.T) { mockCiliumDbgCommand(mock, []string{"service", "list"}, "ID Frontend\n1 10.96.0.1:443", nil) ctx = cmd.WithShellExecutor(ctx, mock) - req := newRequestWithArgs(map[string]any{"node_name": "test-node"}) - result, err := handleListServices(ctx, req) + result, _, err := handleListServices(ctx, &mcp.CallToolRequest{}, listServicesInput{NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "10.96.0.1") @@ -551,8 +475,7 @@ func TestHandleListIPAddresses(t *testing.T) { mockCiliumDbgCommand(mock, []string{"ip", "list"}, "IP Identity\n10.0.0.1 1", nil) ctx = cmd.WithShellExecutor(ctx, mock) - req := newRequestWithArgs(map[string]any{"node_name": "test-node"}) - result, err := handleListIPAddresses(ctx, req) + result, _, err := handleListIPAddresses(ctx, &mcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "10.0.0.1") @@ -564,8 +487,7 @@ func TestHandleDisplaySelectors(t *testing.T) { mockCiliumDbgCommand(mock, []string{"policy", "selectors"}, "SELECTOR IDENTITIES", nil) ctx = cmd.WithShellExecutor(ctx, mock) - req := newRequestWithArgs(map[string]any{"node_name": "test-node"}) - result, err := handleDisplaySelectors(ctx, req) + result, _, err := handleDisplaySelectors(ctx, &mcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "SELECTOR") @@ -577,8 +499,7 @@ func TestHandleListLocalRedirectPolicies(t *testing.T) { mockCiliumDbgCommand(mock, []string{"lrp", "list"}, "No local redirect policies", nil) ctx = cmd.WithShellExecutor(ctx, mock) - req := newRequestWithArgs(map[string]any{"node_name": "test-node"}) - result, err := handleListLocalRedirectPolicies(ctx, req) + result, _, err := handleListLocalRedirectPolicies(ctx, &mcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "No local redirect policies") @@ -590,8 +511,7 @@ func TestHandleRequestDebuggingInformation(t *testing.T) { mockCiliumDbgCommand(mock, []string{"debuginfo"}, "debug info output", nil) ctx = cmd.WithShellExecutor(ctx, mock) - req := newRequestWithArgs(map[string]any{"node_name": "test-node"}) - result, err := handleRequestDebuggingInformation(ctx, req) + result, _, err := handleRequestDebuggingInformation(ctx, &mcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "debug info output") @@ -603,8 +523,7 @@ func TestHandleListXDPCIDRFilters(t *testing.T) { mockCiliumDbgCommand(mock, []string{"prefilter", "list"}, "CIDR filters", nil) ctx = cmd.WithShellExecutor(ctx, mock) - req := newRequestWithArgs(map[string]any{"node_name": "test-node"}) - result, err := handleListXDPCIDRFilters(ctx, req) + result, _, err := handleListXDPCIDRFilters(ctx, &mcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "CIDR filters") @@ -614,52 +533,136 @@ func getResultText(r *mcp.CallToolResult) string { if r == nil || len(r.Content) == 0 { return "" } - if textContent, ok := r.Content[0].(mcp.TextContent); ok { + if textContent, ok := r.Content[0].(*mcp.TextContent); ok { return strings.TrimSpace(textContent.Text) } return "" } -type ciliumHandler func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) - // TestCiliumDbgHandlers exercises the success path of every cilium-dbg based handler. func TestCiliumDbgHandlers(t *testing.T) { cases := []struct { name string - handler ciliumHandler - args map[string]any dbgArgs []string expect string + run func(context.Context) (*mcp.CallToolResult, error) }{ - {"manage_endpoint_labels", handleManageEndpointLabels, map[string]any{"endpoint_id": "34", "labels": "key=val"}, []string{"endpoint", "labels", "34", "--add", "key=val"}, "ok"}, - {"manage_endpoint_configuration", handleManageEndpointConfiguration, map[string]any{"endpoint_id": "34", "config": "Debug=true"}, []string{"endpoint", "config", "34", "Debug=true"}, "ok"}, - {"disconnect_endpoint", handleDisconnectEndpoint, map[string]any{"endpoint_id": "34"}, []string{"endpoint", "disconnect", "34"}, "ok"}, - {"get_identity_details", handleGetIdentityDetails, map[string]any{"identity_id": "123"}, []string{"identity", "get", "123"}, "ok"}, - {"flush_ipsec_state", handleFlushIPsecState, map[string]any{}, []string{"encrypt", "flush", "-f"}, "ok"}, - {"list_envoy_config", handleListEnvoyConfig, map[string]any{"resource_name": "clusters"}, []string{"envoy", "admin", "clusters"}, "ok"}, - {"show_ipcache_cidr", handleShowIPCacheInformation, map[string]any{"cidr": "10.0.0.0/24"}, []string{"ip", "get", "10.0.0.0/24"}, "ok"}, - {"show_ipcache_labels", handleShowIPCacheInformation, map[string]any{"labels": "app=foo"}, []string{"ip", "get", "--labels", "app=foo"}, "ok"}, - {"delete_kvstore_key", handleDeleteKeyFromKVStore, map[string]any{"key": "foo"}, []string{"kvstore", "delete", "foo"}, "ok"}, - {"get_kvstore_key", handleGetKVStoreKey, map[string]any{"key": "foo"}, []string{"kvstore", "get", "foo"}, "ok"}, - {"set_kvstore_key", handleSetKVStoreKey, map[string]any{"key": "foo", "value": "bar"}, []string{"kvstore", "set", "foo=bar"}, "ok"}, - {"show_load_information", handleShowLoadInformation, map[string]any{}, []string{"loadinfo"}, "ok"}, - {"display_policy_node_info", handleDisplayPolicyNodeInformation, map[string]any{}, []string{"policy", "get"}, "ok"}, - {"display_policy_node_info_labels", handleDisplayPolicyNodeInformation, map[string]any{"labels": "k=v"}, []string{"policy", "get", "k=v"}, "ok"}, - {"delete_policy_rules_all", handleDeletePolicyRules, map[string]any{"all": "true"}, []string{"policy", "delete", "--all"}, "ok"}, - {"delete_policy_rules_labels", handleDeletePolicyRules, map[string]any{"labels": "k=v"}, []string{"policy", "delete", "k=v"}, "ok"}, - {"update_xdp_cidr", handleUpdateXDPCIDRFilters, map[string]any{"cidr_prefixes": "10.0.0.0/8"}, []string{"prefilter", "update", "--cidr", "10.0.0.0/8"}, "ok"}, - {"update_xdp_cidr_rev", handleUpdateXDPCIDRFilters, map[string]any{"cidr_prefixes": "10.0.0.0/8", "revision": "2"}, []string{"prefilter", "update", "--cidr", "10.0.0.0/8", "--revision", "2"}, "ok"}, - {"delete_xdp_cidr", handleDeleteXDPCIDRFilters, map[string]any{"cidr_prefixes": "10.0.0.0/8"}, []string{"prefilter", "delete", "--cidr", "10.0.0.0/8"}, "ok"}, - {"delete_xdp_cidr_rev", handleDeleteXDPCIDRFilters, map[string]any{"cidr_prefixes": "10.0.0.0/8", "revision": "2"}, []string{"prefilter", "delete", "--cidr", "10.0.0.0/8", "--revision", "2"}, "ok"}, - {"validate_cnp", handleValidateCiliumNetworkPolicies, map[string]any{"enable_k8s": "true", "enable_k8s_api_discovery": "true"}, []string{"preflight", "validate-cnp", "--enable-k8s", "--enable-k8s-api-discovery"}, "ok"}, - {"list_pcap_recorders", handleListPCAPRecorders, map[string]any{}, []string{"recorder", "list"}, "ok"}, - {"get_pcap_recorder", handleGetPCAPRecorder, map[string]any{"recorder_id": "1"}, []string{"recorder", "get", "1"}, "ok"}, - {"delete_pcap_recorder", handleDeletePCAPRecorder, map[string]any{"recorder_id": "1"}, []string{"recorder", "delete", "1"}, "ok"}, - {"update_pcap_recorder", handleUpdatePCAPRecorder, map[string]any{"recorder_id": "1", "filters": "f"}, []string{"recorder", "update", "1", "--filters", "f", "--caplen", "0", "--id", "0"}, "ok"}, - {"get_service_information", handleGetServiceInformation, map[string]any{"service_id": "5"}, []string{"service", "get", "5"}, "ok"}, - {"delete_service_all", handleDeleteService, map[string]any{"all": "true"}, []string{"service", "delete", "--all"}, "ok"}, - {"delete_service_id", handleDeleteService, map[string]any{"service_id": "5"}, []string{"service", "delete", "5"}, "ok"}, - {"update_service", handleUpdateService, map[string]any{"backends": "b", "frontend": "f", "id": "1"}, []string{"service", "update", "1", "--backends", "b", "--frontend", "f", "--protocol", "TCP", "--states", "active"}, "ok"}, + {"manage_endpoint_labels", []string{"endpoint", "labels", "34", "--add", "key=val"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleManageEndpointLabels(ctx, &mcp.CallToolRequest{}, manageEndpointLabelsInput{EndpointID: "34", Labels: "key=val", NodeName: "test-node"}) + return r, err + }}, + {"manage_endpoint_configuration", []string{"endpoint", "config", "34", "Debug=true"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleManageEndpointConfiguration(ctx, &mcp.CallToolRequest{}, manageEndpointConfigurationInput{EndpointID: "34", Config: "Debug=true", NodeName: "test-node"}) + return r, err + }}, + {"disconnect_endpoint", []string{"endpoint", "disconnect", "34"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleDisconnectEndpoint(ctx, &mcp.CallToolRequest{}, disconnectEndpointInput{EndpointID: "34", NodeName: "test-node"}) + return r, err + }}, + {"get_identity_details", []string{"identity", "get", "123"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleGetIdentityDetails(ctx, &mcp.CallToolRequest{}, getIdentityDetailsInput{IdentityID: "123", NodeName: "test-node"}) + return r, err + }}, + {"flush_ipsec_state", []string{"encrypt", "flush", "-f"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleFlushIPsecState(ctx, &mcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) + return r, err + }}, + {"list_envoy_config", []string{"envoy", "admin", "clusters"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleListEnvoyConfig(ctx, &mcp.CallToolRequest{}, listEnvoyConfigInput{ResourceName: "clusters", NodeName: "test-node"}) + return r, err + }}, + {"show_ipcache_cidr", []string{"ip", "get", "10.0.0.0/24"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleShowIPCacheInformation(ctx, &mcp.CallToolRequest{}, showIPCacheInformationInput{CIDR: "10.0.0.0/24", NodeName: "test-node"}) + return r, err + }}, + {"show_ipcache_labels", []string{"ip", "get", "--labels", "app=foo"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleShowIPCacheInformation(ctx, &mcp.CallToolRequest{}, showIPCacheInformationInput{Labels: "app=foo", NodeName: "test-node"}) + return r, err + }}, + {"delete_kvstore_key", []string{"kvstore", "delete", "foo"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleDeleteKeyFromKVStore(ctx, &mcp.CallToolRequest{}, kvStoreKeyInput{Key: "foo", NodeName: "test-node"}) + return r, err + }}, + {"get_kvstore_key", []string{"kvstore", "get", "foo"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleGetKVStoreKey(ctx, &mcp.CallToolRequest{}, kvStoreKeyInput{Key: "foo", NodeName: "test-node"}) + return r, err + }}, + {"set_kvstore_key", []string{"kvstore", "set", "foo=bar"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleSetKVStoreKey(ctx, &mcp.CallToolRequest{}, setKVStoreKeyInput{Key: "foo", Value: "bar", NodeName: "test-node"}) + return r, err + }}, + {"show_load_information", []string{"loadinfo"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleShowLoadInformation(ctx, &mcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) + return r, err + }}, + {"display_policy_node_info", []string{"policy", "get"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleDisplayPolicyNodeInformation(ctx, &mcp.CallToolRequest{}, displayPolicyNodeInformationInput{NodeName: "test-node"}) + return r, err + }}, + {"display_policy_node_info_labels", []string{"policy", "get", "k=v"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleDisplayPolicyNodeInformation(ctx, &mcp.CallToolRequest{}, displayPolicyNodeInformationInput{Labels: "k=v", NodeName: "test-node"}) + return r, err + }}, + {"delete_policy_rules_all", []string{"policy", "delete", "--all"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleDeletePolicyRules(ctx, &mcp.CallToolRequest{}, deletePolicyRulesInput{All: true, NodeName: "test-node"}) + return r, err + }}, + {"delete_policy_rules_labels", []string{"policy", "delete", "k=v"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleDeletePolicyRules(ctx, &mcp.CallToolRequest{}, deletePolicyRulesInput{Labels: "k=v", NodeName: "test-node"}) + return r, err + }}, + {"update_xdp_cidr", []string{"prefilter", "update", "--cidr", "10.0.0.0/8"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleUpdateXDPCIDRFilters(ctx, &mcp.CallToolRequest{}, xdpCIDRFiltersInput{CIDRPrefixes: "10.0.0.0/8", NodeName: "test-node"}) + return r, err + }}, + {"update_xdp_cidr_rev", []string{"prefilter", "update", "--cidr", "10.0.0.0/8", "--revision", "2"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleUpdateXDPCIDRFilters(ctx, &mcp.CallToolRequest{}, xdpCIDRFiltersInput{CIDRPrefixes: "10.0.0.0/8", Revision: "2", NodeName: "test-node"}) + return r, err + }}, + {"delete_xdp_cidr", []string{"prefilter", "delete", "--cidr", "10.0.0.0/8"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleDeleteXDPCIDRFilters(ctx, &mcp.CallToolRequest{}, xdpCIDRFiltersInput{CIDRPrefixes: "10.0.0.0/8", NodeName: "test-node"}) + return r, err + }}, + {"delete_xdp_cidr_rev", []string{"prefilter", "delete", "--cidr", "10.0.0.0/8", "--revision", "2"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleDeleteXDPCIDRFilters(ctx, &mcp.CallToolRequest{}, xdpCIDRFiltersInput{CIDRPrefixes: "10.0.0.0/8", Revision: "2", NodeName: "test-node"}) + return r, err + }}, + {"validate_cnp", []string{"preflight", "validate-cnp", "--enable-k8s", "--enable-k8s-api-discovery"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleValidateCiliumNetworkPolicies(ctx, &mcp.CallToolRequest{}, validateCiliumNetworkPoliciesInput{EnableK8s: true, EnableK8sAPIDiscovery: true, NodeName: "test-node"}) + return r, err + }}, + {"list_pcap_recorders", []string{"recorder", "list"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleListPCAPRecorders(ctx, &mcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) + return r, err + }}, + {"get_pcap_recorder", []string{"recorder", "get", "1"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleGetPCAPRecorder(ctx, &mcp.CallToolRequest{}, pcapRecorderIDInput{RecorderID: "1", NodeName: "test-node"}) + return r, err + }}, + {"delete_pcap_recorder", []string{"recorder", "delete", "1"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleDeletePCAPRecorder(ctx, &mcp.CallToolRequest{}, pcapRecorderIDInput{RecorderID: "1", NodeName: "test-node"}) + return r, err + }}, + {"update_pcap_recorder", []string{"recorder", "update", "1", "--filters", "f", "--caplen", "0", "--id", "0"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleUpdatePCAPRecorder(ctx, &mcp.CallToolRequest{}, updatePCAPRecorderInput{RecorderID: "1", Filters: "f", NodeName: "test-node"}) + return r, err + }}, + {"get_service_information", []string{"service", "get", "5"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleGetServiceInformation(ctx, &mcp.CallToolRequest{}, getServiceInformationInput{ServiceID: "5", NodeName: "test-node"}) + return r, err + }}, + {"delete_service_all", []string{"service", "delete", "--all"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleDeleteService(ctx, &mcp.CallToolRequest{}, deleteServiceInput{All: true, NodeName: "test-node"}) + return r, err + }}, + {"delete_service_id", []string{"service", "delete", "5"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleDeleteService(ctx, &mcp.CallToolRequest{}, deleteServiceInput{ServiceID: "5", NodeName: "test-node"}) + return r, err + }}, + {"update_service", []string{"service", "update", "1", "--backends", "b", "--frontend", "f", "--protocol", "TCP", "--states", "active"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleUpdateService(ctx, &mcp.CallToolRequest{}, updateServiceInput{Backends: "b", Frontend: "f", ID: "1", NodeName: "test-node"}) + return r, err + }}, } for _, tc := range cases { @@ -668,8 +671,7 @@ func TestCiliumDbgHandlers(t *testing.T) { mockCiliumDbgCommand(mock, tc.dbgArgs, tc.expect, nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - tc.args["node_name"] = "test-node" - result, err := tc.handler(ctx, newRequestWithArgs(tc.args)) + result, err := tc.run(ctx) require.NoError(t, err) assert.False(t, result.IsError, "handler returned error result: %s", getResultText(result)) assert.Contains(t, getResultText(result), tc.expect) @@ -680,36 +682,92 @@ func TestCiliumDbgHandlers(t *testing.T) { // TestCiliumDbgHandlersMissingParams covers required-parameter validation branches. func TestCiliumDbgHandlersMissingParams(t *testing.T) { cases := []struct { - name string - handler ciliumHandler - args map[string]any + name string + run func(context.Context) (*mcp.CallToolResult, error) }{ - {"manage_endpoint_labels", handleManageEndpointLabels, map[string]any{}}, - {"manage_endpoint_configuration_no_id", handleManageEndpointConfiguration, map[string]any{}}, - {"manage_endpoint_configuration_no_config", handleManageEndpointConfiguration, map[string]any{"endpoint_id": "34"}}, - {"disconnect_endpoint", handleDisconnectEndpoint, map[string]any{}}, - {"get_identity_details", handleGetIdentityDetails, map[string]any{}}, - {"list_envoy_config", handleListEnvoyConfig, map[string]any{}}, - {"show_ipcache_none", handleShowIPCacheInformation, map[string]any{}}, - {"delete_kvstore_key", handleDeleteKeyFromKVStore, map[string]any{}}, - {"get_kvstore_key", handleGetKVStoreKey, map[string]any{}}, - {"set_kvstore_key", handleSetKVStoreKey, map[string]any{"key": "foo"}}, - {"delete_policy_rules_none", handleDeletePolicyRules, map[string]any{}}, - {"update_xdp_cidr", handleUpdateXDPCIDRFilters, map[string]any{}}, - {"delete_xdp_cidr", handleDeleteXDPCIDRFilters, map[string]any{}}, - {"get_pcap_recorder", handleGetPCAPRecorder, map[string]any{}}, - {"delete_pcap_recorder", handleDeletePCAPRecorder, map[string]any{}}, - {"update_pcap_recorder", handleUpdatePCAPRecorder, map[string]any{"recorder_id": "1"}}, - {"get_service_information", handleGetServiceInformation, map[string]any{}}, - {"delete_service_none", handleDeleteService, map[string]any{}}, - {"update_service", handleUpdateService, map[string]any{"backends": "b"}}, + {"manage_endpoint_labels", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleManageEndpointLabels(ctx, &mcp.CallToolRequest{}, manageEndpointLabelsInput{}) + return r, err + }}, + {"manage_endpoint_configuration_no_id", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleManageEndpointConfiguration(ctx, &mcp.CallToolRequest{}, manageEndpointConfigurationInput{}) + return r, err + }}, + {"manage_endpoint_configuration_no_config", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleManageEndpointConfiguration(ctx, &mcp.CallToolRequest{}, manageEndpointConfigurationInput{EndpointID: "34"}) + return r, err + }}, + {"disconnect_endpoint", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleDisconnectEndpoint(ctx, &mcp.CallToolRequest{}, disconnectEndpointInput{}) + return r, err + }}, + {"get_identity_details", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleGetIdentityDetails(ctx, &mcp.CallToolRequest{}, getIdentityDetailsInput{}) + return r, err + }}, + {"list_envoy_config", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleListEnvoyConfig(ctx, &mcp.CallToolRequest{}, listEnvoyConfigInput{}) + return r, err + }}, + {"show_ipcache_none", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleShowIPCacheInformation(ctx, &mcp.CallToolRequest{}, showIPCacheInformationInput{}) + return r, err + }}, + {"delete_kvstore_key", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleDeleteKeyFromKVStore(ctx, &mcp.CallToolRequest{}, kvStoreKeyInput{}) + return r, err + }}, + {"get_kvstore_key", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleGetKVStoreKey(ctx, &mcp.CallToolRequest{}, kvStoreKeyInput{}) + return r, err + }}, + {"set_kvstore_key", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleSetKVStoreKey(ctx, &mcp.CallToolRequest{}, setKVStoreKeyInput{Key: "foo"}) + return r, err + }}, + {"delete_policy_rules_none", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleDeletePolicyRules(ctx, &mcp.CallToolRequest{}, deletePolicyRulesInput{}) + return r, err + }}, + {"update_xdp_cidr", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleUpdateXDPCIDRFilters(ctx, &mcp.CallToolRequest{}, xdpCIDRFiltersInput{}) + return r, err + }}, + {"delete_xdp_cidr", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleDeleteXDPCIDRFilters(ctx, &mcp.CallToolRequest{}, xdpCIDRFiltersInput{}) + return r, err + }}, + {"get_pcap_recorder", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleGetPCAPRecorder(ctx, &mcp.CallToolRequest{}, pcapRecorderIDInput{}) + return r, err + }}, + {"delete_pcap_recorder", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleDeletePCAPRecorder(ctx, &mcp.CallToolRequest{}, pcapRecorderIDInput{}) + return r, err + }}, + {"update_pcap_recorder", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleUpdatePCAPRecorder(ctx, &mcp.CallToolRequest{}, updatePCAPRecorderInput{RecorderID: "1"}) + return r, err + }}, + {"get_service_information", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleGetServiceInformation(ctx, &mcp.CallToolRequest{}, getServiceInformationInput{}) + return r, err + }}, + {"delete_service_none", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleDeleteService(ctx, &mcp.CallToolRequest{}, deleteServiceInput{}) + return r, err + }}, + {"update_service", func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleUpdateService(ctx, &mcp.CallToolRequest{}, updateServiceInput{Backends: "b"}) + return r, err + }}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { mock := cmd.NewMockShellExecutor() ctx := cmd.WithShellExecutor(context.Background(), mock) - result, err := tc.handler(ctx, newRequestWithArgs(tc.args)) + result, err := tc.run(ctx) require.NoError(t, err) assert.True(t, result.IsError) assert.Empty(t, mock.GetCallLog()) @@ -721,14 +779,25 @@ func TestCiliumDbgHandlersMissingParams(t *testing.T) { func TestCiliumCliHandlers(t *testing.T) { cases := []struct { name string - handler ciliumHandler - args map[string]any cliArgs []string + run func(context.Context) (*mcp.CallToolResult, error) }{ - {"show_cluster_mesh_status", handleShowClusterMeshStatus, map[string]any{}, []string{"clustermesh", "status"}}, - {"show_features_status", handleShowFeaturesStatus, map[string]any{}, []string{"features", "status"}}, - {"toggle_cluster_mesh_enable", handleToggleClusterMesh, map[string]any{"enable": "true"}, []string{"clustermesh", "enable"}}, - {"toggle_cluster_mesh_disable", handleToggleClusterMesh, map[string]any{"enable": "false"}, []string{"clustermesh", "disable"}}, + {"show_cluster_mesh_status", []string{"clustermesh", "status"}, func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleShowClusterMeshStatus(ctx, &mcp.CallToolRequest{}, noInput{}) + return r, err + }}, + {"show_features_status", []string{"features", "status"}, func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleShowFeaturesStatus(ctx, &mcp.CallToolRequest{}, noInput{}) + return r, err + }}, + {"toggle_cluster_mesh_enable", []string{"clustermesh", "enable"}, func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleToggleClusterMesh(ctx, &mcp.CallToolRequest{}, enableToggleInput{Enable: boolPtr(true)}) + return r, err + }}, + {"toggle_cluster_mesh_disable", []string{"clustermesh", "disable"}, func(ctx context.Context) (*mcp.CallToolResult, error) { + r, _, err := handleToggleClusterMesh(ctx, &mcp.CallToolRequest{}, enableToggleInput{Enable: boolPtr(false)}) + return r, err + }}, } for _, tc := range cases { @@ -736,7 +805,7 @@ func TestCiliumCliHandlers(t *testing.T) { mock := cmd.NewMockShellExecutor() mock.AddCommandString("cilium", tc.cliArgs, "cli-ok", nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, err := tc.handler(ctx, newRequestWithArgs(tc.args)) + result, err := tc.run(ctx) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "cli-ok") @@ -748,7 +817,7 @@ func TestCiliumCliHandlersError(t *testing.T) { mock := cmd.NewMockShellExecutor() mock.AddCommandString("cilium", []string{"clustermesh", "status"}, "", assert.AnError) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, err := handleShowClusterMeshStatus(ctx, newRequestWithArgs(map[string]any{})) + result, _, err := handleShowClusterMeshStatus(ctx, &mcp.CallToolRequest{}, noInput{}) require.NoError(t, err) assert.True(t, result.IsError) assert.Contains(t, getResultText(result), "Error getting cluster mesh status") @@ -759,7 +828,7 @@ func TestCiliumDbgHandlerError(t *testing.T) { mock := cmd.NewMockShellExecutor() mockCiliumDbgCommand(mock, []string{"loadinfo"}, "", assert.AnError) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, err := handleShowLoadInformation(ctx, newRequestWithArgs(map[string]any{"node_name": "test-node"})) + result, _, err := handleShowLoadInformation(ctx, &mcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) require.NoError(t, err) assert.True(t, result.IsError) } diff --git a/pkg/helm/helm.go b/pkg/helm/helm.go index c8a6b917..07ea602f 100644 --- a/pkg/helm/helm.go +++ b/pkg/helm/helm.go @@ -8,66 +8,71 @@ import ( "github.com/kagent-dev/tools/internal/commands" "github.com/kagent-dev/tools/internal/errors" + mcp "github.com/kagent-dev/tools/internal/mcp" "github.com/kagent-dev/tools/internal/security" - "github.com/kagent-dev/tools/internal/telemetry" "github.com/kagent-dev/tools/pkg/utils" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" ) -// Helm list releases -func handleHelmListReleases(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - namespace := mcp.ParseString(request, "namespace", "") - allNamespaces := mcp.ParseString(request, "all_namespaces", "") == "true" - all := mcp.ParseString(request, "all", "") == "true" - uninstalled := mcp.ParseString(request, "uninstalled", "") == "true" - uninstalling := mcp.ParseString(request, "uninstalling", "") == "true" - failed := mcp.ParseString(request, "failed", "") == "true" - deployed := mcp.ParseString(request, "deployed", "") == "true" - pending := mcp.ParseString(request, "pending", "") == "true" - filter := mcp.ParseString(request, "filter", "") - output := mcp.ParseString(request, "output", "") +// toolErrorResult formats a ToolError as an MCP error result. +func toolErrorResult(toolErr *errors.ToolError) *mcp.CallToolResult { + return toolErr.ToMCPResult() +} +type helmListReleasesInput struct { + Namespace string `json:"namespace" jsonschema:"The namespace to list releases from"` + AllNamespaces bool `json:"all_namespaces" jsonschema:"List releases from all namespaces"` + All bool `json:"all" jsonschema:"Show all releases without any filter applied"` + Uninstalled bool `json:"uninstalled" jsonschema:"List uninstalled releases"` + Uninstalling bool `json:"uninstalling" jsonschema:"List uninstalling releases"` + Failed bool `json:"failed" jsonschema:"List failed releases"` + Deployed bool `json:"deployed" jsonschema:"List deployed releases"` + Pending bool `json:"pending" jsonschema:"List pending releases"` + Filter string `json:"filter" jsonschema:"A regular expression to filter releases by"` + Output string `json:"output" jsonschema:"The output format (e.g., 'json', 'yaml', 'table')"` +} + +// Helm list releases +func handleHelmListReleases(ctx context.Context, request *mcp.CallToolRequest, in helmListReleasesInput) (*mcp.CallToolResult, any, error) { args := []string{"list"} - if namespace != "" { - args = append(args, "-n", namespace) + if in.Namespace != "" { + args = append(args, "-n", in.Namespace) } - if allNamespaces { + if in.AllNamespaces { args = append(args, "-A") } - if all { + if in.All { args = append(args, "-a") } - if uninstalled { + if in.Uninstalled { args = append(args, "--uninstalled") } - if uninstalling { + if in.Uninstalling { args = append(args, "--uninstalling") } - if failed { + if in.Failed { args = append(args, "--failed") } - if deployed { + if in.Deployed { args = append(args, "--deployed") } - if pending { + if in.Pending { args = append(args, "--pending") } - if filter != "" { - args = append(args, "-f", filter) + if in.Filter != "" { + args = append(args, "-f", in.Filter) } - if output != "" { - args = append(args, "-o", output) + if in.Output != "" { + args = append(args, "-o", in.Output) } result, err := runHelmCommand(ctx, args) @@ -75,16 +80,16 @@ func handleHelmListReleases(ctx context.Context, request mcp.CallToolRequest) (* // Check if it's a structured error if toolErr, ok := err.(*errors.ToolError); ok { // Add namespace context if provided - if namespace != "" { - toolErr = toolErr.WithContext("namespace", namespace) + if in.Namespace != "" { + toolErr = toolErr.WithContext("namespace", in.Namespace) } - return toolErr.ToMCPResult(), nil + return toolErrorResult(toolErr), nil, nil } // Fallback for non-structured errors - return mcp.NewToolResultError(fmt.Sprintf("Helm list command failed: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Helm list command failed: %v", err)), nil, nil } - return mcp.NewToolResultText(result), nil + return mcp.NewToolResultText(result), nil, nil } func runHelmCommand(ctx context.Context, args []string) (string, error) { @@ -116,232 +121,224 @@ func runHelmCommand(ctx context.Context, args []string) (string, error) { return result, nil } +type helmGetReleaseInput struct { + Name string `json:"name" jsonschema:"The name of the release"` + Namespace string `json:"namespace" jsonschema:"The namespace of the release"` + Resource string `json:"resource" jsonschema:"The resource to get (all, hooks, manifest, notes, values)"` +} + // Helm get release -func handleHelmGetRelease(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - name := mcp.ParseString(request, "name", "") - namespace := mcp.ParseString(request, "namespace", "") - resource := mcp.ParseString(request, "resource", "all") +func handleHelmGetRelease(ctx context.Context, request *mcp.CallToolRequest, in helmGetReleaseInput) (*mcp.CallToolResult, any, error) { + if in.Resource == "" { + in.Resource = "all" + } - if name == "" { - return mcp.NewToolResultError("name parameter is required"), nil + if in.Name == "" { + return mcp.NewToolResultError("name parameter is required"), nil, nil } - if namespace == "" { - return mcp.NewToolResultError("namespace parameter is required"), nil + if in.Namespace == "" { + return mcp.NewToolResultError("namespace parameter is required"), nil, nil } - args := []string{"get", resource, name, "-n", namespace} + args := []string{"get", in.Resource, in.Name, "-n", in.Namespace} result, err := runHelmCommand(ctx, args) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Helm get command failed: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Helm get command failed: %v", err)), nil, nil } - return mcp.NewToolResultText(result), nil + return mcp.NewToolResultText(result), nil, nil } -// Helm upgrade release -func handleHelmUpgradeRelease(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - name := mcp.ParseString(request, "name", "") - chart := mcp.ParseString(request, "chart", "") - namespace := mcp.ParseString(request, "namespace", "") - version := mcp.ParseString(request, "version", "") - values := mcp.ParseString(request, "values", "") - setValues := mcp.ParseString(request, "set", "") - install := mcp.ParseString(request, "install", "") == "true" - dryRun := mcp.ParseString(request, "dry_run", "") == "true" - wait := mcp.ParseString(request, "wait", "") == "true" +type helmUpgradeReleaseInput struct { + Name string `json:"name" jsonschema:"The name of the release"` + Chart string `json:"chart" jsonschema:"The chart to install or upgrade to"` + Namespace string `json:"namespace" jsonschema:"The namespace of the release"` + Version string `json:"version" jsonschema:"The version of the chart to upgrade to"` + Values string `json:"values" jsonschema:"Path to a values file"` + Set string `json:"set" jsonschema:"Set values on the command line (e.g., 'key1=val1,key2=val2')"` + Install bool `json:"install" jsonschema:"Run an install if the release is not present"` + DryRun bool `json:"dry_run" jsonschema:"Simulate an upgrade"` + Wait bool `json:"wait" jsonschema:"Wait for the upgrade to complete"` +} - if name == "" || chart == "" { - return mcp.NewToolResultError("name and chart parameters are required"), nil +// Helm upgrade release +func handleHelmUpgradeRelease(ctx context.Context, request *mcp.CallToolRequest, in helmUpgradeReleaseInput) (*mcp.CallToolResult, any, error) { + if in.Name == "" || in.Chart == "" { + return mcp.NewToolResultError("name and chart parameters are required"), nil, nil } // Validate release name - if err := security.ValidateHelmReleaseName(name); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid release name: %v", err)), nil + if err := security.ValidateHelmReleaseName(in.Name); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid release name: %v", err)), nil, nil } // Validate namespace if provided - if namespace != "" { - if err := security.ValidateNamespace(namespace); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid namespace: %v", err)), nil + if in.Namespace != "" { + if err := security.ValidateNamespace(in.Namespace); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid namespace: %v", err)), nil, nil } } // Validate values file path if provided - if values != "" { - if err := security.ValidateFilePath(values); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid values file path: %v", err)), nil + if in.Values != "" { + if err := security.ValidateFilePath(in.Values); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid values file path: %v", err)), nil, nil } } - args := []string{"upgrade", name, chart} + args := []string{"upgrade", in.Name, in.Chart} - if namespace != "" { - args = append(args, "-n", namespace) + if in.Namespace != "" { + args = append(args, "-n", in.Namespace) } - if version != "" { - args = append(args, "--version", version) + if in.Version != "" { + args = append(args, "--version", in.Version) } - if values != "" { - args = append(args, "-f", values) + if in.Values != "" { + args = append(args, "-f", in.Values) } - if setValues != "" { + if in.Set != "" { // Split multiple set values by comma - setValuesList := strings.Split(setValues, ",") + setValuesList := strings.Split(in.Set, ",") for _, setValue := range setValuesList { args = append(args, "--set", strings.TrimSpace(setValue)) } } - if install { + if in.Install { args = append(args, "--install") } - if dryRun { + if in.DryRun { args = append(args, "--dry-run") } - if wait { + if in.Wait { args = append(args, "--wait") } result, err := runHelmCommand(ctx, args) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Helm upgrade command failed: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Helm upgrade command failed: %v", err)), nil, nil } - return mcp.NewToolResultText(result), nil + return mcp.NewToolResultText(result), nil, nil } -// Helm uninstall release -func handleHelmUninstall(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - name := mcp.ParseString(request, "name", "") - namespace := mcp.ParseString(request, "namespace", "") - dryRun := mcp.ParseString(request, "dry_run", "") == "true" - wait := mcp.ParseString(request, "wait", "") == "true" +type helmUninstallInput struct { + Name string `json:"name" jsonschema:"The name of the release to uninstall"` + Namespace string `json:"namespace" jsonschema:"The namespace of the release"` + DryRun bool `json:"dry_run" jsonschema:"Simulate an uninstall"` + Wait bool `json:"wait" jsonschema:"Wait for the uninstall to complete"` +} - if name == "" || namespace == "" { - return mcp.NewToolResultError("name and namespace parameters are required"), nil +// Helm uninstall release +func handleHelmUninstall(ctx context.Context, request *mcp.CallToolRequest, in helmUninstallInput) (*mcp.CallToolResult, any, error) { + if in.Name == "" || in.Namespace == "" { + return mcp.NewToolResultError("name and namespace parameters are required"), nil, nil } - args := []string{"uninstall", name, "-n", namespace} + args := []string{"uninstall", in.Name, "-n", in.Namespace} - if dryRun { + if in.DryRun { args = append(args, "--dry-run") } - if wait { + if in.Wait { args = append(args, "--wait") } result, err := runHelmCommand(ctx, args) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Helm uninstall command failed: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Helm uninstall command failed: %v", err)), nil, nil } - return mcp.NewToolResultText(result), nil + return mcp.NewToolResultText(result), nil, nil } -// Helm repo add -func handleHelmRepoAdd(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - name := mcp.ParseString(request, "name", "") - url := mcp.ParseString(request, "url", "") +type helmRepoAddInput struct { + Name string `json:"name" jsonschema:"The name of the repository"` + URL string `json:"url" jsonschema:"The URL of the repository"` +} - if name == "" || url == "" { - return mcp.NewToolResultError("name and url parameters are required"), nil +// Helm repo add +func handleHelmRepoAdd(ctx context.Context, request *mcp.CallToolRequest, in helmRepoAddInput) (*mcp.CallToolResult, any, error) { + if in.Name == "" || in.URL == "" { + return mcp.NewToolResultError("name and url parameters are required"), nil, nil } // Validate repository name - if err := security.ValidateHelmReleaseName(name); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid repository name: %v", err)), nil + if err := security.ValidateHelmReleaseName(in.Name); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid repository name: %v", err)), nil, nil } // Validate repository URL - if err := security.ValidateURL(url); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid repository URL: %v", err)), nil + if err := security.ValidateURL(in.URL); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid repository URL: %v", err)), nil, nil } - args := []string{"repo", "add", name, url} + args := []string{"repo", "add", in.Name, in.URL} result, err := runHelmCommand(ctx, args) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Helm repo add command failed: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Helm repo add command failed: %v", err)), nil, nil } - return mcp.NewToolResultText(result), nil + return mcp.NewToolResultText(result), nil, nil } +type helmRepoUpdateInput struct{} + // Helm repo update -func handleHelmRepoUpdate(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func handleHelmRepoUpdate(ctx context.Context, request *mcp.CallToolRequest, in helmRepoUpdateInput) (*mcp.CallToolResult, any, error) { args := []string{"repo", "update"} result, err := runHelmCommand(ctx, args) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Helm repo update command failed: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Helm repo update command failed: %v", err)), nil, nil } - return mcp.NewToolResultText(result), nil + return mcp.NewToolResultText(result), nil, nil } // Register Helm tools -func RegisterTools(s *server.MCPServer, readOnly bool) { +func RegisterTools(s *mcp.Server, readOnly bool) { // Read-only tools - always registered - s.AddTool(mcp.NewTool("helm_list_releases", - mcp.WithDescription("List Helm releases in a namespace"), - mcp.WithString("namespace", mcp.Description("The namespace to list releases from")), - mcp.WithString("all_namespaces", mcp.Description("List releases from all namespaces")), - mcp.WithString("all", mcp.Description("Show all releases without any filter applied")), - mcp.WithString("uninstalled", mcp.Description("List uninstalled releases")), - mcp.WithString("uninstalling", mcp.Description("List uninstalling releases")), - mcp.WithString("failed", mcp.Description("List failed releases")), - mcp.WithString("deployed", mcp.Description("List deployed releases")), - mcp.WithString("pending", mcp.Description("List pending releases")), - mcp.WithString("filter", mcp.Description("A regular expression to filter releases by")), - mcp.WithString("output", mcp.Description("The output format (e.g., 'json', 'yaml', 'table')")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("helm_list_releases", handleHelmListReleases))) - - s.AddTool(mcp.NewTool("helm_get_release", - mcp.WithDescription("Get extended information about a Helm release"), - mcp.WithString("name", mcp.Description("The name of the release"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("The namespace of the release"), mcp.Required()), - mcp.WithString("resource", mcp.Description("The resource to get (all, hooks, manifest, notes, values)")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("helm_get_release", handleHelmGetRelease))) - - s.AddTool(mcp.NewTool("helm_repo_update", - mcp.WithDescription("Update information of available charts locally from chart repositories"), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("helm_repo_update", handleHelmRepoUpdate))) + mcp.AddTool(s, "helm", &mcp.Tool{ + Name: "helm_list_releases", + Description: "List Helm releases in a namespace", + }, handleHelmListReleases) + + mcp.AddTool(s, "helm", &mcp.Tool{ + Name: "helm_get_release", + Description: "Get extended information about a Helm release", + }, handleHelmGetRelease) + + mcp.AddTool(s, "helm", &mcp.Tool{ + Name: "helm_repo_update", + Description: "Update information of available charts locally from chart repositories", + }, handleHelmRepoUpdate) // Write tools - only registered when not in read-only mode if !readOnly { - s.AddTool(mcp.NewTool("helm_upgrade", - mcp.WithDescription("Upgrade or install a Helm release"), - mcp.WithString("name", mcp.Description("The name of the release"), mcp.Required()), - mcp.WithString("chart", mcp.Description("The chart to install or upgrade to"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("The namespace of the release")), - mcp.WithString("version", mcp.Description("The version of the chart to upgrade to")), - mcp.WithString("values", mcp.Description("Path to a values file")), - mcp.WithString("set", mcp.Description("Set values on the command line (e.g., 'key1=val1,key2=val2')")), - mcp.WithString("install", mcp.Description("Run an install if the release is not present")), - mcp.WithString("dry_run", mcp.Description("Simulate an upgrade")), - mcp.WithString("wait", mcp.Description("Wait for the upgrade to complete")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("helm_upgrade", handleHelmUpgradeRelease))) - - s.AddTool(mcp.NewTool("helm_uninstall", - mcp.WithDescription("Uninstall a Helm release"), - mcp.WithString("name", mcp.Description("The name of the release to uninstall"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("The namespace of the release"), mcp.Required()), - mcp.WithString("dry_run", mcp.Description("Simulate an uninstall")), - mcp.WithString("wait", mcp.Description("Wait for the uninstall to complete")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("helm_uninstall", handleHelmUninstall))) - - s.AddTool(mcp.NewTool("helm_repo_add", - mcp.WithDescription("Add a Helm repository"), - mcp.WithString("name", mcp.Description("The name of the repository"), mcp.Required()), - mcp.WithString("url", mcp.Description("The URL of the repository"), mcp.Required()), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("helm_repo_add", handleHelmRepoAdd))) + mcp.AddTool(s, "helm", &mcp.Tool{ + Name: "helm_upgrade", + Description: "Upgrade or install a Helm release", + }, handleHelmUpgradeRelease) + + mcp.AddTool(s, "helm", &mcp.Tool{ + Name: "helm_uninstall", + Description: "Uninstall a Helm release", + }, handleHelmUninstall) + + mcp.AddTool(s, "helm", &mcp.Tool{ + Name: "helm_repo_add", + Description: "Add a Helm repository", + }, handleHelmRepoAdd) } } diff --git a/pkg/helm/helm_test.go b/pkg/helm/helm_test.go index 9e5b26ca..d9665c6f 100644 --- a/pkg/helm/helm_test.go +++ b/pkg/helm/helm_test.go @@ -5,14 +5,13 @@ import ( "testing" "github.com/kagent-dev/tools/internal/cmd" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" + mcp "github.com/kagent-dev/tools/internal/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestRegisterTools(t *testing.T) { - s := server.NewMCPServer("test-server", "v0.0.1") + s := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) RegisterTools(s, false) // false = enable all tools including write operations } @@ -20,14 +19,14 @@ func TestRegisterTools(t *testing.T) { func TestHandleHelmListReleases(t *testing.T) { tests := []struct { name string - args map[string]interface{} + input helmListReleasesInput expectedArgs []string expectedOutput string expectError bool }{ { name: "basic_list_releases", - args: map[string]interface{}{}, + input: helmListReleasesInput{}, expectedArgs: []string{"list"}, expectedOutput: `NAME NAMESPACE REVISION STATUS CHART app1 default 1 deployed my-chart-1.0.0 @@ -36,8 +35,8 @@ app2 default 2 deployed my-chart-2.0.0`, }, { name: "list_releases_with_namespace", - args: map[string]interface{}{ - "namespace": "production", + input: helmListReleasesInput{ + Namespace: "production", }, expectedArgs: []string{"list", "-n", "production"}, expectedOutput: `NAME NAMESPACE REVISION STATUS CHART @@ -46,8 +45,8 @@ prod-app production 1 deployed my-chart-1.0.0`, }, { name: "list_releases_with_all_namespaces", - args: map[string]interface{}{ - "all_namespaces": "true", + input: helmListReleasesInput{ + AllNamespaces: true, }, expectedArgs: []string{"list", "-A"}, expectedOutput: `NAME NAMESPACE REVISION STATUS CHART @@ -57,11 +56,11 @@ prod-app production 1 deployed my-chart-1.0.0`, }, { name: "list_releases_with_multiple_flags", - args: map[string]interface{}{ - "all_namespaces": "true", - "all": "true", - "failed": "true", - "output": "json", + input: helmListReleasesInput{ + AllNamespaces: true, + All: true, + Failed: true, + Output: "json", }, expectedArgs: []string{"list", "-A", "-a", "--failed", "-o", "json"}, expectedOutput: `[ @@ -82,10 +81,7 @@ prod-app production 1 deployed my-chart-1.0.0`, mock.AddCommandString("helm", tt.expectedArgs, tt.expectedOutput, nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - request := mcp.CallToolRequest{} - request.Params.Arguments = tt.args - - result, err := handleHelmListReleases(ctx, request) + result, _, err := handleHelmListReleases(ctx, &mcp.CallToolRequest{}, tt.input) assert.NoError(t, err) assert.False(t, result.IsError) @@ -119,8 +115,7 @@ prod-app production 1 deployed my-chart-1.0.0`, mock.AddCommandString("helm", []string{"list"}, "", assert.AnError) ctx := cmd.WithShellExecutor(context.Background(), mock) - request := mcp.CallToolRequest{} - result, err := handleHelmListReleases(ctx, request) + result, _, err := handleHelmListReleases(ctx, &mcp.CallToolRequest{}, helmListReleasesInput{}) assert.NoError(t, err) // MCP handlers should not return Go errors assert.True(t, result.IsError) @@ -141,13 +136,10 @@ replicaCount: 3` mock.AddCommandString("helm", []string{"get", "all", "myapp", "-n", "default"}, expectedOutput, nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "name": "myapp", - "namespace": "default", - } - - result, err := handleHelmGetRelease(ctx, request) + result, _, err := handleHelmGetRelease(ctx, &mcp.CallToolRequest{}, helmGetReleaseInput{ + Name: "myapp", + Namespace: "default", + }) assert.NoError(t, err) assert.False(t, result.IsError) @@ -165,14 +157,11 @@ replicaCount: 3` mock.AddCommandString("helm", []string{"get", "values", "myapp", "-n", "default"}, "replicaCount: 3", nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "name": "myapp", - "namespace": "default", - "resource": "values", - } - - result, err := handleHelmGetRelease(ctx, request) + result, _, err := handleHelmGetRelease(ctx, &mcp.CallToolRequest{}, helmGetReleaseInput{ + Name: "myapp", + Namespace: "default", + Resource: "values", + }) assert.NoError(t, err) assert.False(t, result.IsError) @@ -189,22 +178,17 @@ replicaCount: 3` ctx := cmd.WithShellExecutor(context.Background(), mock) // Test missing name - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "namespace": "default", - } - - result, err := handleHelmGetRelease(ctx, request) + result, _, err := handleHelmGetRelease(ctx, &mcp.CallToolRequest{}, helmGetReleaseInput{ + Namespace: "default", + }) assert.NoError(t, err) assert.True(t, result.IsError) assert.Contains(t, getResultText(result), "name parameter is required") // Test missing namespace - request.Params.Arguments = map[string]interface{}{ - "name": "myapp", - } - - result, err = handleHelmGetRelease(ctx, request) + result, _, err = handleHelmGetRelease(ctx, &mcp.CallToolRequest{}, helmGetReleaseInput{ + Name: "myapp", + }) assert.NoError(t, err) assert.True(t, result.IsError) assert.Contains(t, getResultText(result), "namespace parameter is required") @@ -229,13 +213,10 @@ REVISION: 2` mock.AddCommandString("helm", []string{"upgrade", "myapp", "stable/myapp", "--timeout", "30s"}, expectedOutput, nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "name": "myapp", - "chart": "stable/myapp", - } - - result, err := handleHelmUpgradeRelease(ctx, request) + result, _, err := handleHelmUpgradeRelease(ctx, &mcp.CallToolRequest{}, helmUpgradeReleaseInput{ + Name: "myapp", + Chart: "stable/myapp", + }) assert.NoError(t, err) assert.False(t, result.IsError) @@ -265,20 +246,17 @@ REVISION: 2` mock.AddCommandString("helm", expectedArgs, "Upgraded with options", nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "name": "myapp", - "chart": "stable/myapp", - "namespace": "production", - "version": "1.2.0", - "values": "values.yaml", - "set": "replicas=5,image.tag=v1.2.0", - "install": "true", - "dry_run": "true", - "wait": "true", - } - - result, err := handleHelmUpgradeRelease(ctx, request) + result, _, err := handleHelmUpgradeRelease(ctx, &mcp.CallToolRequest{}, helmUpgradeReleaseInput{ + Name: "myapp", + Chart: "stable/myapp", + Namespace: "production", + Version: "1.2.0", + Values: "values.yaml", + Set: "replicas=5,image.tag=v1.2.0", + Install: true, + DryRun: true, + Wait: true, + }) assert.NoError(t, err) assert.False(t, result.IsError) @@ -295,12 +273,9 @@ REVISION: 2` ctx := cmd.WithShellExecutor(context.Background(), mock) // Test missing chart - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "name": "myapp", - } - - result, err := handleHelmUpgradeRelease(ctx, request) + result, _, err := handleHelmUpgradeRelease(ctx, &mcp.CallToolRequest{}, helmUpgradeReleaseInput{ + Name: "myapp", + }) assert.NoError(t, err) assert.True(t, result.IsError) assert.Contains(t, getResultText(result), "name and chart parameters are required") @@ -320,13 +295,10 @@ func TestHandleHelmUninstall(t *testing.T) { mock.AddCommandString("helm", []string{"uninstall", "myapp", "-n", "default"}, expectedOutput, nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "name": "myapp", - "namespace": "default", - } - - result, err := handleHelmUninstall(ctx, request) + result, _, err := handleHelmUninstall(ctx, &mcp.CallToolRequest{}, helmUninstallInput{ + Name: "myapp", + Namespace: "default", + }) assert.NoError(t, err) assert.NotNil(t, result) @@ -347,15 +319,12 @@ func TestHandleHelmUninstall(t *testing.T) { mock.AddCommandString("helm", []string{"uninstall", "myapp", "-n", "production", "--dry-run", "--wait"}, expectedOutput, nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "name": "myapp", - "namespace": "production", - "dry_run": "true", - "wait": "true", - } - - result, err := handleHelmUninstall(ctx, request) + result, _, err := handleHelmUninstall(ctx, &mcp.CallToolRequest{}, helmUninstallInput{ + Name: "myapp", + Namespace: "production", + DryRun: true, + Wait: true, + }) assert.NoError(t, err) assert.False(t, result.IsError) @@ -372,22 +341,17 @@ func TestHandleHelmUninstall(t *testing.T) { ctx := cmd.WithShellExecutor(context.Background(), mock) // Test missing name - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "namespace": "default", - } - - result, err := handleHelmUninstall(ctx, request) + result, _, err := handleHelmUninstall(ctx, &mcp.CallToolRequest{}, helmUninstallInput{ + Namespace: "default", + }) assert.NoError(t, err) assert.True(t, result.IsError) assert.Contains(t, getResultText(result), "name and namespace parameters are required") // Test missing namespace - request.Params.Arguments = map[string]interface{}{ - "name": "myapp", - } - - result, err = handleHelmUninstall(ctx, request) + result, _, err = handleHelmUninstall(ctx, &mcp.CallToolRequest{}, helmUninstallInput{ + Name: "myapp", + }) assert.NoError(t, err) assert.True(t, result.IsError) assert.Contains(t, getResultText(result), "name and namespace parameters are required") @@ -407,13 +371,10 @@ func TestHandleHelmRepoAdd(t *testing.T) { mock.AddCommandString("helm", []string{"repo", "add", "my-repo", "https://charts.example.com/"}, expectedOutput, nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "name": "my-repo", - "url": "https://charts.example.com/", - } - - result, err := handleHelmRepoAdd(ctx, request) + result, _, err := handleHelmRepoAdd(ctx, &mcp.CallToolRequest{}, helmRepoAddInput{ + Name: "my-repo", + URL: "https://charts.example.com/", + }) assert.NoError(t, err) assert.False(t, result.IsError) @@ -431,12 +392,9 @@ func TestHandleHelmRepoAdd(t *testing.T) { ctx := cmd.WithShellExecutor(context.Background(), mock) // Test missing name - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "url": "https://charts.example.com/", - } - - result, err := handleHelmRepoAdd(ctx, request) + result, _, err := handleHelmRepoAdd(ctx, &mcp.CallToolRequest{}, helmRepoAddInput{ + URL: "https://charts.example.com/", + }) assert.NoError(t, err) assert.True(t, result.IsError) assert.Contains(t, getResultText(result), "name and url parameters are required") @@ -458,8 +416,7 @@ Update Complete. ⎈Happy Helming!⎈` mock.AddCommandString("helm", []string{"repo", "update"}, expectedOutput, nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - request := mcp.CallToolRequest{} - result, err := handleHelmRepoUpdate(ctx, request) + result, _, err := handleHelmRepoUpdate(ctx, &mcp.CallToolRequest{}, helmRepoUpdateInput{}) assert.NoError(t, err) assert.False(t, result.IsError) @@ -478,7 +435,7 @@ func getResultText(result *mcp.CallToolResult) string { if result == nil || len(result.Content) == 0 { return "" } - if textContent, ok := result.Content[0].(mcp.TextContent); ok { + if textContent, ok := result.Content[0].(*mcp.TextContent); ok { return textContent.Text } return "" diff --git a/pkg/istio/istio.go b/pkg/istio/istio.go index dd1958c9..6a0da8e0 100644 --- a/pkg/istio/istio.go +++ b/pkg/istio/istio.go @@ -6,33 +6,33 @@ import ( "strings" "github.com/kagent-dev/tools/internal/commands" - "github.com/kagent-dev/tools/internal/telemetry" + mcp "github.com/kagent-dev/tools/internal/mcp" "github.com/kagent-dev/tools/pkg/utils" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" ) -// Istio proxy status -func handleIstioProxyStatus(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - podName := mcp.ParseString(request, "pod_name", "") - namespace := mcp.ParseString(request, "namespace", "") +type istioProxyStatusInput struct { + PodName string `json:"pod_name" jsonschema:"Name of the pod to get proxy status for"` + Namespace string `json:"namespace" jsonschema:"Namespace of the pod"` +} +// Istio proxy status +func handleIstioProxyStatus(ctx context.Context, request *mcp.CallToolRequest, in istioProxyStatusInput) (*mcp.CallToolResult, any, error) { args := []string{"proxy-status"} - if namespace != "" { - args = append(args, "-n", namespace) + if in.Namespace != "" { + args = append(args, "-n", in.Namespace) } - if podName != "" { - args = append(args, podName) + if in.PodName != "" { + args = append(args, in.PodName) } result, err := runIstioCtl(ctx, args) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("istioctl proxy-status failed: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("istioctl proxy-status failed: %v", err)), nil, nil } - return mcp.NewToolResultText(result), nil + return mcp.NewToolResultText(result), nil, nil } func runIstioCtl(ctx context.Context, args []string) (string, error) { @@ -43,336 +43,376 @@ func runIstioCtl(ctx context.Context, args []string) (string, error) { Execute(ctx) } +type istioProxyConfigInput struct { + PodName string `json:"pod_name" jsonschema:"Name of the pod to get proxy configuration for"` + Namespace string `json:"namespace" jsonschema:"Namespace of the pod"` + ConfigType string `json:"config_type" jsonschema:"Type of configuration (all, bootstrap, cluster, ecds, listener, log, route, secret)"` +} + // Istio proxy config -func handleIstioProxyConfig(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - podName := mcp.ParseString(request, "pod_name", "") - namespace := mcp.ParseString(request, "namespace", "") - configType := mcp.ParseString(request, "config_type", "all") +func handleIstioProxyConfig(ctx context.Context, request *mcp.CallToolRequest, in istioProxyConfigInput) (*mcp.CallToolResult, any, error) { + if in.ConfigType == "" { + in.ConfigType = "all" + } - if podName == "" { - return mcp.NewToolResultError("pod_name parameter is required"), nil + if in.PodName == "" { + return mcp.NewToolResultError("pod_name parameter is required"), nil, nil } - args := []string{"proxy-config", configType} + args := []string{"proxy-config", in.ConfigType} - if namespace != "" { - args = append(args, fmt.Sprintf("%s.%s", podName, namespace)) + if in.Namespace != "" { + args = append(args, fmt.Sprintf("%s.%s", in.PodName, in.Namespace)) } else { - args = append(args, podName) + args = append(args, in.PodName) } result, err := runIstioCtl(ctx, args) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("istioctl proxy-config failed: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("istioctl proxy-config failed: %v", err)), nil, nil } - return mcp.NewToolResultText(result), nil + return mcp.NewToolResultText(result), nil, nil +} + +type istioInstallInput struct { + Profile string `json:"profile" jsonschema:"Istio configuration profile (ambient, default, demo, minimal, empty)"` } // Istio install -func handleIstioInstall(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - profile := mcp.ParseString(request, "profile", "default") +func handleIstioInstall(ctx context.Context, request *mcp.CallToolRequest, in istioInstallInput) (*mcp.CallToolResult, any, error) { + if in.Profile == "" { + in.Profile = "default" + } - args := []string{"install", "--set", fmt.Sprintf("profile=%s", profile), "-y"} + args := []string{"install", "--set", fmt.Sprintf("profile=%s", in.Profile), "-y"} result, err := runIstioCtl(ctx, args) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("istioctl install failed: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("istioctl install failed: %v", err)), nil, nil } - return mcp.NewToolResultText(result), nil + return mcp.NewToolResultText(result), nil, nil +} + +type istioGenerateManifestInput struct { + Profile string `json:"profile" jsonschema:"Istio configuration profile (ambient, default, demo, minimal, empty)"` } // Istio generate manifest -func handleIstioGenerateManifest(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - profile := mcp.ParseString(request, "profile", "default") +func handleIstioGenerateManifest(ctx context.Context, request *mcp.CallToolRequest, in istioGenerateManifestInput) (*mcp.CallToolResult, any, error) { + if in.Profile == "" { + in.Profile = "default" + } - args := []string{"manifest", "generate", "--set", fmt.Sprintf("profile=%s", profile)} + args := []string{"manifest", "generate", "--set", fmt.Sprintf("profile=%s", in.Profile)} result, err := runIstioCtl(ctx, args) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("istioctl manifest generate failed: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("istioctl manifest generate failed: %v", err)), nil, nil } - return mcp.NewToolResultText(result), nil + return mcp.NewToolResultText(result), nil, nil } -// Istio analyze -func handleIstioAnalyzeClusterConfiguration(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - namespace := mcp.ParseString(request, "namespace", "") - allNamespaces := mcp.ParseString(request, "all_namespaces", "") == "true" +type istioAnalyzeClusterConfigurationInput struct { + Namespace string `json:"namespace" jsonschema:"Namespace to analyze"` + AllNamespaces bool `json:"all_namespaces" jsonschema:"Analyze all namespaces"` +} +// Istio analyze +func handleIstioAnalyzeClusterConfiguration(ctx context.Context, request *mcp.CallToolRequest, in istioAnalyzeClusterConfigurationInput) (*mcp.CallToolResult, any, error) { args := []string{"analyze"} - if allNamespaces { + if in.AllNamespaces { args = append(args, "-A") - } else if namespace != "" { - args = append(args, "-n", namespace) + } else if in.Namespace != "" { + args = append(args, "-n", in.Namespace) } result, err := runIstioCtl(ctx, args) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("istioctl analyze failed: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("istioctl analyze failed: %v", err)), nil, nil } - return mcp.NewToolResultText(result), nil + return mcp.NewToolResultText(result), nil, nil } -// Istio version -func handleIstioVersion(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - short := mcp.ParseString(request, "short", "") == "true" +type istioVersionInput struct { + Short bool `json:"short" jsonschema:"Return short version output"` +} +// Istio version +func handleIstioVersion(ctx context.Context, request *mcp.CallToolRequest, in istioVersionInput) (*mcp.CallToolResult, any, error) { args := []string{"version"} - if short { + if in.Short { args = append(args, "--short") } result, err := runIstioCtl(ctx, args) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("istioctl version failed: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("istioctl version failed: %v", err)), nil, nil } - return mcp.NewToolResultText(result), nil + return mcp.NewToolResultText(result), nil, nil } +type istioRemoteClustersInput struct{} + // Istio remote clusters -func handleIstioRemoteClusters(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func handleIstioRemoteClusters(ctx context.Context, request *mcp.CallToolRequest, in istioRemoteClustersInput) (*mcp.CallToolResult, any, error) { args := []string{"remote-clusters"} result, err := runIstioCtl(ctx, args) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("istioctl remote-clusters failed: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("istioctl remote-clusters failed: %v", err)), nil, nil } - return mcp.NewToolResultText(result), nil + return mcp.NewToolResultText(result), nil, nil } -// Waypoint list -func handleWaypointList(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - namespace := mcp.ParseString(request, "namespace", "") - allNamespaces := mcp.ParseString(request, "all_namespaces", "") == "true" +type waypointListInput struct { + Namespace string `json:"namespace" jsonschema:"Namespace to list waypoints in"` + AllNamespaces bool `json:"all_namespaces" jsonschema:"List waypoints in all namespaces"` +} +// Waypoint list +func handleWaypointList(ctx context.Context, request *mcp.CallToolRequest, in waypointListInput) (*mcp.CallToolResult, any, error) { args := []string{"waypoint", "list"} - if allNamespaces { + if in.AllNamespaces { args = append(args, "-A") - } else if namespace != "" { - args = append(args, "-n", namespace) + } else if in.Namespace != "" { + args = append(args, "-n", in.Namespace) } result, err := runIstioCtl(ctx, args) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("istioctl waypoint list failed: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("istioctl waypoint list failed: %v", err)), nil, nil } - return mcp.NewToolResultText(result), nil + return mcp.NewToolResultText(result), nil, nil +} + +type waypointGenerateInput struct { + Namespace string `json:"namespace" jsonschema:"Namespace for the waypoint resource"` + Name string `json:"name" jsonschema:"Name of the waypoint resource"` + TrafficType string `json:"traffic_type" jsonschema:"Traffic type for the waypoint (all, service, workload)"` } // Waypoint generate -func handleWaypointGenerate(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - namespace := mcp.ParseString(request, "namespace", "") - name := mcp.ParseString(request, "name", "waypoint") - trafficType := mcp.ParseString(request, "traffic_type", "all") +func handleWaypointGenerate(ctx context.Context, request *mcp.CallToolRequest, in waypointGenerateInput) (*mcp.CallToolResult, any, error) { + if in.Name == "" { + in.Name = "waypoint" + } + if in.TrafficType == "" { + in.TrafficType = "all" + } - if namespace == "" { - return mcp.NewToolResultError("namespace parameter is required"), nil + if in.Namespace == "" { + return mcp.NewToolResultError("namespace parameter is required"), nil, nil } args := []string{"waypoint", "generate"} - if name != "" { - args = append(args, name) + if in.Name != "" { + args = append(args, in.Name) } - args = append(args, "-n", namespace) + args = append(args, "-n", in.Namespace) - if trafficType != "" { - args = append(args, "--for", trafficType) + if in.TrafficType != "" { + args = append(args, "--for", in.TrafficType) } result, err := runIstioCtl(ctx, args) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("istioctl waypoint generate failed: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("istioctl waypoint generate failed: %v", err)), nil, nil } - return mcp.NewToolResultText(result), nil + return mcp.NewToolResultText(result), nil, nil } -// Waypoint apply -func handleWaypointApply(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - namespace := mcp.ParseString(request, "namespace", "") - enrollNamespace := mcp.ParseString(request, "enroll_namespace", "") == "true" +type waypointApplyInput struct { + Namespace string `json:"namespace" jsonschema:"Namespace to apply the waypoint in"` + EnrollNamespace bool `json:"enroll_namespace" jsonschema:"Enroll the namespace in the ambient mesh"` +} - if namespace == "" { - return mcp.NewToolResultError("namespace parameter is required"), nil +// Waypoint apply +func handleWaypointApply(ctx context.Context, request *mcp.CallToolRequest, in waypointApplyInput) (*mcp.CallToolResult, any, error) { + if in.Namespace == "" { + return mcp.NewToolResultError("namespace parameter is required"), nil, nil } - args := []string{"waypoint", "apply", "-n", namespace} + args := []string{"waypoint", "apply", "-n", in.Namespace} - if enrollNamespace { + if in.EnrollNamespace { args = append(args, "--enroll-namespace") } result, err := runIstioCtl(ctx, args) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("istioctl waypoint apply failed: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("istioctl waypoint apply failed: %v", err)), nil, nil } - return mcp.NewToolResultText(result), nil + return mcp.NewToolResultText(result), nil, nil } -// Waypoint delete -func handleWaypointDelete(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - namespace := mcp.ParseString(request, "namespace", "") - names := mcp.ParseString(request, "names", "") - all := mcp.ParseString(request, "all", "") == "true" +type waypointDeleteInput struct { + Namespace string `json:"namespace" jsonschema:"Namespace containing the waypoints to delete"` + Names string `json:"names" jsonschema:"Comma-separated list of waypoint names to delete"` + All bool `json:"all" jsonschema:"Delete all waypoints in the namespace"` +} - if namespace == "" { - return mcp.NewToolResultError("namespace parameter is required"), nil +// Waypoint delete +func handleWaypointDelete(ctx context.Context, request *mcp.CallToolRequest, in waypointDeleteInput) (*mcp.CallToolResult, any, error) { + if in.Namespace == "" { + return mcp.NewToolResultError("namespace parameter is required"), nil, nil } args := []string{"waypoint", "delete"} - if all { + if in.All { args = append(args, "--all") - } else if names != "" { - namesList := strings.Split(names, ",") + } else if in.Names != "" { + namesList := strings.Split(in.Names, ",") for _, name := range namesList { args = append(args, strings.TrimSpace(name)) } } - args = append(args, "-n", namespace) + args = append(args, "-n", in.Namespace) result, err := runIstioCtl(ctx, args) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("istioctl waypoint delete failed: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("istioctl waypoint delete failed: %v", err)), nil, nil } - return mcp.NewToolResultText(result), nil + return mcp.NewToolResultText(result), nil, nil } -// Waypoint status -func handleWaypointStatus(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - namespace := mcp.ParseString(request, "namespace", "") - name := mcp.ParseString(request, "name", "") +type waypointStatusInput struct { + Namespace string `json:"namespace" jsonschema:"Namespace of the waypoint"` + Name string `json:"name" jsonschema:"Name of the waypoint resource"` +} - if namespace == "" { - return mcp.NewToolResultError("namespace parameter is required"), nil +// Waypoint status +func handleWaypointStatus(ctx context.Context, request *mcp.CallToolRequest, in waypointStatusInput) (*mcp.CallToolResult, any, error) { + if in.Namespace == "" { + return mcp.NewToolResultError("namespace parameter is required"), nil, nil } args := []string{"waypoint", "status"} - if name != "" { - args = append(args, name) + if in.Name != "" { + args = append(args, in.Name) } - args = append(args, "-n", namespace) + args = append(args, "-n", in.Namespace) result, err := runIstioCtl(ctx, args) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("istioctl waypoint status failed: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("istioctl waypoint status failed: %v", err)), nil, nil } - return mcp.NewToolResultText(result), nil + return mcp.NewToolResultText(result), nil, nil +} + +type ztunnelConfigInput struct { + Namespace string `json:"namespace" jsonschema:"Namespace to get ztunnel configuration for"` + ConfigType string `json:"config_type" jsonschema:"Type of ztunnel configuration (all, workloads, services)"` } // Ztunnel config -func handleZtunnelConfig(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - namespace := mcp.ParseString(request, "namespace", "") - configType := mcp.ParseString(request, "config_type", "all") +func handleZtunnelConfig(ctx context.Context, request *mcp.CallToolRequest, in ztunnelConfigInput) (*mcp.CallToolResult, any, error) { + if in.ConfigType == "" { + in.ConfigType = "all" + } - args := []string{"ztunnel", "config", configType} + args := []string{"ztunnel", "config", in.ConfigType} - if namespace != "" { - args = append(args, "-n", namespace) + if in.Namespace != "" { + args = append(args, "-n", in.Namespace) } result, err := runIstioCtl(ctx, args) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("istioctl ztunnel config failed: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("istioctl ztunnel config failed: %v", err)), nil, nil } - return mcp.NewToolResultText(result), nil + return mcp.NewToolResultText(result), nil, nil } // Register Istio tools -func RegisterTools(s *server.MCPServer, readOnly bool) { +func RegisterTools(s *mcp.Server, readOnly bool) { // Read-only tools - always registered - // Istio proxy status - s.AddTool(mcp.NewTool("istio_proxy_status", - mcp.WithDescription("Get Envoy proxy status for pods, retrieves last sent and acknowledged xDS sync from Istiod to each Envoy in the mesh"), - mcp.WithString("pod_name", mcp.Description("Name of the pod to get proxy status for")), - mcp.WithString("namespace", mcp.Description("Namespace of the pod")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("istio_proxy_status", handleIstioProxyStatus))) - - // Istio proxy config - s.AddTool(mcp.NewTool("istio_proxy_config", - mcp.WithDescription("Get specific proxy configuration for a single pod"), - mcp.WithString("pod_name", mcp.Description("Name of the pod to get proxy configuration for"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("Namespace of the pod")), - mcp.WithString("config_type", mcp.Description("Type of configuration (all, bootstrap, cluster, ecds, listener, log, route, secret)")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("istio_proxy_config", handleIstioProxyConfig))) - - // Istio generate manifest (read-only - just generates YAML, doesn't apply) - s.AddTool(mcp.NewTool("istio_generate_manifest", - mcp.WithDescription("Generate Istio manifest for a given profile"), - mcp.WithString("profile", mcp.Description("Istio configuration profile (ambient, default, demo, minimal, empty)")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("istio_generate_manifest", handleIstioGenerateManifest))) - - // Istio analyze - s.AddTool(mcp.NewTool("istio_analyze_cluster_configuration", - mcp.WithDescription("Analyze Istio cluster configuration for issues"), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("istio_analyze_cluster_configuration", handleIstioAnalyzeClusterConfiguration))) - - // Istio version - s.AddTool(mcp.NewTool("istio_version", - mcp.WithDescription("Get Istio version information"), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("istio_version", handleIstioVersion))) - - // Istio remote clusters - s.AddTool(mcp.NewTool("istio_remote_clusters", - mcp.WithDescription("List remote clusters registered with Istio"), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("istio_remote_clusters", handleIstioRemoteClusters))) - - // Waypoint list - s.AddTool(mcp.NewTool("istio_list_waypoints", - mcp.WithDescription("List all waypoints in the mesh"), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("istio_list_waypoints", handleWaypointList))) - - // Waypoint generate (read-only - just generates YAML, doesn't apply) - s.AddTool(mcp.NewTool("istio_generate_waypoint", - mcp.WithDescription("Generate a waypoint resource YAML"), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("istio_generate_waypoint", handleWaypointGenerate))) - - // Waypoint status - s.AddTool(mcp.NewTool("istio_waypoint_status", - mcp.WithDescription("Get the status of a waypoint resource"), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("istio_waypoint_status", handleWaypointStatus))) - - // Ztunnel config - s.AddTool(mcp.NewTool("istio_ztunnel_config", - mcp.WithDescription("Get the ztunnel configuration for a namespace"), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("istio_ztunnel_config", handleZtunnelConfig))) + mcp.AddTool(s, "istio", &mcp.Tool{ + Name: "istio_proxy_status", + Description: "Get Envoy proxy status for pods, retrieves last sent and acknowledged xDS sync from Istiod to each Envoy in the mesh", + }, handleIstioProxyStatus) + + mcp.AddTool(s, "istio", &mcp.Tool{ + Name: "istio_proxy_config", + Description: "Get specific proxy configuration for a single pod", + }, handleIstioProxyConfig) + + mcp.AddTool(s, "istio", &mcp.Tool{ + Name: "istio_generate_manifest", + Description: "Generate Istio manifest for a given profile", + }, handleIstioGenerateManifest) + + mcp.AddTool(s, "istio", &mcp.Tool{ + Name: "istio_analyze_cluster_configuration", + Description: "Analyze Istio cluster configuration for issues", + }, handleIstioAnalyzeClusterConfiguration) + + mcp.AddTool(s, "istio", &mcp.Tool{ + Name: "istio_version", + Description: "Get Istio version information", + }, handleIstioVersion) + + mcp.AddTool(s, "istio", &mcp.Tool{ + Name: "istio_remote_clusters", + Description: "List remote clusters registered with Istio", + }, handleIstioRemoteClusters) + + mcp.AddTool(s, "istio", &mcp.Tool{ + Name: "istio_list_waypoints", + Description: "List all waypoints in the mesh", + }, handleWaypointList) + + mcp.AddTool(s, "istio", &mcp.Tool{ + Name: "istio_generate_waypoint", + Description: "Generate a waypoint resource YAML", + }, handleWaypointGenerate) + + mcp.AddTool(s, "istio", &mcp.Tool{ + Name: "istio_waypoint_status", + Description: "Get the status of a waypoint resource", + }, handleWaypointStatus) + + mcp.AddTool(s, "istio", &mcp.Tool{ + Name: "istio_ztunnel_config", + Description: "Get the ztunnel configuration for a namespace", + }, handleZtunnelConfig) // Write tools - only registered when write operations are enabled if !readOnly { - // Istio install - s.AddTool(mcp.NewTool("istio_install_istio", - mcp.WithDescription("Install Istio with a specified configuration profile"), - mcp.WithString("profile", mcp.Description("Istio configuration profile (ambient, default, demo, minimal, empty)")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("istio_install_istio", handleIstioInstall))) - - // Waypoint apply - s.AddTool(mcp.NewTool("istio_apply_waypoint", - mcp.WithDescription("Apply a waypoint resource to the cluster"), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("istio_apply_waypoint", handleWaypointApply))) - - // Waypoint delete - s.AddTool(mcp.NewTool("istio_delete_waypoint", - mcp.WithDescription("Delete a waypoint resource from the cluster"), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("istio_delete_waypoint", handleWaypointDelete))) + mcp.AddTool(s, "istio", &mcp.Tool{ + Name: "istio_install_istio", + Description: "Install Istio with a specified configuration profile", + }, handleIstioInstall) + + mcp.AddTool(s, "istio", &mcp.Tool{ + Name: "istio_apply_waypoint", + Description: "Apply a waypoint resource to the cluster", + }, handleWaypointApply) + + mcp.AddTool(s, "istio", &mcp.Tool{ + Name: "istio_delete_waypoint", + Description: "Delete a waypoint resource from the cluster", + }, handleWaypointDelete) } } diff --git a/pkg/istio/istio_test.go b/pkg/istio/istio_test.go index 4eacea90..e57f4c1d 100644 --- a/pkg/istio/istio_test.go +++ b/pkg/istio/istio_test.go @@ -5,14 +5,13 @@ import ( "testing" "github.com/kagent-dev/tools/internal/cmd" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" + mcp "github.com/kagent-dev/tools/internal/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestRegisterTools(t *testing.T) { - s := server.NewMCPServer("test-server", "v0.0.1") + s := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) RegisterTools(s, false) // false = enable all tools including write operations } @@ -25,7 +24,7 @@ func TestHandleIstioProxyStatus(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) - result, err := handleIstioProxyStatus(ctx, mcp.CallToolRequest{}) + result, _, err := handleIstioProxyStatus(ctx, &mcp.CallToolRequest{}, istioProxyStatusInput{}) require.NoError(t, err) assert.NotNil(t, result) @@ -38,12 +37,9 @@ func TestHandleIstioProxyStatus(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "namespace": "istio-system", - } - - result, err := handleIstioProxyStatus(ctx, request) + result, _, err := handleIstioProxyStatus(ctx, &mcp.CallToolRequest{}, istioProxyStatusInput{ + Namespace: "istio-system", + }) require.NoError(t, err) assert.NotNil(t, result) @@ -56,13 +52,10 @@ func TestHandleIstioProxyStatus(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "pod_name": "test-pod", - "namespace": "default", - } - - result, err := handleIstioProxyStatus(ctx, request) + result, _, err := handleIstioProxyStatus(ctx, &mcp.CallToolRequest{}, istioProxyStatusInput{ + PodName: "test-pod", + Namespace: "default", + }) require.NoError(t, err) assert.NotNil(t, result) @@ -74,7 +67,7 @@ func TestHandleIstioProxyConfig(t *testing.T) { ctx := context.Background() t.Run("missing pod_name parameter", func(t *testing.T) { - result, err := handleIstioProxyConfig(ctx, mcp.CallToolRequest{}) + result, _, err := handleIstioProxyConfig(ctx, &mcp.CallToolRequest{}, istioProxyConfigInput{}) require.NoError(t, err) assert.NotNil(t, result) @@ -87,12 +80,9 @@ func TestHandleIstioProxyConfig(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "pod_name": "test-pod", - } - - result, err := handleIstioProxyConfig(ctx, request) + result, _, err := handleIstioProxyConfig(ctx, &mcp.CallToolRequest{}, istioProxyConfigInput{ + PodName: "test-pod", + }) require.NoError(t, err) assert.NotNil(t, result) @@ -105,14 +95,11 @@ func TestHandleIstioProxyConfig(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "pod_name": "test-pod", - "namespace": "default", - "config_type": "cluster", - } - - result, err := handleIstioProxyConfig(ctx, request) + result, _, err := handleIstioProxyConfig(ctx, &mcp.CallToolRequest{}, istioProxyConfigInput{ + PodName: "test-pod", + Namespace: "default", + ConfigType: "cluster", + }) require.NoError(t, err) assert.NotNil(t, result) @@ -129,7 +116,7 @@ func TestHandleIstioInstall(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) - result, err := handleIstioInstall(ctx, mcp.CallToolRequest{}) + result, _, err := handleIstioInstall(ctx, &mcp.CallToolRequest{}, istioInstallInput{}) require.NoError(t, err) assert.NotNil(t, result) @@ -142,12 +129,9 @@ func TestHandleIstioInstall(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "profile": "demo", - } - - result, err := handleIstioInstall(ctx, request) + result, _, err := handleIstioInstall(ctx, &mcp.CallToolRequest{}, istioInstallInput{ + Profile: "demo", + }) require.NoError(t, err) assert.NotNil(t, result) @@ -163,12 +147,9 @@ func TestHandleIstioGenerateManifest(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "profile": "minimal", - } - - result, err := handleIstioGenerateManifest(ctx, request) + result, _, err := handleIstioGenerateManifest(ctx, &mcp.CallToolRequest{}, istioGenerateManifestInput{ + Profile: "minimal", + }) require.NoError(t, err) assert.NotNil(t, result) @@ -184,12 +165,9 @@ func TestHandleIstioAnalyzeClusterConfiguration(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "all_namespaces": "true", - } - - result, err := handleIstioAnalyzeClusterConfiguration(ctx, request) + result, _, err := handleIstioAnalyzeClusterConfiguration(ctx, &mcp.CallToolRequest{}, istioAnalyzeClusterConfigurationInput{ + AllNamespaces: true, + }) require.NoError(t, err) assert.NotNil(t, result) @@ -202,12 +180,9 @@ func TestHandleIstioAnalyzeClusterConfiguration(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "namespace": "default", - } - - result, err := handleIstioAnalyzeClusterConfiguration(ctx, request) + result, _, err := handleIstioAnalyzeClusterConfiguration(ctx, &mcp.CallToolRequest{}, istioAnalyzeClusterConfigurationInput{ + Namespace: "default", + }) require.NoError(t, err) assert.NotNil(t, result) @@ -224,7 +199,7 @@ func TestHandleIstioVersion(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) - result, err := handleIstioVersion(ctx, mcp.CallToolRequest{}) + result, _, err := handleIstioVersion(ctx, &mcp.CallToolRequest{}, istioVersionInput{}) require.NoError(t, err) assert.NotNil(t, result) @@ -237,12 +212,9 @@ func TestHandleIstioVersion(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "short": "true", - } - - result, err := handleIstioVersion(ctx, request) + result, _, err := handleIstioVersion(ctx, &mcp.CallToolRequest{}, istioVersionInput{ + Short: true, + }) require.NoError(t, err) assert.NotNil(t, result) @@ -258,7 +230,7 @@ func TestHandleIstioRemoteClusters(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) - result, err := handleIstioRemoteClusters(ctx, mcp.CallToolRequest{}) + result, _, err := handleIstioRemoteClusters(ctx, &mcp.CallToolRequest{}, istioRemoteClustersInput{}) require.NoError(t, err) assert.NotNil(t, result) @@ -274,12 +246,9 @@ func TestHandleWaypointList(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "all_namespaces": "true", - } - - result, err := handleWaypointList(ctx, request) + result, _, err := handleWaypointList(ctx, &mcp.CallToolRequest{}, waypointListInput{ + AllNamespaces: true, + }) require.NoError(t, err) assert.NotNil(t, result) @@ -292,12 +261,9 @@ func TestHandleWaypointList(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "namespace": "default", - } - - result, err := handleWaypointList(ctx, request) + result, _, err := handleWaypointList(ctx, &mcp.CallToolRequest{}, waypointListInput{ + Namespace: "default", + }) require.NoError(t, err) assert.NotNil(t, result) @@ -314,14 +280,11 @@ func TestHandleWaypointGenerate(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "namespace": "default", - "name": "waypoint", - "traffic_type": "all", - } - - result, err := handleWaypointGenerate(ctx, request) + result, _, err := handleWaypointGenerate(ctx, &mcp.CallToolRequest{}, waypointGenerateInput{ + Namespace: "default", + Name: "waypoint", + TrafficType: "all", + }) require.NoError(t, err) assert.NotNil(t, result) @@ -348,7 +311,7 @@ func TestIstioErrorHandling(t *testing.T) { mock.AddCommandString("istioctl", []string{"proxy-status"}, "", assert.AnError) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, err := handleIstioProxyStatus(ctx, mcp.CallToolRequest{}) + result, _, err := handleIstioProxyStatus(ctx, &mcp.CallToolRequest{}, istioProxyStatusInput{}) require.NoError(t, err) assert.NotNil(t, result) @@ -362,9 +325,7 @@ func TestHandleWaypointApply(t *testing.T) { mock.AddCommandString("istioctl", []string{"waypoint", "apply", "-n", "default"}, "applied", nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{"namespace": "default"} - result, err := handleWaypointApply(ctx, req) + result, _, err := handleWaypointApply(ctx, &mcp.CallToolRequest{}, waypointApplyInput{Namespace: "default"}) require.NoError(t, err) assert.False(t, result.IsError) }) @@ -374,9 +335,10 @@ func TestHandleWaypointApply(t *testing.T) { mock.AddCommandString("istioctl", []string{"waypoint", "apply", "-n", "default", "--enroll-namespace"}, "applied", nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{"namespace": "default", "enroll_namespace": "true"} - result, err := handleWaypointApply(ctx, req) + result, _, err := handleWaypointApply(ctx, &mcp.CallToolRequest{}, waypointApplyInput{ + Namespace: "default", + EnrollNamespace: true, + }) require.NoError(t, err) assert.False(t, result.IsError) }) @@ -384,7 +346,7 @@ func TestHandleWaypointApply(t *testing.T) { t.Run("missing namespace", func(t *testing.T) { mock := cmd.NewMockShellExecutor() ctx := cmd.WithShellExecutor(context.Background(), mock) - result, err := handleWaypointApply(ctx, mcp.CallToolRequest{}) + result, _, err := handleWaypointApply(ctx, &mcp.CallToolRequest{}, waypointApplyInput{}) require.NoError(t, err) assert.True(t, result.IsError) }) @@ -393,9 +355,7 @@ func TestHandleWaypointApply(t *testing.T) { mock := cmd.NewMockShellExecutor() mock.AddCommandString("istioctl", []string{"waypoint", "apply", "-n", "default"}, "", assert.AnError) ctx := cmd.WithShellExecutor(context.Background(), mock) - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{"namespace": "default"} - result, err := handleWaypointApply(ctx, req) + result, _, err := handleWaypointApply(ctx, &mcp.CallToolRequest{}, waypointApplyInput{Namespace: "default"}) require.NoError(t, err) assert.True(t, result.IsError) }) @@ -406,9 +366,10 @@ func TestHandleWaypointDelete(t *testing.T) { mock := cmd.NewMockShellExecutor() mock.AddCommandString("istioctl", []string{"waypoint", "delete", "--all", "-n", "default"}, "deleted", nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{"namespace": "default", "all": "true"} - result, err := handleWaypointDelete(ctx, req) + result, _, err := handleWaypointDelete(ctx, &mcp.CallToolRequest{}, waypointDeleteInput{ + Namespace: "default", + All: true, + }) require.NoError(t, err) assert.False(t, result.IsError) }) @@ -417,9 +378,10 @@ func TestHandleWaypointDelete(t *testing.T) { mock := cmd.NewMockShellExecutor() mock.AddCommandString("istioctl", []string{"waypoint", "delete", "wp1", "wp2", "-n", "default"}, "deleted", nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{"namespace": "default", "names": "wp1, wp2"} - result, err := handleWaypointDelete(ctx, req) + result, _, err := handleWaypointDelete(ctx, &mcp.CallToolRequest{}, waypointDeleteInput{ + Namespace: "default", + Names: "wp1, wp2", + }) require.NoError(t, err) assert.False(t, result.IsError) }) @@ -427,7 +389,7 @@ func TestHandleWaypointDelete(t *testing.T) { t.Run("missing namespace", func(t *testing.T) { mock := cmd.NewMockShellExecutor() ctx := cmd.WithShellExecutor(context.Background(), mock) - result, err := handleWaypointDelete(ctx, mcp.CallToolRequest{}) + result, _, err := handleWaypointDelete(ctx, &mcp.CallToolRequest{}, waypointDeleteInput{}) require.NoError(t, err) assert.True(t, result.IsError) }) @@ -438,9 +400,10 @@ func TestHandleWaypointStatus(t *testing.T) { mock := cmd.NewMockShellExecutor() mock.AddCommandString("istioctl", []string{"waypoint", "status", "wp1", "-n", "default"}, "status", nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{"namespace": "default", "name": "wp1"} - result, err := handleWaypointStatus(ctx, req) + result, _, err := handleWaypointStatus(ctx, &mcp.CallToolRequest{}, waypointStatusInput{ + Namespace: "default", + Name: "wp1", + }) require.NoError(t, err) assert.False(t, result.IsError) }) @@ -449,9 +412,9 @@ func TestHandleWaypointStatus(t *testing.T) { mock := cmd.NewMockShellExecutor() mock.AddCommandString("istioctl", []string{"waypoint", "status", "-n", "default"}, "status", nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{"namespace": "default"} - result, err := handleWaypointStatus(ctx, req) + result, _, err := handleWaypointStatus(ctx, &mcp.CallToolRequest{}, waypointStatusInput{ + Namespace: "default", + }) require.NoError(t, err) assert.False(t, result.IsError) }) @@ -459,7 +422,7 @@ func TestHandleWaypointStatus(t *testing.T) { t.Run("missing namespace", func(t *testing.T) { mock := cmd.NewMockShellExecutor() ctx := cmd.WithShellExecutor(context.Background(), mock) - result, err := handleWaypointStatus(ctx, mcp.CallToolRequest{}) + result, _, err := handleWaypointStatus(ctx, &mcp.CallToolRequest{}, waypointStatusInput{}) require.NoError(t, err) assert.True(t, result.IsError) }) @@ -470,7 +433,7 @@ func TestHandleZtunnelConfig(t *testing.T) { mock := cmd.NewMockShellExecutor() mock.AddCommandString("istioctl", []string{"ztunnel", "config", "all"}, "ztunnel config", nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, err := handleZtunnelConfig(ctx, mcp.CallToolRequest{}) + result, _, err := handleZtunnelConfig(ctx, &mcp.CallToolRequest{}, ztunnelConfigInput{}) require.NoError(t, err) assert.False(t, result.IsError) }) @@ -479,9 +442,10 @@ func TestHandleZtunnelConfig(t *testing.T) { mock := cmd.NewMockShellExecutor() mock.AddCommandString("istioctl", []string{"ztunnel", "config", "workloads", "-n", "istio-system"}, "ztunnel config", nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{"config_type": "workloads", "namespace": "istio-system"} - result, err := handleZtunnelConfig(ctx, req) + result, _, err := handleZtunnelConfig(ctx, &mcp.CallToolRequest{}, ztunnelConfigInput{ + ConfigType: "workloads", + Namespace: "istio-system", + }) require.NoError(t, err) assert.False(t, result.IsError) }) @@ -490,7 +454,7 @@ func TestHandleZtunnelConfig(t *testing.T) { mock := cmd.NewMockShellExecutor() mock.AddCommandString("istioctl", []string{"ztunnel", "config", "all"}, "", assert.AnError) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, err := handleZtunnelConfig(ctx, mcp.CallToolRequest{}) + result, _, err := handleZtunnelConfig(ctx, &mcp.CallToolRequest{}, ztunnelConfigInput{}) require.NoError(t, err) assert.True(t, result.IsError) }) diff --git a/pkg/k8s/k8s.go b/pkg/k8s/k8s.go index 6def2f2a..abb40ac0 100644 --- a/pkg/k8s/k8s.go +++ b/pkg/k8s/k8s.go @@ -12,15 +12,13 @@ import ( "strings" "time" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" "github.com/tmc/langchaingo/llms" "github.com/kagent-dev/tools/internal/cache" "github.com/kagent-dev/tools/internal/commands" "github.com/kagent-dev/tools/internal/logger" + mcp "github.com/kagent-dev/tools/internal/mcp" "github.com/kagent-dev/tools/internal/security" - "github.com/kagent-dev/tools/internal/telemetry" ) // K8sTool struct to hold the LLM model @@ -54,436 +52,634 @@ func (k *K8sTool) runKubectlCommandWithCacheInvalidation(ctx context.Context, he return result, err } -// Enhanced kubectl get -func (k *K8sTool) handleKubectlGetEnhanced(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - resourceType := mcp.ParseString(request, "resource_type", "") - resourceName := mcp.ParseString(request, "resource_name", "") - namespace := mcp.ParseString(request, "namespace", "") - allNamespaces := mcp.ParseString(request, "all_namespaces", "") == "true" - output := mcp.ParseString(request, "output", "wide") +// getResourcesInput is the typed input for k8s_get_resources. +type getResourcesInput struct { + ResourceType string `json:"resource_type" jsonschema:"Type of resource (pod, service, deployment, etc.)"` + ResourceName string `json:"resource_name" jsonschema:"Name of specific resource (optional)"` + Namespace string `json:"namespace" jsonschema:"Namespace to query (optional)"` + AllNamespaces bool `json:"all_namespaces" jsonschema:"Query all namespaces"` + Output string `json:"output" jsonschema:"Output format (json, yaml, wide)"` +} - if resourceType == "" { - return mcp.NewToolResultError("resource_type parameter is required"), nil +// Enhanced kubectl get +func (k *K8sTool) handleKubectlGetEnhanced(ctx context.Context, request *mcp.CallToolRequest, in getResourcesInput) (*mcp.CallToolResult, any, error) { + if in.ResourceType == "" { + return mcp.NewToolResultError("resource_type parameter is required"), nil, nil + } + if in.Output == "" { + in.Output = "wide" } - args := []string{"get", resourceType} + args := []string{"get", in.ResourceType} - if resourceName != "" { - args = append(args, resourceName) + if in.ResourceName != "" { + args = append(args, in.ResourceName) } - if allNamespaces { + if in.AllNamespaces { args = append(args, "--all-namespaces") - } else if namespace != "" { - args = append(args, "-n", namespace) + } else if in.Namespace != "" { + args = append(args, "-n", in.Namespace) } - if output != "" { - args = append(args, "-o", output) - } else { - args = append(args, "-o", "json") - } + args = append(args, "-o", in.Output) - return k.runKubectlCommand(ctx, request.Header, args...) + res, err := k.runKubectlCommand(ctx, mcp.Header(request), args...) + return res, nil, err } -// Get pod logs -func (k *K8sTool) handleKubectlLogsEnhanced(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - podName := mcp.ParseString(request, "pod_name", "") - namespace := mcp.ParseString(request, "namespace", "default") - container := mcp.ParseString(request, "container", "") - tailLines := mcp.ParseInt(request, "tail_lines", 50) +// logsInput is the typed input for k8s_get_pod_logs. +type logsInput struct { + PodName string `json:"pod_name" jsonschema:"Name of the pod"` + Namespace string `json:"namespace" jsonschema:"Namespace of the pod (default: default)"` + Container string `json:"container" jsonschema:"Container name (for multi-container pods)"` + TailLines int `json:"tail_lines" jsonschema:"Number of lines to show from the end (default: 50)"` +} - if podName == "" { - return mcp.NewToolResultError("pod_name parameter is required"), nil +// Get pod logs +func (k *K8sTool) handleKubectlLogsEnhanced(ctx context.Context, request *mcp.CallToolRequest, in logsInput) (*mcp.CallToolResult, any, error) { + if in.PodName == "" { + return mcp.NewToolResultError("pod_name parameter is required"), nil, nil + } + if in.Namespace == "" { + in.Namespace = "default" + } + if in.TailLines == 0 { + in.TailLines = 50 } - args := []string{"logs", podName, "-n", namespace} + args := []string{"logs", in.PodName, "-n", in.Namespace} - if container != "" { - args = append(args, "-c", container) + if in.Container != "" { + args = append(args, "-c", in.Container) } - if tailLines > 0 { - args = append(args, "--tail", fmt.Sprintf("%d", tailLines)) + if in.TailLines > 0 { + args = append(args, "--tail", fmt.Sprintf("%d", in.TailLines)) } - return k.runKubectlCommand(ctx, request.Header, args...) + res, err := k.runKubectlCommand(ctx, mcp.Header(request), args...) + return res, nil, err } -// Scale deployment -func (k *K8sTool) handleScaleDeployment(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - deploymentName := mcp.ParseString(request, "name", "") - namespace := mcp.ParseString(request, "namespace", "default") - replicas := mcp.ParseInt(request, "replicas", 1) +// scaleInput is the typed input for k8s_scale. +type scaleInput struct { + Name string `json:"name" jsonschema:"Name of the deployment"` + Namespace string `json:"namespace" jsonschema:"Namespace of the deployment (default: default)"` + Replicas int `json:"replicas" jsonschema:"Number of replicas"` +} - if deploymentName == "" { - return mcp.NewToolResultError("name parameter is required"), nil +// Scale deployment +func (k *K8sTool) handleScaleDeployment(ctx context.Context, request *mcp.CallToolRequest, in scaleInput) (*mcp.CallToolResult, any, error) { + if in.Name == "" { + return mcp.NewToolResultError("name parameter is required"), nil, nil + } + if in.Namespace == "" { + in.Namespace = "default" } + if in.Replicas == 0 { + in.Replicas = 1 + } + + args := []string{"scale", "deployment", in.Name, "--replicas", fmt.Sprintf("%d", in.Replicas), "-n", in.Namespace} - args := []string{"scale", "deployment", deploymentName, "--replicas", fmt.Sprintf("%d", replicas), "-n", namespace} + res, err := k.runKubectlCommandWithCacheInvalidation(ctx, mcp.Header(request), args...) + return res, nil, err +} - return k.runKubectlCommandWithCacheInvalidation(ctx, request.Header, args...) +// patchResourceInput is the typed input for k8s_patch_resource. +type patchResourceInput struct { + ResourceType string `json:"resource_type" jsonschema:"Type of resource (deployment, service, etc.)"` + ResourceName string `json:"resource_name" jsonschema:"Name of the resource"` + Patch string `json:"patch" jsonschema:"JSON patch to apply"` + PatchType string `json:"patch_type" jsonschema:"Patch strategy: \"strategic\" (default; built-in Kubernetes types only), \"merge\" (RFC 7386 JSON merge patch; required for CustomResources/CRDs), or \"json\" (RFC 6902 JSON patch)."` + Namespace string `json:"namespace" jsonschema:"Namespace of the resource (default: default)"` } // Patch resource -func (k *K8sTool) handlePatchResource(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - resourceType := mcp.ParseString(request, "resource_type", "") - resourceName := mcp.ParseString(request, "resource_name", "") - patch := mcp.ParseString(request, "patch", "") - namespace := mcp.ParseString(request, "namespace", "default") - patchType := mcp.ParseString(request, "patch_type", "strategic") +func (k *K8sTool) handlePatchResource(ctx context.Context, request *mcp.CallToolRequest, in patchResourceInput) (*mcp.CallToolResult, any, error) { + if in.Namespace == "" { + in.Namespace = "default" + } + if in.PatchType == "" { + in.PatchType = "strategic" + } - if resourceType == "" || resourceName == "" || patch == "" { - return mcp.NewToolResultError("resource_type, resource_name, and patch parameters are required"), nil + if in.ResourceType == "" || in.ResourceName == "" || in.Patch == "" { + return mcp.NewToolResultError("resource_type, resource_name, and patch parameters are required"), nil, nil } // Validate patch type. "strategic" is only implemented for built-in Kubernetes // types; CustomResources (CRDs) reject it and require "merge" or "json". - switch patchType { + switch in.PatchType { case "strategic", "merge", "json": default: - return mcp.NewToolResultError(fmt.Sprintf("Invalid patch_type %q: must be one of strategic, merge, json", patchType)), nil + return mcp.NewToolResultError(fmt.Sprintf("Invalid patch_type %q: must be one of strategic, merge, json", in.PatchType)), nil, nil } - // Validate resource name for security - if err := security.ValidateK8sResourceName(resourceName); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid resource name: %v", err)), nil + if err := security.ValidateK8sResourceName(in.ResourceName); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid resource name: %v", err)), nil, nil } - // Validate namespace for security - if err := security.ValidateNamespace(namespace); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid namespace: %v", err)), nil + if err := security.ValidateNamespace(in.Namespace); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid namespace: %v", err)), nil, nil } - // Validate patch content as JSON/YAML - if err := security.ValidateYAMLContent(patch); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid patch content: %v", err)), nil + if err := security.ValidateYAMLContent(in.Patch); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid patch content: %v", err)), nil, nil } - args := []string{"patch", resourceType, resourceName, "--type=" + patchType, "-p", patch, "-n", namespace} + args := []string{"patch", in.ResourceType, in.ResourceName, "--type=" + in.PatchType, "-p", in.Patch, "-n", in.Namespace} + + res, err := k.runKubectlCommandWithCacheInvalidation(ctx, mcp.Header(request), args...) + return res, nil, err +} - return k.runKubectlCommandWithCacheInvalidation(ctx, request.Header, args...) +// patchStatusInput is the typed input for k8s_patch_status. +type patchStatusInput struct { + ResourceType string `json:"resource_type" jsonschema:"Type of resource (deployment, service, etc.)"` + ResourceName string `json:"resource_name" jsonschema:"Name of the resource"` + Patch string `json:"patch" jsonschema:"JSON/YAML status patch"` + Namespace string `json:"namespace" jsonschema:"Namespace of the resource (default: default)"` } // Patch resource status -func (k *K8sTool) handlePatchStatus(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - resourceType := mcp.ParseString(request, "resource_type", "") - resourceName := mcp.ParseString(request, "resource_name", "") - patch := mcp.ParseString(request, "patch", "") - namespace := mcp.ParseString(request, "namespace", "default") +func (k *K8sTool) handlePatchStatus(ctx context.Context, request *mcp.CallToolRequest, in patchStatusInput) (*mcp.CallToolResult, any, error) { + if in.Namespace == "" { + in.Namespace = "default" + } - if resourceType == "" || resourceName == "" || patch == "" { - return mcp.NewToolResultError("resource_type, resource_name, and patch parameters are required"), nil + if in.ResourceType == "" || in.ResourceName == "" || in.Patch == "" { + return mcp.NewToolResultError("resource_type, resource_name, and patch parameters are required"), nil, nil } - // Validate resource name for security - if err := security.ValidateK8sResourceName(resourceName); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid resource name: %v", err)), nil + if err := security.ValidateK8sResourceName(in.ResourceName); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid resource name: %v", err)), nil, nil } - // Validate namespace for security - if err := security.ValidateNamespace(namespace); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid namespace: %v", err)), nil + if err := security.ValidateNamespace(in.Namespace); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid namespace: %v", err)), nil, nil } - // Validate patch content as JSON/YAML - if err := security.ValidateYAMLContent(patch); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid patch content: %v", err)), nil + if err := security.ValidateYAMLContent(in.Patch); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid patch content: %v", err)), nil, nil } args := []string{ "patch", - resourceType, - resourceName, + in.ResourceType, + in.ResourceName, "--subresource=status", "--type=merge", "-p", - patch, + in.Patch, "-n", - namespace, + in.Namespace, } - return k.runKubectlCommandWithCacheInvalidation(ctx, request.Header, args...) + res, err := k.runKubectlCommandWithCacheInvalidation(ctx, mcp.Header(request), args...) + return res, nil, err } -// Apply manifest from content -func (k *K8sTool) handleApplyManifest(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - manifest := mcp.ParseString(request, "manifest", "") +// applyManifestInput is the typed input for k8s_apply_manifest. +type applyManifestInput struct { + Manifest string `json:"manifest" jsonschema:"YAML manifest content"` +} - if manifest == "" { - return mcp.NewToolResultError("manifest parameter is required"), nil +// Apply manifest from content +func (k *K8sTool) handleApplyManifest(ctx context.Context, request *mcp.CallToolRequest, in applyManifestInput) (*mcp.CallToolResult, any, error) { + if in.Manifest == "" { + return mcp.NewToolResultError("manifest parameter is required"), nil, nil } - // Validate YAML content for security - if err := security.ValidateYAMLContent(manifest); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid manifest content: %v", err)), nil + if err := security.ValidateYAMLContent(in.Manifest); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid manifest content: %v", err)), nil, nil } - // Create temporary file with secure permissions tmpFile, err := os.CreateTemp("", "k8s-manifest-*.yaml") if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to create temp file: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to create temp file: %v", err)), nil, nil } - // Ensure file is removed regardless of execution path defer func() { if removeErr := os.Remove(tmpFile.Name()); removeErr != nil { logger.Get().Error("Failed to remove temporary file", "error", removeErr, "file", tmpFile.Name()) } }() - // Set secure file permissions (readable/writable by owner only) if err := os.Chmod(tmpFile.Name(), 0600); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to set file permissions: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to set file permissions: %v", err)), nil, nil } - // Write manifest content to temporary file - if _, err := tmpFile.WriteString(manifest); err != nil { + if _, err := tmpFile.WriteString(in.Manifest); err != nil { tmpFile.Close() - return mcp.NewToolResultError(fmt.Sprintf("Failed to write to temp file: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to write to temp file: %v", err)), nil, nil } - // Close the file before passing to kubectl if err := tmpFile.Close(); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to close temp file: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to close temp file: %v", err)), nil, nil } - return k.runKubectlCommandWithCacheInvalidation(ctx, request.Header, "apply", "-f", tmpFile.Name()) + res, err := k.runKubectlCommandWithCacheInvalidation(ctx, mcp.Header(request), "apply", "-f", tmpFile.Name()) + return res, nil, err +} + +// deleteResourceInput is the typed input for k8s_delete_resource. +type deleteResourceInput struct { + ResourceType string `json:"resource_type" jsonschema:"Type of resource (pod, service, deployment, etc.)"` + ResourceName string `json:"resource_name" jsonschema:"Name of the resource"` + Namespace string `json:"namespace" jsonschema:"Namespace of the resource (default: default)"` } // Delete resource -func (k *K8sTool) handleDeleteResource(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - resourceType := mcp.ParseString(request, "resource_type", "") - resourceName := mcp.ParseString(request, "resource_name", "") - namespace := mcp.ParseString(request, "namespace", "default") +func (k *K8sTool) handleDeleteResource(ctx context.Context, request *mcp.CallToolRequest, in deleteResourceInput) (*mcp.CallToolResult, any, error) { + if in.Namespace == "" { + in.Namespace = "default" + } - if resourceType == "" || resourceName == "" { - return mcp.NewToolResultError("resource_type and resource_name parameters are required"), nil + if in.ResourceType == "" || in.ResourceName == "" { + return mcp.NewToolResultError("resource_type and resource_name parameters are required"), nil, nil } - args := []string{"delete", resourceType, resourceName, "-n", namespace} + args := []string{"delete", in.ResourceType, in.ResourceName, "-n", in.Namespace} - return k.runKubectlCommandWithCacheInvalidation(ctx, request.Header, args...) + res, err := k.runKubectlCommandWithCacheInvalidation(ctx, mcp.Header(request), args...) + return res, nil, err } -// Check service connectivity -func (k *K8sTool) handleCheckServiceConnectivity(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - serviceName := mcp.ParseString(request, "service_name", "") - namespace := mcp.ParseString(request, "namespace", "default") +// waitInput is the typed input for k8s_wait. +type waitInput struct { + ResourceType string `json:"resource_type" jsonschema:"Type of resource (pod, deployment, job, etc.)"` + Condition string `json:"condition" jsonschema:"Condition to wait for, passed to --for. Examples: 'condition=Ready', 'condition=Available', 'delete', 'create', \"jsonpath={.status.phase}=Running\""` + ResourceName string `json:"resource_name" jsonschema:"Name of a specific resource. Omit to target by selector or all"` + Selector string `json:"selector" jsonschema:"Label selector to target resources, e.g. 'app=nginx'"` + All bool `json:"all" jsonschema:"Wait on all resources of the type in the namespace"` + Namespace string `json:"namespace" jsonschema:"Namespace of the resource (default: default)"` + Timeout string `json:"timeout" jsonschema:"Max wait duration, e.g. '30s', '5m'. 0 waits forever (default: 30s)"` +} + +// Wait for a condition on one or more resources (kubectl wait) +func (k *K8sTool) handleKubectlWait(ctx context.Context, request *mcp.CallToolRequest, in waitInput) (*mcp.CallToolResult, any, error) { + if in.Namespace == "" { + in.Namespace = "default" + } + if in.Timeout == "" { + in.Timeout = "30s" + } + + if in.ResourceType == "" || in.Condition == "" { + return mcp.NewToolResultError("resource_type and condition parameters are required"), nil, nil + } + if in.ResourceName == "" && in.Selector == "" && !in.All { + return mcp.NewToolResultError("one of resource_name, selector, or all=true must be provided"), nil, nil + } + + if err := security.ValidateNamespace(in.Namespace); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid namespace: %v", err)), nil, nil + } + + target := in.ResourceType + if in.ResourceName != "" { + if err := security.ValidateK8sResourceName(in.ResourceName); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid resource name: %v", err)), nil, nil + } + target = fmt.Sprintf("%s/%s", in.ResourceType, in.ResourceName) + } + + args := []string{"wait", target, "--for=" + in.Condition, "--timeout", in.Timeout, "-n", in.Namespace} + if in.Selector != "" { + args = append(args, "-l", in.Selector) + } + if in.All { + args = append(args, "--all") + } + + res, err := k.runKubectlCommand(ctx, mcp.Header(request), args...) + return res, nil, err +} - if serviceName == "" { - return mcp.NewToolResultError("service_name parameter is required"), nil +// serviceConnectivityInput is the typed input for k8s_check_service_connectivity. +type serviceConnectivityInput struct { + ServiceName string `json:"service_name" jsonschema:"Service name to test (e.g., my-service.my-namespace.svc.cluster.local:80)"` + Namespace string `json:"namespace" jsonschema:"Namespace to run the check from (default: default)"` +} + +// Check service connectivity +func (k *K8sTool) handleCheckServiceConnectivity(ctx context.Context, request *mcp.CallToolRequest, in serviceConnectivityInput) (*mcp.CallToolResult, any, error) { + if in.Namespace == "" { + in.Namespace = "default" } + if in.ServiceName == "" { + return mcp.NewToolResultError("service_name parameter is required"), nil, nil + } + + headers := mcp.Header(request) // Create a temporary curl pod for connectivity check podName := fmt.Sprintf("curl-test-%d", rand.Intn(10000)) defer func() { - _, _ = k.runKubectlCommand(ctx, request.Header, "delete", "pod", podName, "-n", namespace, "--ignore-not-found") + _, _ = k.runKubectlCommand(ctx, headers, "delete", "pod", podName, "-n", in.Namespace, "--ignore-not-found") }() // Create the curl pod - _, err := k.runKubectlCommand(ctx, request.Header, "run", podName, "--image=curlimages/curl", "-n", namespace, "--restart=Never", "--", "sleep", "3600") + _, err := k.runKubectlCommand(ctx, headers, "run", podName, "--image=curlimages/curl", "-n", in.Namespace, "--restart=Never", "--", "sleep", "3600") if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to create curl pod: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to create curl pod: %v", err)), nil, nil } // Wait for pod to be ready - _, err = k.runKubectlCommandWithTimeout(ctx, request.Header, 60*time.Second, "wait", "--for=condition=ready", "pod/"+podName, "-n", namespace) + _, err = k.runKubectlCommandWithTimeout(ctx, headers, 60*time.Second, "wait", "--for=condition=ready", "pod/"+podName, "-n", in.Namespace) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to wait for curl pod: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to wait for curl pod: %v", err)), nil, nil } // Execute kubectl command - return k.runKubectlCommand(ctx, request.Header, "exec", podName, "-n", namespace, "--", "curl", "-s", serviceName) + res, err := k.runKubectlCommand(ctx, headers, "exec", podName, "-n", in.Namespace, "--", "curl", "-s", in.ServiceName) + return res, nil, err } -// Get cluster events -func (k *K8sTool) handleGetEvents(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - namespace := mcp.ParseString(request, "namespace", "") +// eventsInput is the typed input for k8s_get_events. +type eventsInput struct { + Namespace string `json:"namespace" jsonschema:"Namespace to get events from (default: default)"` +} +// Get cluster events +func (k *K8sTool) handleGetEvents(ctx context.Context, request *mcp.CallToolRequest, in eventsInput) (*mcp.CallToolResult, any, error) { args := []string{"get", "events", "-o", "json"} - if namespace != "" { - args = append(args, "-n", namespace) + if in.Namespace != "" { + args = append(args, "-n", in.Namespace) } else { args = append(args, "--all-namespaces") } - return k.runKubectlCommand(ctx, request.Header, args...) + res, err := k.runKubectlCommand(ctx, mcp.Header(request), args...) + return res, nil, err } -// Execute command in pod -func (k *K8sTool) handleExecCommand(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - podName := mcp.ParseString(request, "pod_name", "") - namespace := mcp.ParseString(request, "namespace", "default") - command := mcp.ParseString(request, "command", "") +// execCommandInput is the typed input for k8s_execute_command. +type execCommandInput struct { + PodName string `json:"pod_name" jsonschema:"Name of the pod to execute in"` + Namespace string `json:"namespace" jsonschema:"Namespace of the pod (default: default)"` + Container string `json:"container" jsonschema:"Container name (for multi-container pods)"` + Command string `json:"command" jsonschema:"Command to execute"` +} - if podName == "" || command == "" { - return mcp.NewToolResultError("pod_name and command parameters are required"), nil +// Execute command in pod +func (k *K8sTool) handleExecCommand(ctx context.Context, request *mcp.CallToolRequest, in execCommandInput) (*mcp.CallToolResult, any, error) { + if in.Namespace == "" { + in.Namespace = "default" + } + if in.PodName == "" || in.Command == "" { + return mcp.NewToolResultError("pod_name and command parameters are required"), nil, nil } - // Validate pod name for security - if err := security.ValidateK8sResourceName(podName); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid pod name: %v", err)), nil + if err := security.ValidateK8sResourceName(in.PodName); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid pod name: %v", err)), nil, nil } - // Validate namespace for security - if err := security.ValidateNamespace(namespace); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid namespace: %v", err)), nil + if err := security.ValidateNamespace(in.Namespace); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid namespace: %v", err)), nil, nil } - // Validate command input for security - if err := security.ValidateCommandInput(command); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid command: %v", err)), nil + if err := security.ValidateCommandInput(in.Command); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid command: %v", err)), nil, nil } - args := []string{"exec", podName, "-n", namespace, "--", command} + args := []string{"exec", in.PodName, "-n", in.Namespace, "--", in.Command} - return k.runKubectlCommand(ctx, request.Header, args...) + res, err := k.runKubectlCommand(ctx, mcp.Header(request), args...) + return res, nil, err } +// noInput is the typed input for tools that take no arguments. +type noInput struct{} + // Get available API resources -func (k *K8sTool) handleGetAvailableAPIResources(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return k.runKubectlCommand(ctx, request.Header, "api-resources") +func (k *K8sTool) handleGetAvailableAPIResources(ctx context.Context, request *mcp.CallToolRequest, _ noInput) (*mcp.CallToolResult, any, error) { + res, err := k.runKubectlCommand(ctx, mcp.Header(request), "api-resources") + return res, nil, err } -// Kubectl describe tool -func (k *K8sTool) handleKubectlDescribeTool(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - resourceType := mcp.ParseString(request, "resource_type", "") - resourceName := mcp.ParseString(request, "resource_name", "") - namespace := mcp.ParseString(request, "namespace", "") +// describeInput is the typed input for k8s_describe_resource. +type describeInput struct { + ResourceType string `json:"resource_type" jsonschema:"Type of resource (deployment, service, pod, node, etc.)"` + ResourceName string `json:"resource_name" jsonschema:"Name of the resource"` + Namespace string `json:"namespace" jsonschema:"Namespace of the resource (optional)"` +} - if resourceType == "" || resourceName == "" { - return mcp.NewToolResultError("resource_type and resource_name parameters are required"), nil +// Kubectl describe tool +func (k *K8sTool) handleKubectlDescribeTool(ctx context.Context, request *mcp.CallToolRequest, in describeInput) (*mcp.CallToolResult, any, error) { + if in.ResourceType == "" || in.ResourceName == "" { + return mcp.NewToolResultError("resource_type and resource_name parameters are required"), nil, nil } - args := []string{"describe", resourceType, resourceName} - if namespace != "" { - args = append(args, "-n", namespace) + args := []string{"describe", in.ResourceType, in.ResourceName} + if in.Namespace != "" { + args = append(args, "-n", in.Namespace) } - return k.runKubectlCommand(ctx, request.Header, args...) + res, err := k.runKubectlCommand(ctx, mcp.Header(request), args...) + return res, nil, err } -// Rollout operations -func (k *K8sTool) handleRollout(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - action := mcp.ParseString(request, "action", "") - resourceType := mcp.ParseString(request, "resource_type", "") - resourceName := mcp.ParseString(request, "resource_name", "") - namespace := mcp.ParseString(request, "namespace", "") +// rolloutInput is the typed input for k8s_rollout. +type rolloutInput struct { + Action string `json:"action" jsonschema:"The rollout action to perform"` + ResourceType string `json:"resource_type" jsonschema:"The type of resource to rollout (e.g., deployment)"` + ResourceName string `json:"resource_name" jsonschema:"The name of the resource to rollout"` + Namespace string `json:"namespace" jsonschema:"The namespace of the resource"` +} - if action == "" || resourceType == "" || resourceName == "" { - return mcp.NewToolResultError("action, resource_type, and resource_name parameters are required"), nil +// Rollout operations +func (k *K8sTool) handleRollout(ctx context.Context, request *mcp.CallToolRequest, in rolloutInput) (*mcp.CallToolResult, any, error) { + if in.Action == "" || in.ResourceType == "" || in.ResourceName == "" { + return mcp.NewToolResultError("action, resource_type, and resource_name parameters are required"), nil, nil } - args := []string{"rollout", action, fmt.Sprintf("%s/%s", resourceType, resourceName)} - if namespace != "" { - args = append(args, "-n", namespace) + args := []string{"rollout", in.Action, fmt.Sprintf("%s/%s", in.ResourceType, in.ResourceName)} + if in.Namespace != "" { + args = append(args, "-n", in.Namespace) } - return k.runKubectlCommand(ctx, request.Header, args...) + res, err := k.runKubectlCommand(ctx, mcp.Header(request), args...) + return res, nil, err } // Get cluster configuration -func (k *K8sTool) handleGetClusterConfiguration(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return k.runKubectlCommand(ctx, request.Header, "config", "view", "-o", "json") +func (k *K8sTool) handleGetClusterConfiguration(ctx context.Context, request *mcp.CallToolRequest, _ noInput) (*mcp.CallToolResult, any, error) { + res, err := k.runKubectlCommand(ctx, mcp.Header(request), "config", "view", "-o", "json") + return res, nil, err } -// Remove annotation -func (k *K8sTool) handleRemoveAnnotation(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - resourceType := mcp.ParseString(request, "resource_type", "") - resourceName := mcp.ParseString(request, "resource_name", "") - annotationKey := mcp.ParseString(request, "annotation_key", "") - namespace := mcp.ParseString(request, "namespace", "") +// removeAnnotationInput is the typed input for k8s_remove_annotation. +type removeAnnotationInput struct { + ResourceType string `json:"resource_type" jsonschema:"The type of resource"` + ResourceName string `json:"resource_name" jsonschema:"The name of the resource"` + AnnotationKey string `json:"annotation_key" jsonschema:"The key of the annotation to remove"` + Namespace string `json:"namespace" jsonschema:"The namespace of the resource"` +} - if resourceType == "" || resourceName == "" || annotationKey == "" { - return mcp.NewToolResultError("resource_type, resource_name, and annotation_key parameters are required"), nil +// Remove annotation +func (k *K8sTool) handleRemoveAnnotation(ctx context.Context, request *mcp.CallToolRequest, in removeAnnotationInput) (*mcp.CallToolResult, any, error) { + if in.ResourceType == "" || in.ResourceName == "" || in.AnnotationKey == "" { + return mcp.NewToolResultError("resource_type, resource_name, and annotation_key parameters are required"), nil, nil } - args := []string{"annotate", resourceType, resourceName, annotationKey + "-"} - if namespace != "" { - args = append(args, "-n", namespace) + args := []string{"annotate", in.ResourceType, in.ResourceName, in.AnnotationKey + "-"} + if in.Namespace != "" { + args = append(args, "-n", in.Namespace) } - return k.runKubectlCommand(ctx, request.Header, args...) + res, err := k.runKubectlCommand(ctx, mcp.Header(request), args...) + return res, nil, err } -// Remove label -func (k *K8sTool) handleRemoveLabel(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - resourceType := mcp.ParseString(request, "resource_type", "") - resourceName := mcp.ParseString(request, "resource_name", "") - labelKey := mcp.ParseString(request, "label_key", "") - namespace := mcp.ParseString(request, "namespace", "") +// removeLabelInput is the typed input for k8s_remove_label. +type removeLabelInput struct { + ResourceType string `json:"resource_type" jsonschema:"The type of resource"` + ResourceName string `json:"resource_name" jsonschema:"The name of the resource"` + LabelKey string `json:"label_key" jsonschema:"The key of the label to remove"` + Namespace string `json:"namespace" jsonschema:"The namespace of the resource"` +} - if resourceType == "" || resourceName == "" || labelKey == "" { - return mcp.NewToolResultError("resource_type, resource_name, and label_key parameters are required"), nil +// Remove label +func (k *K8sTool) handleRemoveLabel(ctx context.Context, request *mcp.CallToolRequest, in removeLabelInput) (*mcp.CallToolResult, any, error) { + if in.ResourceType == "" || in.ResourceName == "" || in.LabelKey == "" { + return mcp.NewToolResultError("resource_type, resource_name, and label_key parameters are required"), nil, nil } - args := []string{"label", resourceType, resourceName, labelKey + "-"} - if namespace != "" { - args = append(args, "-n", namespace) + args := []string{"label", in.ResourceType, in.ResourceName, in.LabelKey + "-"} + if in.Namespace != "" { + args = append(args, "-n", in.Namespace) } - return k.runKubectlCommand(ctx, request.Header, args...) + res, err := k.runKubectlCommand(ctx, mcp.Header(request), args...) + return res, nil, err } -// Annotate resource -func (k *K8sTool) handleAnnotateResource(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - resourceType := mcp.ParseString(request, "resource_type", "") - resourceName := mcp.ParseString(request, "resource_name", "") - annotations := mcp.ParseString(request, "annotations", "") - namespace := mcp.ParseString(request, "namespace", "") +// annotateInput is the typed input for k8s_annotate_resource. +type annotateInput struct { + ResourceType string `json:"resource_type" jsonschema:"The type of resource"` + ResourceName string `json:"resource_name" jsonschema:"The name of the resource"` + Annotations string `json:"annotations" jsonschema:"Space-separated key=value pairs for annotations"` + Namespace string `json:"namespace" jsonschema:"The namespace of the resource"` +} - if resourceType == "" || resourceName == "" || annotations == "" { - return mcp.NewToolResultError("resource_type, resource_name, and annotations parameters are required"), nil +// Annotate resource +func (k *K8sTool) handleAnnotateResource(ctx context.Context, request *mcp.CallToolRequest, in annotateInput) (*mcp.CallToolResult, any, error) { + if in.ResourceType == "" || in.ResourceName == "" || in.Annotations == "" { + return mcp.NewToolResultError("resource_type, resource_name, and annotations parameters are required"), nil, nil } - args := []string{"annotate", resourceType, resourceName} - args = append(args, strings.Fields(annotations)...) + args := []string{"annotate", in.ResourceType, in.ResourceName} + args = append(args, strings.Fields(in.Annotations)...) - if namespace != "" { - args = append(args, "-n", namespace) + if in.Namespace != "" { + args = append(args, "-n", in.Namespace) } - return k.runKubectlCommand(ctx, request.Header, args...) + res, err := k.runKubectlCommand(ctx, mcp.Header(request), args...) + return res, nil, err } -// Label resource -func (k *K8sTool) handleLabelResource(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - resourceType := mcp.ParseString(request, "resource_type", "") - resourceName := mcp.ParseString(request, "resource_name", "") - labels := mcp.ParseString(request, "labels", "") - namespace := mcp.ParseString(request, "namespace", "") +// labelInput is the typed input for k8s_label_resource. +type labelInput struct { + ResourceType string `json:"resource_type" jsonschema:"The type of resource"` + ResourceName string `json:"resource_name" jsonschema:"The name of the resource"` + Labels string `json:"labels" jsonschema:"Space-separated key=value pairs for labels"` + Namespace string `json:"namespace" jsonschema:"The namespace of the resource"` +} - if resourceType == "" || resourceName == "" || labels == "" { - return mcp.NewToolResultError("resource_type, resource_name, and labels parameters are required"), nil +// Label resource +func (k *K8sTool) handleLabelResource(ctx context.Context, request *mcp.CallToolRequest, in labelInput) (*mcp.CallToolResult, any, error) { + if in.ResourceType == "" || in.ResourceName == "" || in.Labels == "" { + return mcp.NewToolResultError("resource_type, resource_name, and labels parameters are required"), nil, nil } - args := []string{"label", resourceType, resourceName} - args = append(args, strings.Fields(labels)...) + args := []string{"label", in.ResourceType, in.ResourceName} + args = append(args, strings.Fields(in.Labels)...) - if namespace != "" { - args = append(args, "-n", namespace) + if in.Namespace != "" { + args = append(args, "-n", in.Namespace) } - return k.runKubectlCommand(ctx, request.Header, args...) + res, err := k.runKubectlCommand(ctx, mcp.Header(request), args...) + return res, nil, err +} + +// createFromURLInput is the typed input for k8s_create_resource_from_url. +type createFromURLInput struct { + URL string `json:"url" jsonschema:"The URL of the manifest"` + Namespace string `json:"namespace" jsonschema:"The namespace to create the resource in"` } // Create resource from URL -func (k *K8sTool) handleCreateResourceFromURL(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - url := mcp.ParseString(request, "url", "") - namespace := mcp.ParseString(request, "namespace", "") +func (k *K8sTool) handleCreateResourceFromURL(ctx context.Context, request *mcp.CallToolRequest, in createFromURLInput) (*mcp.CallToolResult, any, error) { + if in.URL == "" { + return mcp.NewToolResultError("url parameter is required"), nil, nil + } + + args := []string{"create", "-f", in.URL} + if in.Namespace != "" { + args = append(args, "-n", in.Namespace) + } + + res, err := k.runKubectlCommand(ctx, mcp.Header(request), args...) + return res, nil, err +} - if url == "" { - return mcp.NewToolResultError("url parameter is required"), nil +// getResourceYAMLInput is the typed input for k8s_get_resource_yaml. +type getResourceYAMLInput struct { + ResourceType string `json:"resource_type" jsonschema:"Type of resource"` + ResourceName string `json:"resource_name" jsonschema:"Name of the resource"` + Namespace string `json:"namespace" jsonschema:"Namespace of the resource (optional)"` +} + +// Get resource YAML +func (k *K8sTool) handleGetResourceYAML(ctx context.Context, request *mcp.CallToolRequest, in getResourceYAMLInput) (*mcp.CallToolResult, any, error) { + if in.ResourceType == "" || in.ResourceName == "" { + return mcp.NewToolResultError("resource_type and resource_name are required"), nil, nil + } + + args := []string{"get", in.ResourceType, in.ResourceName, "-o", "yaml"} + if in.Namespace != "" { + args = append(args, "-n", in.Namespace) + } + + res, err := k.runKubectlCommand(ctx, mcp.Header(request), args...) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Get YAML command failed: %v", err)), nil, nil + } + return res, nil, nil +} + +// createResourceInput is the typed input for k8s_create_resource. +type createResourceInput struct { + YAMLContent string `json:"yaml_content" jsonschema:"YAML content of the resource"` +} + +// Create resource from YAML content +func (k *K8sTool) handleCreateResource(ctx context.Context, request *mcp.CallToolRequest, in createResourceInput) (*mcp.CallToolResult, any, error) { + if in.YAMLContent == "" { + return mcp.NewToolResultError("yaml_content is required"), nil, nil + } + + tmpFile, err := os.CreateTemp("", "k8s-resource-*.yaml") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to create temp file: %v", err)), nil, nil } + defer os.Remove(tmpFile.Name()) - args := []string{"create", "-f", url} - if namespace != "" { - args = append(args, "-n", namespace) + if _, err := tmpFile.WriteString(in.YAMLContent); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to write to temp file: %v", err)), nil, nil } + tmpFile.Close() - return k.runKubectlCommand(ctx, request.Header, args...) + res, err := k.runKubectlCommand(ctx, mcp.Header(request), "create", "-f", tmpFile.Name()) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Create command failed: %v", err)), nil, nil + } + return res, nil, nil } // Resource generation embeddings @@ -530,23 +726,25 @@ var ( resourceTypes = maps.Keys(resourceMap) ) -// Generate resource using LLM -func (k *K8sTool) handleGenerateResource(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - resourceType := mcp.ParseString(request, "resource_type", "") - resourceDescription := mcp.ParseString(request, "resource_description", "") +// generateResourceInput is the typed input for k8s_generate_resource. +type generateResourceInput struct { + ResourceDescription string `json:"resource_description" jsonschema:"Detailed description of the resource to generate"` + ResourceType string `json:"resource_type" jsonschema:"Type of resource to generate"` +} - if resourceType == "" || resourceDescription == "" { - return mcp.NewToolResultError("resource_type and resource_description parameters are required"), nil +// Generate resource using LLM +func (k *K8sTool) handleGenerateResource(ctx context.Context, request *mcp.CallToolRequest, in generateResourceInput) (*mcp.CallToolResult, any, error) { + if in.ResourceType == "" || in.ResourceDescription == "" { + return mcp.NewToolResultError("resource_type and resource_description parameters are required"), nil, nil } - systemPrompt, ok := resourceMap[resourceType] + systemPrompt, ok := resourceMap[in.ResourceType] if !ok { - return mcp.NewToolResultError(fmt.Sprintf("resource type %s not found", resourceType)), nil + return mcp.NewToolResultError(fmt.Sprintf("resource type %s not found", in.ResourceType)), nil, nil } - // Use the injected LLM model if available, otherwise create a new OpenAI instance if k.llmModel == nil { - return mcp.NewToolResultError("No LLM client present, can't generate resource"), nil + return mcp.NewToolResultError("No LLM client present, can't generate resource"), nil, nil } llm := k.llmModel @@ -560,24 +758,23 @@ func (k *K8sTool) handleGenerateResource(ctx context.Context, request mcp.CallTo { Role: llms.ChatMessageTypeHuman, Parts: []llms.ContentPart{ - llms.TextContent{Text: resourceDescription}, + llms.TextContent{Text: in.ResourceDescription}, }, }, } resp, err := llm.GenerateContent(ctx, contents, llms.WithModel("gpt-4o-mini")) if err != nil { - return mcp.NewToolResultError("failed to generate content: " + err.Error()), nil + return mcp.NewToolResultError("failed to generate content: " + err.Error()), nil, nil } choices := resp.Choices if len(choices) < 1 { - return mcp.NewToolResultError("empty response from model"), nil + return mcp.NewToolResultError("empty response from model"), nil, nil } - c1 := choices[0] - responseText := c1.Content + responseText := choices[0].Content - return mcp.NewToolResultText(responseText), nil + return mcp.NewToolResultText(responseText), nil, nil } // extractBearerToken extracts the Bearer token from the Authorization header @@ -641,207 +838,126 @@ func (k *K8sTool) runKubectlCommandWithTimeout(ctx context.Context, headers http return mcp.NewToolResultText(output), nil } -// RegisterK8sTools registers all k8s tools with the MCP server -func RegisterTools(s *server.MCPServer, llm llms.Model, kubeconfig string, readOnly bool) { +// RegisterTools registers all k8s tools with the MCP server +func RegisterTools(s *mcp.Server, llm llms.Model, kubeconfig string, readOnly bool) { k8sTool := NewK8sToolWithConfig(kubeconfig, llm) // Read-only tools - always registered - s.AddTool(mcp.NewTool("k8s_get_resources", - mcp.WithDescription("Get Kubernetes resources using kubectl"), - mcp.WithString("resource_type", mcp.Description("Type of resource (pod, service, deployment, etc.)"), mcp.Required()), - mcp.WithString("resource_name", mcp.Description("Name of specific resource (optional)")), - mcp.WithString("namespace", mcp.Description("Namespace to query (optional)")), - mcp.WithString("all_namespaces", mcp.Description("Query all namespaces (true/false)")), - mcp.WithString("output", mcp.Description("Output format (json, yaml, wide)"), mcp.DefaultString("wide")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_get_resources", k8sTool.handleKubectlGetEnhanced))) - - s.AddTool(mcp.NewTool("k8s_get_pod_logs", - mcp.WithDescription("Get logs from a Kubernetes pod"), - mcp.WithString("pod_name", mcp.Description("Name of the pod"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("Namespace of the pod (default: default)")), - mcp.WithString("container", mcp.Description("Container name (for multi-container pods)")), - mcp.WithNumber("tail_lines", mcp.Description("Number of lines to show from the end (default: 50)")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_get_pod_logs", k8sTool.handleKubectlLogsEnhanced))) - - s.AddTool(mcp.NewTool("k8s_get_events", - mcp.WithDescription("Get events from a Kubernetes namespace"), - mcp.WithString("namespace", mcp.Description("Namespace to get events from (default: default)")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_get_events", k8sTool.handleGetEvents))) - - s.AddTool(mcp.NewTool("k8s_get_available_api_resources", - mcp.WithDescription("Get available Kubernetes API resources"), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_get_available_api_resources", k8sTool.handleGetAvailableAPIResources))) - - s.AddTool(mcp.NewTool("k8s_get_cluster_configuration", - mcp.WithDescription("Get cluster configuration details"), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_get_cluster_configuration", k8sTool.handleGetClusterConfiguration))) - - s.AddTool(mcp.NewTool("k8s_get_resource_yaml", - mcp.WithDescription("Get the YAML representation of a Kubernetes resource"), - mcp.WithString("resource_type", mcp.Description("Type of resource"), mcp.Required()), - mcp.WithString("resource_name", mcp.Description("Name of the resource"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("Namespace of the resource (optional)")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_get_resource_yaml", func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - resourceType := mcp.ParseString(request, "resource_type", "") - resourceName := mcp.ParseString(request, "resource_name", "") - namespace := mcp.ParseString(request, "namespace", "") - - if resourceType == "" || resourceName == "" { - return mcp.NewToolResultError("resource_type and resource_name are required"), nil - } - - args := []string{"get", resourceType, resourceName, "-o", "yaml"} - if namespace != "" { - args = append(args, "-n", namespace) - } - - result, err := k8sTool.runKubectlCommand(ctx, request.Header, args...) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Get YAML command failed: %v", err)), nil - } - - return result, nil - }))) - - s.AddTool(mcp.NewTool("k8s_describe_resource", - mcp.WithDescription("Describe a Kubernetes resource in detail"), - mcp.WithString("resource_type", mcp.Description("Type of resource (deployment, service, pod, node, etc.)"), mcp.Required()), - mcp.WithString("resource_name", mcp.Description("Name of the resource"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("Namespace of the resource (optional)")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_describe_resource", k8sTool.handleKubectlDescribeTool))) - - s.AddTool(mcp.NewTool("k8s_generate_resource", - mcp.WithDescription("Generate a Kubernetes resource YAML from a description"), - mcp.WithString("resource_description", mcp.Description("Detailed description of the resource to generate"), mcp.Required()), - mcp.WithString("resource_type", mcp.Description(fmt.Sprintf("Type of resource to generate (%s)", strings.Join(slices.Collect(resourceTypes), ", "))), mcp.Required()), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_generate_resource", k8sTool.handleGenerateResource))) + mcp.AddTool(s, "k8s", &mcp.Tool{ + Name: "k8s_get_resources", + Description: "Get Kubernetes resources using kubectl", + }, k8sTool.handleKubectlGetEnhanced) + + mcp.AddTool(s, "k8s", &mcp.Tool{ + Name: "k8s_get_pod_logs", + Description: "Get logs from a Kubernetes pod", + }, k8sTool.handleKubectlLogsEnhanced) + + mcp.AddTool(s, "k8s", &mcp.Tool{ + Name: "k8s_get_events", + Description: "Get events from a Kubernetes namespace", + }, k8sTool.handleGetEvents) + + mcp.AddTool(s, "k8s", &mcp.Tool{ + Name: "k8s_get_available_api_resources", + Description: "Get available Kubernetes API resources", + }, k8sTool.handleGetAvailableAPIResources) + + mcp.AddTool(s, "k8s", &mcp.Tool{ + Name: "k8s_get_cluster_configuration", + Description: "Get cluster configuration details", + }, k8sTool.handleGetClusterConfiguration) + + mcp.AddTool(s, "k8s", &mcp.Tool{ + Name: "k8s_get_resource_yaml", + Description: "Get the YAML representation of a Kubernetes resource", + }, k8sTool.handleGetResourceYAML) + + mcp.AddTool(s, "k8s", &mcp.Tool{ + Name: "k8s_describe_resource", + Description: "Describe a Kubernetes resource in detail", + }, k8sTool.handleKubectlDescribeTool) + + mcp.AddTool(s, "k8s", &mcp.Tool{ + Name: "k8s_wait", + Description: "Wait for a condition on Kubernetes resources (kubectl wait). Blocks until the condition is met or the timeout elapses.", + }, k8sTool.handleKubectlWait) + + mcp.AddTool(s, "k8s", &mcp.Tool{ + Name: "k8s_generate_resource", + Description: fmt.Sprintf("Generate a Kubernetes resource YAML from a description. Supported resource_type values: %s", strings.Join(slices.Collect(resourceTypes), ", ")), + }, k8sTool.handleGenerateResource) // Write tools - only registered when write operations are enabled if !readOnly { - s.AddTool(mcp.NewTool("k8s_scale", - mcp.WithDescription("Scale a Kubernetes deployment"), - mcp.WithString("name", mcp.Description("Name of the deployment"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("Namespace of the deployment (default: default)")), - mcp.WithNumber("replicas", mcp.Description("Number of replicas"), mcp.Required()), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_scale", k8sTool.handleScaleDeployment))) - - s.AddTool(mcp.NewTool("k8s_patch_resource", - mcp.WithDescription("Patch a Kubernetes resource. Defaults to a strategic merge patch, which is only supported for built-in types; set patch_type to \"merge\" (or \"json\") to patch a CustomResource/CRD."), - mcp.WithString("resource_type", mcp.Description("Type of resource (deployment, service, etc.)"), mcp.Required()), - mcp.WithString("resource_name", mcp.Description("Name of the resource"), mcp.Required()), - mcp.WithString("patch", mcp.Description("JSON patch to apply"), mcp.Required()), - mcp.WithString("patch_type", mcp.Description("Patch strategy: \"strategic\" (default; built-in Kubernetes types only), \"merge\" (RFC 7386 JSON merge patch; required for CustomResources/CRDs), or \"json\" (RFC 6902 JSON patch).")), - mcp.WithString("namespace", mcp.Description("Namespace of the resource (default: default)")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_patch_resource", k8sTool.handlePatchResource))) - - s.AddTool(mcp.NewTool("k8s_patch_status", - mcp.WithDescription("Patch the status of a Kubernetes resource"), - mcp.WithString("resource_type", mcp.Description("Type of resource (deployment, service, etc.)"), mcp.Required()), - mcp.WithString("resource_name", mcp.Description("Name of the resource"), mcp.Required()), - mcp.WithString("patch", mcp.Description("JSON/YAML status patch"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("Namespace of the resource (default: default)")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_patch_status", k8sTool.handlePatchStatus))) - - s.AddTool(mcp.NewTool("k8s_apply_manifest", - mcp.WithDescription("Apply a YAML manifest to the Kubernetes cluster"), - mcp.WithString("manifest", mcp.Description("YAML manifest content"), mcp.Required()), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_apply_manifest", k8sTool.handleApplyManifest))) - - s.AddTool(mcp.NewTool("k8s_delete_resource", - mcp.WithDescription("Delete a Kubernetes resource"), - mcp.WithString("resource_type", mcp.Description("Type of resource (pod, service, deployment, etc.)"), mcp.Required()), - mcp.WithString("resource_name", mcp.Description("Name of the resource"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("Namespace of the resource (default: default)")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_delete_resource", k8sTool.handleDeleteResource))) - - s.AddTool(mcp.NewTool("k8s_check_service_connectivity", - mcp.WithDescription("Check connectivity to a service using a temporary curl pod"), - mcp.WithString("service_name", mcp.Description("Service name to test (e.g., my-service.my-namespace.svc.cluster.local:80)"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("Namespace to run the check from (default: default)")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_check_service_connectivity", k8sTool.handleCheckServiceConnectivity))) - - s.AddTool(mcp.NewTool("k8s_execute_command", - mcp.WithDescription("Execute a command in a Kubernetes pod"), - mcp.WithString("pod_name", mcp.Description("Name of the pod to execute in"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("Namespace of the pod (default: default)")), - mcp.WithString("container", mcp.Description("Container name (for multi-container pods)")), - mcp.WithString("command", mcp.Description("Command to execute"), mcp.Required()), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_execute_command", k8sTool.handleExecCommand))) - - s.AddTool(mcp.NewTool("k8s_rollout", - mcp.WithDescription("Perform rollout operations on Kubernetes resources (history, pause, restart, resume, status, undo)"), - mcp.WithString("action", mcp.Description("The rollout action to perform"), mcp.Required()), - mcp.WithString("resource_type", mcp.Description("The type of resource to rollout (e.g., deployment)"), mcp.Required()), - mcp.WithString("resource_name", mcp.Description("The name of the resource to rollout"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("The namespace of the resource")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_rollout", k8sTool.handleRollout))) - - s.AddTool(mcp.NewTool("k8s_label_resource", - mcp.WithDescription("Add or update labels on a Kubernetes resource"), - mcp.WithString("resource_type", mcp.Description("The type of resource"), mcp.Required()), - mcp.WithString("resource_name", mcp.Description("The name of the resource"), mcp.Required()), - mcp.WithString("labels", mcp.Description("Space-separated key=value pairs for labels"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("The namespace of the resource")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_label_resource", k8sTool.handleLabelResource))) - - s.AddTool(mcp.NewTool("k8s_annotate_resource", - mcp.WithDescription("Add or update annotations on a Kubernetes resource"), - mcp.WithString("resource_type", mcp.Description("The type of resource"), mcp.Required()), - mcp.WithString("resource_name", mcp.Description("The name of the resource"), mcp.Required()), - mcp.WithString("annotations", mcp.Description("Space-separated key=value pairs for annotations"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("The namespace of the resource")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_annotate_resource", k8sTool.handleAnnotateResource))) - - s.AddTool(mcp.NewTool("k8s_remove_annotation", - mcp.WithDescription("Remove an annotation from a Kubernetes resource"), - mcp.WithString("resource_type", mcp.Description("The type of resource"), mcp.Required()), - mcp.WithString("resource_name", mcp.Description("The name of the resource"), mcp.Required()), - mcp.WithString("annotation_key", mcp.Description("The key of the annotation to remove"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("The namespace of the resource")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_remove_annotation", k8sTool.handleRemoveAnnotation))) - - s.AddTool(mcp.NewTool("k8s_remove_label", - mcp.WithDescription("Remove a label from a Kubernetes resource"), - mcp.WithString("resource_type", mcp.Description("The type of resource"), mcp.Required()), - mcp.WithString("resource_name", mcp.Description("The name of the resource"), mcp.Required()), - mcp.WithString("label_key", mcp.Description("The key of the label to remove"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("The namespace of the resource")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_remove_label", k8sTool.handleRemoveLabel))) - - s.AddTool(mcp.NewTool("k8s_create_resource", - mcp.WithDescription("Create a Kubernetes resource from YAML content"), - mcp.WithString("yaml_content", mcp.Description("YAML content of the resource"), mcp.Required()), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_create_resource", func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - yamlContent := mcp.ParseString(request, "yaml_content", "") - - if yamlContent == "" { - return mcp.NewToolResultError("yaml_content is required"), nil - } - - // Create temporary file - tmpFile, err := os.CreateTemp("", "k8s-resource-*.yaml") - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to create temp file: %v", err)), nil - } - defer os.Remove(tmpFile.Name()) - - if _, err := tmpFile.WriteString(yamlContent); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to write to temp file: %v", err)), nil - } - tmpFile.Close() - - result, err := k8sTool.runKubectlCommand(ctx, request.Header, "create", "-f", tmpFile.Name()) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Create command failed: %v", err)), nil - } - - return result, nil - }))) - - s.AddTool(mcp.NewTool("k8s_create_resource_from_url", - mcp.WithDescription("Create a Kubernetes resource from a URL pointing to a YAML manifest"), - mcp.WithString("url", mcp.Description("The URL of the manifest"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("The namespace to create the resource in")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_create_resource_from_url", k8sTool.handleCreateResourceFromURL))) + mcp.AddTool(s, "k8s", &mcp.Tool{ + Name: "k8s_scale", + Description: "Scale a Kubernetes deployment", + }, k8sTool.handleScaleDeployment) + + mcp.AddTool(s, "k8s", &mcp.Tool{ + Name: "k8s_patch_resource", + Description: "Patch a Kubernetes resource. Defaults to a strategic merge patch, which is only supported for built-in types; set patch_type to \"merge\" (or \"json\") to patch a CustomResource/CRD.", + }, k8sTool.handlePatchResource) + + mcp.AddTool(s, "k8s", &mcp.Tool{ + Name: "k8s_patch_status", + Description: "Patch the status of a Kubernetes resource", + }, k8sTool.handlePatchStatus) + + mcp.AddTool(s, "k8s", &mcp.Tool{ + Name: "k8s_apply_manifest", + Description: "Apply a YAML manifest to the Kubernetes cluster", + }, k8sTool.handleApplyManifest) + + mcp.AddTool(s, "k8s", &mcp.Tool{ + Name: "k8s_delete_resource", + Description: "Delete a Kubernetes resource", + }, k8sTool.handleDeleteResource) + + mcp.AddTool(s, "k8s", &mcp.Tool{ + Name: "k8s_check_service_connectivity", + Description: "Check connectivity to a service using a temporary curl pod", + }, k8sTool.handleCheckServiceConnectivity) + + mcp.AddTool(s, "k8s", &mcp.Tool{ + Name: "k8s_execute_command", + Description: "Execute a command in a Kubernetes pod", + }, k8sTool.handleExecCommand) + + mcp.AddTool(s, "k8s", &mcp.Tool{ + Name: "k8s_rollout", + Description: "Perform rollout operations on Kubernetes resources (history, pause, restart, resume, status, undo)", + }, k8sTool.handleRollout) + + mcp.AddTool(s, "k8s", &mcp.Tool{ + Name: "k8s_label_resource", + Description: "Add or update labels on a Kubernetes resource", + }, k8sTool.handleLabelResource) + + mcp.AddTool(s, "k8s", &mcp.Tool{ + Name: "k8s_annotate_resource", + Description: "Add or update annotations on a Kubernetes resource", + }, k8sTool.handleAnnotateResource) + + mcp.AddTool(s, "k8s", &mcp.Tool{ + Name: "k8s_remove_annotation", + Description: "Remove an annotation from a Kubernetes resource", + }, k8sTool.handleRemoveAnnotation) + + mcp.AddTool(s, "k8s", &mcp.Tool{ + Name: "k8s_remove_label", + Description: "Remove a label from a Kubernetes resource", + }, k8sTool.handleRemoveLabel) + + mcp.AddTool(s, "k8s", &mcp.Tool{ + Name: "k8s_create_resource", + Description: "Create a Kubernetes resource from YAML content", + }, k8sTool.handleCreateResource) + + mcp.AddTool(s, "k8s", &mcp.Tool{ + Name: "k8s_create_resource_from_url", + Description: "Create a Kubernetes resource from a URL pointing to a YAML manifest", + }, k8sTool.handleCreateResourceFromURL) } } diff --git a/pkg/k8s/k8s_test.go b/pkg/k8s/k8s_test.go index 6c008a20..c292cb36 100644 --- a/pkg/k8s/k8s_test.go +++ b/pkg/k8s/k8s_test.go @@ -6,8 +6,7 @@ import ( "testing" "github.com/kagent-dev/tools/internal/cmd" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" + mcp "github.com/kagent-dev/tools/internal/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/tmc/langchaingo/llms" @@ -15,11 +14,11 @@ import ( func TestRegisterTools(t *testing.T) { t.Run("read-write", func(t *testing.T) { - s := server.NewMCPServer("test", "v0.0.1") + s := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "v0.0.1"}, nil) RegisterTools(s, nil, "", false) }) t.Run("read-only", func(t *testing.T) { - s := server.NewMCPServer("test", "v0.0.1") + s := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "v0.0.1"}, nil) RegisterTools(s, nil, "/tmp/kubeconfig", true) }) } @@ -51,7 +50,7 @@ func getResultText(result *mcp.CallToolResult) string { if result == nil || len(result.Content) == 0 { return "" } - if textContent, ok := result.Content[0].(mcp.TextContent); ok { + if textContent, ok := result.Content[0].(*mcp.TextContent); ok { return textContent.Text } return "" @@ -65,11 +64,8 @@ func headerWithBearerToken(token string) http.Header { } // Helper function to create a CallToolRequest with Bearer token -func requestWithBearerToken(token string, args map[string]interface{}) mcp.CallToolRequest { - req := mcp.CallToolRequest{} - req.Header = headerWithBearerToken(token) - req.Params.Arguments = args - return req +func requestWithBearerToken(token string) *mcp.CallToolRequest { + return &mcp.CallToolRequest{Extra: &mcp.RequestExtra{Header: headerWithBearerToken(token)}} } func TestHandleGetAvailableAPIResources(t *testing.T) { @@ -85,8 +81,8 @@ services svc v1 k8sTool := newTestK8sTool() - req := mcp.CallToolRequest{} - result, err := k8sTool.handleGetAvailableAPIResources(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handleGetAvailableAPIResources(ctx, req, noInput{}) assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -103,8 +99,8 @@ services svc v1 k8sTool := newTestK8sTool() - req := mcp.CallToolRequest{} - result, err := k8sTool.handleGetAvailableAPIResources(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handleGetAvailableAPIResources(ctx, req, noInput{}) assert.NoError(t, err) // MCP handlers should not return Go errors assert.NotNil(t, result) assert.True(t, result.IsError) @@ -122,13 +118,8 @@ func TestHandleScaleDeployment(t *testing.T) { k8sTool := newTestK8sTool() - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{ - "name": "test-deployment", - "replicas": float64(5), // JSON numbers come as float64 - } - - result, err := k8sTool.handleScaleDeployment(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handleScaleDeployment(ctx, req, scaleInput{Name: "test-deployment", Replicas: 5}) assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -144,13 +135,8 @@ func TestHandleScaleDeployment(t *testing.T) { k8sTool := newTestK8sTool() - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{ - // Missing name parameter (this is the required one) - "replicas": float64(3), - } - - result, err := k8sTool.handleScaleDeployment(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handleScaleDeployment(ctx, req, scaleInput{Replicas: 3}) assert.NoError(t, err) assert.NotNil(t, result) assert.True(t, result.IsError) @@ -169,12 +155,8 @@ func TestHandleScaleDeployment(t *testing.T) { k8sTool := newTestK8sTool() - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{ - "name": "test-deployment", - } - - result, err := k8sTool.handleScaleDeployment(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handleScaleDeployment(ctx, req, scaleInput{Name: "test-deployment"}) assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -201,8 +183,8 @@ func TestHandleGetEvents(t *testing.T) { k8sTool := newTestK8sTool() - req := mcp.CallToolRequest{} - result, err := k8sTool.handleGetEvents(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handleGetEvents(ctx, req, eventsInput{}) assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -219,12 +201,8 @@ func TestHandleGetEvents(t *testing.T) { k8sTool := newTestK8sTool() - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{ - "namespace": "custom-namespace", - } - - result, err := k8sTool.handleGetEvents(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handleGetEvents(ctx, req, eventsInput{Namespace: "custom-namespace"}) assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -240,13 +218,8 @@ func TestHandlePatchResource(t *testing.T) { k8sTool := newTestK8sTool() - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{ - "resource_type": "deployment", - // Missing resource_name and patch - } - - result, err := k8sTool.handlePatchResource(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handlePatchResource(ctx, req, patchResourceInput{ResourceType: "deployment"}) assert.NoError(t, err) assert.NotNil(t, result) assert.True(t, result.IsError) @@ -264,14 +237,8 @@ func TestHandlePatchResource(t *testing.T) { k8sTool := newTestK8sTool() - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{ - "resource_type": "deployment", - "resource_name": "test-deployment", - "patch": `{"spec":{"replicas":5}}`, - } - - result, err := k8sTool.handlePatchResource(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handlePatchResource(ctx, req, patchResourceInput{ResourceType: "deployment", ResourceName: "test-deployment", Patch: `{"spec":{"replicas":5}}`}) assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -288,16 +255,14 @@ func TestHandlePatchResource(t *testing.T) { k8sTool := newTestK8sTool() - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{ - "resource_type": "installers.composition.krateo.io", - "resource_name": "installer", - "patch": `{"spec":{"features":{"composableportal":true}}}`, - "patch_type": "merge", - "namespace": "krateo-system", - } - - result, err := k8sTool.handlePatchResource(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handlePatchResource(ctx, req, patchResourceInput{ + ResourceType: "installers.composition.krateo.io", + ResourceName: "installer", + Patch: `{"spec":{"features":{"composableportal":true}}}`, + PatchType: "merge", + Namespace: "krateo-system", + }) assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -312,15 +277,13 @@ func TestHandlePatchResource(t *testing.T) { k8sTool := newTestK8sTool() - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{ - "resource_type": "deployment", - "resource_name": "test-deployment", - "patch": `{"spec":{"replicas":5}}`, - "patch_type": "bogus", - } - - result, err := k8sTool.handlePatchResource(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handlePatchResource(ctx, req, patchResourceInput{ + ResourceType: "deployment", + ResourceName: "test-deployment", + Patch: `{"spec":{"replicas":5}}`, + PatchType: "bogus", + }) assert.NoError(t, err) assert.NotNil(t, result) assert.True(t, result.IsError) @@ -339,13 +302,8 @@ func TestHandlePatchStatus(t *testing.T) { k8sTool := newTestK8sTool() - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{ - "resource_type": "customresource", - // Missing resource_name and patch - } - - result, err := k8sTool.handlePatchStatus(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handlePatchStatus(ctx, req, patchStatusInput{ResourceType: "customresource"}) assert.NoError(t, err) assert.NotNil(t, result) assert.True(t, result.IsError) @@ -363,14 +321,8 @@ func TestHandlePatchStatus(t *testing.T) { k8sTool := newTestK8sTool() - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{ - "resource_type": "customresource", - "resource_name": "test-resource", - "patch": `{"status":{"phase":"Ready"}}`, - } - - result, err := k8sTool.handlePatchStatus(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handlePatchStatus(ctx, req, patchStatusInput{ResourceType: "customresource", ResourceName: "test-resource", Patch: `{"status":{"phase":"Ready"}}`}) assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -389,13 +341,8 @@ func TestHandleDeleteResource(t *testing.T) { k8sTool := newTestK8sTool() - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{ - "resource_type": "pod", - // Missing resource_name - } - - result, err := k8sTool.handleDeleteResource(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handleDeleteResource(ctx, req, deleteResourceInput{ResourceType: "pod"}) assert.NoError(t, err) assert.NotNil(t, result) assert.True(t, result.IsError) @@ -413,13 +360,8 @@ func TestHandleDeleteResource(t *testing.T) { k8sTool := newTestK8sTool() - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{ - "resource_type": "deployment", - "resource_name": "test-deployment", - } - - result, err := k8sTool.handleDeleteResource(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handleDeleteResource(ctx, req, deleteResourceInput{ResourceType: "deployment", ResourceName: "test-deployment"}) assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -438,10 +380,8 @@ func TestHandleCheckServiceConnectivity(t *testing.T) { k8sTool := newTestK8sTool() - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{} - - result, err := k8sTool.handleCheckServiceConnectivity(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handleCheckServiceConnectivity(ctx, req, serviceConnectivityInput{}) assert.NoError(t, err) assert.NotNil(t, result) assert.True(t, result.IsError) @@ -464,12 +404,8 @@ func TestHandleCheckServiceConnectivity(t *testing.T) { k8sTool := newTestK8sTool() - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{ - "service_name": "test-service.default.svc.cluster.local:80", - } - - result, err := k8sTool.handleCheckServiceConnectivity(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handleCheckServiceConnectivity(ctx, req, serviceConnectivityInput{ServiceName: "test-service.default.svc.cluster.local:80"}) assert.NoError(t, err) assert.NotNil(t, result) // Should attempt connectivity check (may succeed or fail but validates params) @@ -485,13 +421,8 @@ func TestHandleKubectlDescribeTool(t *testing.T) { k8sTool := newTestK8sTool() - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{ - "resource_type": "deployment", - // Missing resource_name - } - - result, err := k8sTool.handleKubectlDescribeTool(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handleKubectlDescribeTool(ctx, req, describeInput{ResourceType: "deployment"}) assert.NoError(t, err) assert.NotNil(t, result) assert.True(t, result.IsError) @@ -511,14 +442,8 @@ Labels: app=test` k8sTool := newTestK8sTool() - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{ - "resource_type": "deployment", - "resource_name": "test-deployment", - "namespace": "default", - } - - result, err := k8sTool.handleKubectlDescribeTool(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handleKubectlDescribeTool(ctx, req, describeInput{ResourceType: "deployment", ResourceName: "test-deployment", Namespace: "default"}) assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -536,8 +461,8 @@ func TestHandleKubectlGetEnhanced(t *testing.T) { ctx := cmd.WithShellExecutor(context.Background(), mock) k8sTool := newTestK8sTool() - req := mcp.CallToolRequest{} - result, err := k8sTool.handleKubectlGetEnhanced(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handleKubectlGetEnhanced(ctx, req, getResourcesInput{}) assert.NoError(t, err) assert.NotNil(t, result) assert.True(t, result.IsError) @@ -554,9 +479,8 @@ func TestHandleKubectlGetEnhanced(t *testing.T) { ctx := cmd.WithShellExecutor(ctx, mock) k8sTool := newTestK8sTool() - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{"resource_type": "pods"} - result, err := k8sTool.handleKubectlGetEnhanced(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handleKubectlGetEnhanced(ctx, req, getResourcesInput{ResourceType: "pods"}) assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -571,8 +495,8 @@ func TestHandleKubectlLogsEnhanced(t *testing.T) { ctx := cmd.WithShellExecutor(context.Background(), mock) k8sTool := newTestK8sTool() - req := mcp.CallToolRequest{} - result, err := k8sTool.handleKubectlLogsEnhanced(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handleKubectlLogsEnhanced(ctx, req, logsInput{}) assert.NoError(t, err) assert.NotNil(t, result) assert.True(t, result.IsError) @@ -590,9 +514,8 @@ log line 2` ctx := cmd.WithShellExecutor(ctx, mock) k8sTool := newTestK8sTool() - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{"pod_name": "test-pod"} - result, err := k8sTool.handleKubectlLogsEnhanced(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handleKubectlLogsEnhanced(ctx, req, logsInput{PodName: "test-pod"}) assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -619,12 +542,8 @@ spec: k8sTool := newTestK8sTool() - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{ - "manifest": manifest, - } - - result, err := k8sTool.handleApplyManifest(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handleApplyManifest(ctx, req, applyManifestInput{Manifest: manifest}) assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -650,12 +569,8 @@ spec: k8sTool := newTestK8sTool() - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{ - // Missing manifest parameter - } - - result, err := k8sTool.handleApplyManifest(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handleApplyManifest(ctx, req, applyManifestInput{}) assert.NoError(t, err) assert.NotNil(t, result) assert.True(t, result.IsError) @@ -681,14 +596,8 @@ drwxr-xr-x 1 root root 4096 Jan 1 12:00 ..` k8sTool := newTestK8sTool() - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{ - "pod_name": "mypod", - "namespace": "default", - "command": "ls -la", - } - - result, err := k8sTool.handleExecCommand(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handleExecCommand(ctx, req, execCommandInput{PodName: "mypod", Namespace: "default", Command: "ls -la"}) assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -710,13 +619,8 @@ drwxr-xr-x 1 root root 4096 Jan 1 12:00 ..` k8sTool := newTestK8sTool() - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{ - "pod_name": "mypod", - // Missing command parameter - } - - result, err := k8sTool.handleExecCommand(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handleExecCommand(ctx, req, execCommandInput{PodName: "mypod"}) assert.NoError(t, err) assert.NotNil(t, result) assert.True(t, result.IsError) @@ -739,15 +643,13 @@ func TestHandleRollout(t *testing.T) { k8sTool := newTestK8sTool() - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{ - "action": "restart", - "resource_type": "deployment", - "resource_name": "myapp", - "namespace": "default", - } - - result, err := k8sTool.handleRollout(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handleRollout(ctx, req, rolloutInput{ + Action: "restart", + ResourceType: "deployment", + ResourceName: "myapp", + Namespace: "default", + }) assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -769,13 +671,8 @@ func TestHandleRollout(t *testing.T) { k8sTool := newTestK8sTool() - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{ - "action": "restart", - // Missing resource_type and resource_name - } - - result, err := k8sTool.handleRollout(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handleRollout(ctx, req, rolloutInput{Action: "restart"}) assert.NoError(t, err) assert.NotNil(t, result) assert.True(t, result.IsError) @@ -831,13 +728,8 @@ spec: k8sTool := newTestK8sToolWithLLM(mockLLM) - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{ - "resource_type": "istio_auth_policy", - "resource_description": "A peer authentication policy for strict mTLS", - } - - result, err := k8sTool.handleGenerateResource(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handleGenerateResource(ctx, req, generateResourceInput{ResourceType: "istio_auth_policy", ResourceDescription: "A peer authentication policy for strict mTLS"}) assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -853,13 +745,8 @@ spec: t.Run("missing parameters", func(t *testing.T) { k8sTool := newTestK8sTool() - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{ - "resource_type": "istio_auth_policy", - // Missing resource_description - } - - result, err := k8sTool.handleGenerateResource(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handleGenerateResource(ctx, req, generateResourceInput{ResourceType: "istio_auth_policy"}) assert.NoError(t, err) assert.NotNil(t, result) assert.True(t, result.IsError) @@ -869,13 +756,8 @@ spec: t.Run("no LLM model", func(t *testing.T) { k8sTool := newTestK8sTool() // No LLM model - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{ - "resource_type": "istio_auth_policy", - "resource_description": "A peer authentication policy for strict mTLS", - } - - result, err := k8sTool.handleGenerateResource(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handleGenerateResource(ctx, req, generateResourceInput{ResourceType: "istio_auth_policy", ResourceDescription: "A peer authentication policy for strict mTLS"}) assert.NoError(t, err) assert.NotNil(t, result) assert.True(t, result.IsError) @@ -891,13 +773,8 @@ spec: k8sTool := newTestK8sToolWithLLM(mockLLM) - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{ - "resource_type": "invalid_resource_type", - "resource_description": "A test resource", - } - - result, err := k8sTool.handleGenerateResource(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handleGenerateResource(ctx, req, generateResourceInput{ResourceType: "invalid_resource_type", ResourceDescription: "A test resource"}) assert.NoError(t, err) assert.NotNil(t, result) assert.True(t, result.IsError) @@ -920,15 +797,13 @@ func TestHandleAnnotateResource(t *testing.T) { k8sTool := newTestK8sTool() - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{ - "resource_type": "deployment", - "resource_name": "test-deployment", - "annotations": "key1=value1 key2=value2", - "namespace": "default", - } - - result, err := k8sTool.handleAnnotateResource(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handleAnnotateResource(ctx, req, annotateInput{ + ResourceType: "deployment", + ResourceName: "test-deployment", + Annotations: "key1=value1 key2=value2", + Namespace: "default", + }) assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -943,13 +818,8 @@ func TestHandleAnnotateResource(t *testing.T) { k8sTool := newTestK8sTool() - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{ - "resource_type": "deployment", - // Missing resource_name and annotations - } - - result, err := k8sTool.handleAnnotateResource(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handleAnnotateResource(ctx, req, annotateInput{ResourceType: "deployment"}) assert.NoError(t, err) assert.NotNil(t, result) assert.True(t, result.IsError) @@ -972,15 +842,13 @@ func TestHandleLabelResource(t *testing.T) { k8sTool := newTestK8sTool() - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{ - "resource_type": "deployment", - "resource_name": "test-deployment", - "labels": "env=prod version=1.0", - "namespace": "default", - } - - result, err := k8sTool.handleLabelResource(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handleLabelResource(ctx, req, labelInput{ + ResourceType: "deployment", + ResourceName: "test-deployment", + Labels: "env=prod version=1.0", + Namespace: "default", + }) assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -995,13 +863,8 @@ func TestHandleLabelResource(t *testing.T) { k8sTool := newTestK8sTool() - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{ - "resource_type": "deployment", - // Missing resource_name and labels - } - - result, err := k8sTool.handleLabelResource(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handleLabelResource(ctx, req, labelInput{ResourceType: "deployment"}) assert.NoError(t, err) assert.NotNil(t, result) assert.True(t, result.IsError) @@ -1024,15 +887,13 @@ func TestHandleRemoveAnnotation(t *testing.T) { k8sTool := newTestK8sTool() - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{ - "resource_type": "deployment", - "resource_name": "test-deployment", - "annotation_key": "key1", - "namespace": "default", - } - - result, err := k8sTool.handleRemoveAnnotation(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handleRemoveAnnotation(ctx, req, removeAnnotationInput{ + ResourceType: "deployment", + ResourceName: "test-deployment", + AnnotationKey: "key1", + Namespace: "default", + }) assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -1047,13 +908,8 @@ func TestHandleRemoveAnnotation(t *testing.T) { k8sTool := newTestK8sTool() - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{ - "resource_type": "deployment", - // Missing resource_name and annotation_key - } - - result, err := k8sTool.handleRemoveAnnotation(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handleRemoveAnnotation(ctx, req, removeAnnotationInput{ResourceType: "deployment"}) assert.NoError(t, err) assert.NotNil(t, result) assert.True(t, result.IsError) @@ -1076,15 +932,13 @@ func TestHandleRemoveLabel(t *testing.T) { k8sTool := newTestK8sTool() - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{ - "resource_type": "deployment", - "resource_name": "test-deployment", - "label_key": "env", - "namespace": "default", - } - - result, err := k8sTool.handleRemoveLabel(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handleRemoveLabel(ctx, req, removeLabelInput{ + ResourceType: "deployment", + ResourceName: "test-deployment", + LabelKey: "env", + Namespace: "default", + }) assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -1099,13 +953,8 @@ func TestHandleRemoveLabel(t *testing.T) { k8sTool := newTestK8sTool() - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{ - "resource_type": "deployment", - // Missing resource_name and label_key - } - - result, err := k8sTool.handleRemoveLabel(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handleRemoveLabel(ctx, req, removeLabelInput{ResourceType: "deployment"}) assert.NoError(t, err) assert.NotNil(t, result) assert.True(t, result.IsError) @@ -1128,13 +977,8 @@ func TestHandleCreateResourceFromURL(t *testing.T) { k8sTool := newTestK8sTool() - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{ - "url": "https://example.com/manifest.yaml", - "namespace": "default", - } - - result, err := k8sTool.handleCreateResourceFromURL(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handleCreateResourceFromURL(ctx, req, createFromURLInput{URL: "https://example.com/manifest.yaml", Namespace: "default"}) assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -1149,12 +993,8 @@ func TestHandleCreateResourceFromURL(t *testing.T) { k8sTool := newTestK8sTool() - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{ - // Missing url parameter - } - - result, err := k8sTool.handleCreateResourceFromURL(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handleCreateResourceFromURL(ctx, req, createFromURLInput{}) assert.NoError(t, err) assert.NotNil(t, result) assert.True(t, result.IsError) @@ -1191,8 +1031,8 @@ users: k8sTool := newTestK8sTool() - req := mcp.CallToolRequest{} - result, err := k8sTool.handleGetClusterConfiguration(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handleGetClusterConfiguration(ctx, req, noInput{}) assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -1214,8 +1054,8 @@ func TestBearerTokenPassthrough(t *testing.T) { ctx := cmd.WithShellExecutor(ctx, mock) k8sTool := newTestK8sToolWithPassthrough(true) - req := requestWithBearerToken("test-token-123", map[string]interface{}{"resource_type": "pods"}) - result, err := k8sTool.handleKubectlGetEnhanced(ctx, req) + req := requestWithBearerToken("test-token-123") + result, _, err := k8sTool.handleKubectlGetEnhanced(ctx, req, getResourcesInput{ResourceType: "pods"}) assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -1235,12 +1075,8 @@ func TestBearerTokenPassthrough(t *testing.T) { ctx := cmd.WithShellExecutor(ctx, mock) k8sTool := newTestK8sToolWithPassthrough(true) - req := requestWithBearerToken("my-auth-token", map[string]interface{}{ - "name": "test-deployment", - "replicas": float64(5), - }) - - result, err := k8sTool.handleScaleDeployment(ctx, req) + req := requestWithBearerToken("my-auth-token") + result, _, err := k8sTool.handleScaleDeployment(ctx, req, scaleInput{Name: "test-deployment", Replicas: 5}) assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -1260,8 +1096,8 @@ log line 2` ctx := cmd.WithShellExecutor(ctx, mock) k8sTool := newTestK8sToolWithPassthrough(true) - req := requestWithBearerToken("logs-token", map[string]interface{}{"pod_name": "test-pod"}) - result, err := k8sTool.handleKubectlLogsEnhanced(ctx, req) + req := requestWithBearerToken("logs-token") + result, _, err := k8sTool.handleKubectlLogsEnhanced(ctx, req, logsInput{PodName: "test-pod"}) assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -1279,12 +1115,8 @@ log line 2` ctx := cmd.WithShellExecutor(ctx, mock) k8sTool := newTestK8sToolWithPassthrough(true) - req := requestWithBearerToken("delete-token", map[string]interface{}{ - "resource_type": "deployment", - "resource_name": "test-deployment", - }) - - result, err := k8sTool.handleDeleteResource(ctx, req) + req := requestWithBearerToken("delete-token") + result, _, err := k8sTool.handleDeleteResource(ctx, req, deleteResourceInput{ResourceType: "deployment", ResourceName: "test-deployment"}) assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -1302,13 +1134,8 @@ log line 2` ctx := cmd.WithShellExecutor(ctx, mock) k8sTool := newTestK8sToolWithPassthrough(true) - req := requestWithBearerToken("patch-token", map[string]interface{}{ - "resource_type": "deployment", - "resource_name": "test-deployment", - "patch": `{"spec":{"replicas":5}}`, - }) - - result, err := k8sTool.handlePatchResource(ctx, req) + req := requestWithBearerToken("patch-token") + result, _, err := k8sTool.handlePatchResource(ctx, req, patchResourceInput{ResourceType: "deployment", ResourceName: "test-deployment", Patch: `{"spec":{"replicas":5}}`}) assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -1326,13 +1153,8 @@ log line 2` ctx := cmd.WithShellExecutor(ctx, mock) k8sTool := newTestK8sToolWithPassthrough(true) - req := requestWithBearerToken("describe-token", map[string]interface{}{ - "resource_type": "deployment", - "resource_name": "test-deployment", - "namespace": "default", - }) - - result, err := k8sTool.handleKubectlDescribeTool(ctx, req) + req := requestWithBearerToken("describe-token") + result, _, err := k8sTool.handleKubectlDescribeTool(ctx, req, describeInput{ResourceType: "deployment", ResourceName: "test-deployment", Namespace: "default"}) assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -1350,14 +1172,8 @@ log line 2` ctx := cmd.WithShellExecutor(ctx, mock) k8sTool := newTestK8sToolWithPassthrough(true) - req := requestWithBearerToken("rollout-token", map[string]interface{}{ - "action": "restart", - "resource_type": "deployment", - "resource_name": "myapp", - "namespace": "default", - }) - - result, err := k8sTool.handleRollout(ctx, req) + req := requestWithBearerToken("rollout-token") + result, _, err := k8sTool.handleRollout(ctx, req, rolloutInput{Action: "restart", ResourceType: "deployment", ResourceName: "myapp", Namespace: "default"}) assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -1375,8 +1191,8 @@ log line 2` ctx := cmd.WithShellExecutor(ctx, mock) k8sTool := newTestK8sToolWithPassthrough(true) - req := requestWithBearerToken("events-token", nil) - result, err := k8sTool.handleGetEvents(ctx, req) + req := requestWithBearerToken("events-token") + result, _, err := k8sTool.handleGetEvents(ctx, req, eventsInput{}) assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -1394,13 +1210,8 @@ log line 2` ctx := cmd.WithShellExecutor(ctx, mock) k8sTool := newTestK8sToolWithPassthrough(true) - req := requestWithBearerToken("exec-token", map[string]interface{}{ - "pod_name": "mypod", - "namespace": "default", - "command": "ls -la", - }) - - result, err := k8sTool.handleExecCommand(ctx, req) + req := requestWithBearerToken("exec-token") + result, _, err := k8sTool.handleExecCommand(ctx, req, execCommandInput{PodName: "mypod", Namespace: "default", Command: "ls -la"}) assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -1418,13 +1229,8 @@ log line 2` ctx := cmd.WithShellExecutor(ctx, mock) k8sTool := newTestK8sToolWithPassthrough(true) - req := requestWithBearerToken("annotate-token", map[string]interface{}{ - "resource_type": "deployment", - "resource_name": "test-deployment", - "annotations": "key1=value1", - }) - - result, err := k8sTool.handleAnnotateResource(ctx, req) + req := requestWithBearerToken("annotate-token") + result, _, err := k8sTool.handleAnnotateResource(ctx, req, annotateInput{ResourceType: "deployment", ResourceName: "test-deployment", Annotations: "key1=value1"}) assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -1442,13 +1248,8 @@ log line 2` ctx := cmd.WithShellExecutor(ctx, mock) k8sTool := newTestK8sToolWithPassthrough(true) - req := requestWithBearerToken("label-token", map[string]interface{}{ - "resource_type": "deployment", - "resource_name": "test-deployment", - "labels": "env=prod", - }) - - result, err := k8sTool.handleLabelResource(ctx, req) + req := requestWithBearerToken("label-token") + result, _, err := k8sTool.handleLabelResource(ctx, req, labelInput{ResourceType: "deployment", ResourceName: "test-deployment", Labels: "env=prod"}) assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -1466,8 +1267,8 @@ log line 2` ctx := cmd.WithShellExecutor(ctx, mock) k8sTool := newTestK8sToolWithPassthrough(true) - req := requestWithBearerToken("api-token", nil) - result, err := k8sTool.handleGetAvailableAPIResources(ctx, req) + req := requestWithBearerToken("api-token") + result, _, err := k8sTool.handleGetAvailableAPIResources(ctx, req, noInput{}) assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -1485,8 +1286,8 @@ log line 2` ctx := cmd.WithShellExecutor(ctx, mock) k8sTool := newTestK8sToolWithPassthrough(true) - req := requestWithBearerToken("config-token", nil) - result, err := k8sTool.handleGetClusterConfiguration(ctx, req) + req := requestWithBearerToken("config-token") + result, _, err := k8sTool.handleGetClusterConfiguration(ctx, req, noInput{}) assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -1504,13 +1305,8 @@ log line 2` ctx := cmd.WithShellExecutor(ctx, mock) k8sTool := newTestK8sToolWithPassthrough(true) - req := requestWithBearerToken("remove-anno-token", map[string]interface{}{ - "resource_type": "deployment", - "resource_name": "test-deployment", - "annotation_key": "key1", - }) - - result, err := k8sTool.handleRemoveAnnotation(ctx, req) + req := requestWithBearerToken("remove-anno-token") + result, _, err := k8sTool.handleRemoveAnnotation(ctx, req, removeAnnotationInput{ResourceType: "deployment", ResourceName: "test-deployment", AnnotationKey: "key1"}) assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -1528,13 +1324,8 @@ log line 2` ctx := cmd.WithShellExecutor(ctx, mock) k8sTool := newTestK8sToolWithPassthrough(true) - req := requestWithBearerToken("remove-label-token", map[string]interface{}{ - "resource_type": "deployment", - "resource_name": "test-deployment", - "label_key": "env", - }) - - result, err := k8sTool.handleRemoveLabel(ctx, req) + req := requestWithBearerToken("remove-label-token") + result, _, err := k8sTool.handleRemoveLabel(ctx, req, removeLabelInput{ResourceType: "deployment", ResourceName: "test-deployment", LabelKey: "env"}) assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -1552,12 +1343,8 @@ log line 2` ctx := cmd.WithShellExecutor(ctx, mock) k8sTool := newTestK8sToolWithPassthrough(true) - req := requestWithBearerToken("url-token", map[string]interface{}{ - "url": "https://example.com/manifest.yaml", - "namespace": "default", - }) - - result, err := k8sTool.handleCreateResourceFromURL(ctx, req) + req := requestWithBearerToken("url-token") + result, _, err := k8sTool.handleCreateResourceFromURL(ctx, req, createFromURLInput{URL: "https://example.com/manifest.yaml", Namespace: "default"}) assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -1580,11 +1367,8 @@ metadata: ctx := cmd.WithShellExecutor(ctx, mock) k8sTool := newTestK8sToolWithPassthrough(true) - req := requestWithBearerToken("apply-token", map[string]interface{}{ - "manifest": manifest, - }) - - result, err := k8sTool.handleApplyManifest(ctx, req) + req := requestWithBearerToken("apply-token") + result, _, err := k8sTool.handleApplyManifest(ctx, req, applyManifestInput{Manifest: manifest}) assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -1597,9 +1381,8 @@ metadata: t.Run("returns error when passthrough true and authorization header missing", func(t *testing.T) { k8sTool := newTestK8sToolWithPassthrough(true) - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{"resource_type": "pods"} - result, err := k8sTool.handleKubectlGetEnhanced(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handleKubectlGetEnhanced(ctx, req, getResourcesInput{ResourceType: "pods"}) assert.NoError(t, err) assert.NotNil(t, result) assert.True(t, result.IsError) @@ -1614,10 +1397,8 @@ metadata: ctx := cmd.WithShellExecutor(ctx, mock) k8sTool := newTestK8sToolWithPassthrough(false) - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{"resource_type": "pods"} - // No Header set on request - result, err := k8sTool.handleKubectlGetEnhanced(ctx, req) + req := &mcp.CallToolRequest{} + result, _, err := k8sTool.handleKubectlGetEnhanced(ctx, req, getResourcesInput{ResourceType: "pods"}) assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -1636,11 +1417,9 @@ metadata: ctx := cmd.WithShellExecutor(ctx, mock) k8sTool := newTestK8sToolWithPassthrough(false) - req := mcp.CallToolRequest{} - req.Header = http.Header{} - req.Header.Set("Authorization", "Basic dXNlcjpwYXNz") - req.Params.Arguments = map[string]interface{}{"resource_type": "pods"} - result, err := k8sTool.handleKubectlGetEnhanced(ctx, req) + req := &mcp.CallToolRequest{Extra: &mcp.RequestExtra{Header: http.Header{}}} + req.Extra.Header.Set("Authorization", "Basic dXNlcjpwYXNz") + result, _, err := k8sTool.handleKubectlGetEnhanced(ctx, req, getResourcesInput{ResourceType: "pods"}) assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) diff --git a/pkg/kubescape/kubescape.go b/pkg/kubescape/kubescape.go index e2227fa4..cf349dcc 100644 --- a/pkg/kubescape/kubescape.go +++ b/pkg/kubescape/kubescape.go @@ -8,12 +8,10 @@ import ( "time" "github.com/kagent-dev/tools/internal/errors" - "github.com/kagent-dev/tools/internal/telemetry" + mcp "github.com/kagent-dev/tools/internal/mcp" helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers" "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" spdxv1beta1 "github.com/kubescape/storage/pkg/generated/clientset/versioned/typed/softwarecomposition/v1beta1" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" corev1 "k8s.io/api/core/v1" apiextensionsclientset "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" k8serrors "k8s.io/apimachinery/pkg/api/errors" @@ -38,6 +36,11 @@ const ( storagePodLabel = "app.kubernetes.io/name=storage" ) +// kubescapeErrResult adapts ToolError to an MCP error result. +func kubescapeErrResult(toolErr *errors.ToolError) *mcp.CallToolResult { + return toolErr.ToMCPResult() +} + // KubescapeTool holds the clients for Kubescape and Kubernetes APIs type KubescapeTool struct { spdxClient spdxv1beta1.SpdxV1beta1Interface @@ -114,14 +117,64 @@ type CheckStatus struct { Details interface{} `json:"details,omitempty"` } +type checkHealthInput struct { + Namespace string `json:"namespace" jsonschema:"Namespace to check (default: kubescape)"` +} + +type listVulnerabilityManifestsInput struct { + Namespace string `json:"namespace" jsonschema:"Filter by namespace (optional, defaults to all namespaces)"` + Level string `json:"level" jsonschema:"Type of manifests to list: 'image', 'workload', or 'both' (default: both)"` +} + +type listVulnerabilitiesInManifestInput struct { + Namespace string `json:"namespace" jsonschema:"Namespace of the manifest (default: kubescape)"` + ManifestName string `json:"manifest_name" jsonschema:"Name of the vulnerability manifest"` +} + +type getVulnerabilityDetailsInput struct { + Namespace string `json:"namespace" jsonschema:"Namespace of the manifest (default: kubescape)"` + ManifestName string `json:"manifest_name" jsonschema:"Name of the vulnerability manifest"` + CveID string `json:"cve_id" jsonschema:"CVE identifier (e.g., CVE-2023-12345)"` +} + +type listConfigurationScansInput struct { + Namespace string `json:"namespace" jsonschema:"Filter by namespace (optional, defaults to all namespaces)"` +} + +type getConfigurationScanInput struct { + Namespace string `json:"namespace" jsonschema:"Namespace of the scan (default: kubescape)"` + ManifestName string `json:"manifest_name" jsonschema:"Name of the configuration scan manifest"` +} + +type listApplicationProfilesInput struct { + Namespace string `json:"namespace" jsonschema:"Filter by namespace (optional, defaults to all namespaces)"` +} + +type getApplicationProfileInput struct { + Namespace string `json:"namespace" jsonschema:"Namespace of the profile"` + Name string `json:"name" jsonschema:"Name of the application profile"` +} + +type listNetworkNeighborhoodsInput struct { + Namespace string `json:"namespace" jsonschema:"Filter by namespace (optional, defaults to all namespaces)"` +} + +type getNetworkNeighborhoodInput struct { + Namespace string `json:"namespace" jsonschema:"Namespace of the network neighborhood"` + Name string `json:"name" jsonschema:"Name of the network neighborhood"` +} + // handleCheckHealth verifies Kubescape operator installation and readiness -func (k *KubescapeTool) handleCheckHealth(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (k *KubescapeTool) handleCheckHealth(ctx context.Context, request *mcp.CallToolRequest, in checkHealthInput) (*mcp.CallToolResult, any, error) { if k.initError != nil { toolErr := errors.NewKubescapeError("check_health", k.initError) - return toolErr.ToMCPResult(), nil + return kubescapeErrResult(toolErr), nil, nil } - namespace := mcp.ParseString(request, "namespace", defaultKubescapeNamespace) + namespace := in.Namespace + if namespace == "" { + namespace = defaultKubescapeNamespace + } result := HealthCheckResult{ Healthy: true, @@ -456,21 +509,24 @@ func (k *KubescapeTool) handleCheckHealth(ctx context.Context, request mcp.CallT content, err := json.MarshalIndent(result, "", " ") if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil, nil } - return mcp.NewToolResultText(string(content)), nil + return mcp.NewToolResultText(string(content)), nil, nil } // handleListVulnerabilityManifests lists vulnerability manifests at image and workload levels -func (k *KubescapeTool) handleListVulnerabilityManifests(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (k *KubescapeTool) handleListVulnerabilityManifests(ctx context.Context, request *mcp.CallToolRequest, in listVulnerabilityManifestsInput) (*mcp.CallToolResult, any, error) { if k.initError != nil { toolErr := errors.NewKubescapeError("list_vulnerability_manifests", k.initError) - return toolErr.ToMCPResult(), nil + return kubescapeErrResult(toolErr), nil, nil } - namespace := mcp.ParseString(request, "namespace", "") - level := mcp.ParseString(request, "level", "both") + namespace := in.Namespace + level := in.Level + if level == "" { + level = "both" + } // Build label selector based on level labelSelector := "" @@ -498,7 +554,7 @@ func (k *KubescapeTool) handleListVulnerabilityManifests(ctx context.Context, re toolErr := errors.NewKubescapeError("list_vulnerability_manifests", err). WithContext("namespace", namespace). WithContext("level", level) - return toolErr.ToMCPResult(), nil + return kubescapeErrResult(toolErr), nil, nil } // Build response @@ -526,24 +582,27 @@ func (k *KubescapeTool) handleListVulnerabilityManifests(ctx context.Context, re content, err := json.MarshalIndent(result, "", " ") if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil, nil } - return mcp.NewToolResultText(string(content)), nil + return mcp.NewToolResultText(string(content)), nil, nil } // handleListVulnerabilitiesInManifest lists all CVEs in a specific manifest -func (k *KubescapeTool) handleListVulnerabilitiesInManifest(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (k *KubescapeTool) handleListVulnerabilitiesInManifest(ctx context.Context, request *mcp.CallToolRequest, in listVulnerabilitiesInManifestInput) (*mcp.CallToolResult, any, error) { if k.initError != nil { toolErr := errors.NewKubescapeError("list_vulnerabilities", k.initError) - return toolErr.ToMCPResult(), nil + return kubescapeErrResult(toolErr), nil, nil } - namespace := mcp.ParseString(request, "namespace", defaultKubescapeNamespace) - manifestName := mcp.ParseString(request, "manifest_name", "") + namespace := in.Namespace + if namespace == "" { + namespace = defaultKubescapeNamespace + } + manifestName := in.ManifestName if manifestName == "" { - return mcp.NewToolResultError("manifest_name parameter is required"), nil + return mcp.NewToolResultError("manifest_name parameter is required"), nil, nil } manifest, err := k.spdxClient.VulnerabilityManifests(namespace).Get(ctx, manifestName, metav1.GetOptions{}) @@ -551,7 +610,7 @@ func (k *KubescapeTool) handleListVulnerabilitiesInManifest(ctx context.Context, toolErr := errors.NewKubescapeError("get_vulnerability_manifest", err). WithContext("namespace", namespace). WithContext("manifest_name", manifestName) - return toolErr.ToMCPResult(), nil + return kubescapeErrResult(toolErr), nil, nil } // Extract vulnerabilities with summary info @@ -598,28 +657,31 @@ func (k *KubescapeTool) handleListVulnerabilitiesInManifest(ctx context.Context, content, err := json.MarshalIndent(result, "", " ") if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil, nil } - return mcp.NewToolResultText(string(content)), nil + return mcp.NewToolResultText(string(content)), nil, nil } // handleGetVulnerabilityDetails gets detailed info about a specific CVE in a manifest -func (k *KubescapeTool) handleGetVulnerabilityDetails(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (k *KubescapeTool) handleGetVulnerabilityDetails(ctx context.Context, request *mcp.CallToolRequest, in getVulnerabilityDetailsInput) (*mcp.CallToolResult, any, error) { if k.initError != nil { toolErr := errors.NewKubescapeError("get_vulnerability_details", k.initError) - return toolErr.ToMCPResult(), nil + return kubescapeErrResult(toolErr), nil, nil } - namespace := mcp.ParseString(request, "namespace", defaultKubescapeNamespace) - manifestName := mcp.ParseString(request, "manifest_name", "") - cveID := mcp.ParseString(request, "cve_id", "") + namespace := in.Namespace + if namespace == "" { + namespace = defaultKubescapeNamespace + } + manifestName := in.ManifestName + cveID := in.CveID if manifestName == "" { - return mcp.NewToolResultError("manifest_name parameter is required"), nil + return mcp.NewToolResultError("manifest_name parameter is required"), nil, nil } if cveID == "" { - return mcp.NewToolResultError("cve_id parameter is required"), nil + return mcp.NewToolResultError("cve_id parameter is required"), nil, nil } manifest, err := k.spdxClient.VulnerabilityManifests(namespace).Get(ctx, manifestName, metav1.GetOptions{}) @@ -627,7 +689,7 @@ func (k *KubescapeTool) handleGetVulnerabilityDetails(ctx context.Context, reque toolErr := errors.NewKubescapeError("get_vulnerability_manifest", err). WithContext("namespace", namespace). WithContext("manifest_name", manifestName) - return toolErr.ToMCPResult(), nil + return kubescapeErrResult(toolErr), nil, nil } // Find matching CVE entries @@ -639,25 +701,25 @@ func (k *KubescapeTool) handleGetVulnerabilityDetails(ctx context.Context, reque } if len(matches) == 0 { - return mcp.NewToolResultError(fmt.Sprintf("CVE %s not found in manifest %s", cveID, manifestName)), nil + return mcp.NewToolResultError(fmt.Sprintf("CVE %s not found in manifest %s", cveID, manifestName)), nil, nil } content, err := json.MarshalIndent(matches, "", " ") if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil, nil } - return mcp.NewToolResultText(string(content)), nil + return mcp.NewToolResultText(string(content)), nil, nil } // handleListConfigurationScans lists configuration security scan results -func (k *KubescapeTool) handleListConfigurationScans(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (k *KubescapeTool) handleListConfigurationScans(ctx context.Context, request *mcp.CallToolRequest, in listConfigurationScansInput) (*mcp.CallToolResult, any, error) { if k.initError != nil { toolErr := errors.NewKubescapeError("list_configuration_scans", k.initError) - return toolErr.ToMCPResult(), nil + return kubescapeErrResult(toolErr), nil, nil } - namespace := mcp.ParseString(request, "namespace", "") + namespace := in.Namespace queryNamespace := metav1.NamespaceAll if namespace != "" { @@ -668,7 +730,7 @@ func (k *KubescapeTool) handleListConfigurationScans(ctx context.Context, reques if err != nil { toolErr := errors.NewKubescapeError("list_configuration_scans", err). WithContext("namespace", namespace) - return toolErr.ToMCPResult(), nil + return kubescapeErrResult(toolErr), nil, nil } configManifests := []map[string]interface{}{} @@ -688,24 +750,27 @@ func (k *KubescapeTool) handleListConfigurationScans(ctx context.Context, reques content, err := json.MarshalIndent(result, "", " ") if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil, nil } - return mcp.NewToolResultText(string(content)), nil + return mcp.NewToolResultText(string(content)), nil, nil } // handleGetConfigurationScan gets details of a specific configuration scan -func (k *KubescapeTool) handleGetConfigurationScan(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (k *KubescapeTool) handleGetConfigurationScan(ctx context.Context, request *mcp.CallToolRequest, in getConfigurationScanInput) (*mcp.CallToolResult, any, error) { if k.initError != nil { toolErr := errors.NewKubescapeError("get_configuration_scan", k.initError) - return toolErr.ToMCPResult(), nil + return kubescapeErrResult(toolErr), nil, nil } - namespace := mcp.ParseString(request, "namespace", defaultKubescapeNamespace) - manifestName := mcp.ParseString(request, "manifest_name", "") + namespace := in.Namespace + if namespace == "" { + namespace = defaultKubescapeNamespace + } + manifestName := in.ManifestName if manifestName == "" { - return mcp.NewToolResultError("manifest_name parameter is required"), nil + return mcp.NewToolResultError("manifest_name parameter is required"), nil, nil } manifest, err := k.spdxClient.WorkloadConfigurationScans(namespace).Get(ctx, manifestName, metav1.GetOptions{}) @@ -713,25 +778,25 @@ func (k *KubescapeTool) handleGetConfigurationScan(ctx context.Context, request toolErr := errors.NewKubescapeError("get_configuration_scan", err). WithContext("namespace", namespace). WithContext("manifest_name", manifestName) - return toolErr.ToMCPResult(), nil + return kubescapeErrResult(toolErr), nil, nil } content, err := json.MarshalIndent(manifest, "", " ") if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil, nil } - return mcp.NewToolResultText(string(content)), nil + return mcp.NewToolResultText(string(content)), nil, nil } // handleListApplicationProfiles lists application profiles showing runtime behavior data -func (k *KubescapeTool) handleListApplicationProfiles(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (k *KubescapeTool) handleListApplicationProfiles(ctx context.Context, request *mcp.CallToolRequest, in listApplicationProfilesInput) (*mcp.CallToolResult, any, error) { if k.initError != nil { toolErr := errors.NewKubescapeError("list_application_profiles", k.initError) - return toolErr.ToMCPResult(), nil + return kubescapeErrResult(toolErr), nil, nil } - namespace := mcp.ParseString(request, "namespace", "") + namespace := in.Namespace queryNamespace := metav1.NamespaceAll if namespace != "" { @@ -742,7 +807,7 @@ func (k *KubescapeTool) handleListApplicationProfiles(ctx context.Context, reque if err != nil { toolErr := errors.NewKubescapeError("list_application_profiles", err). WithContext("namespace", namespace) - return toolErr.ToMCPResult(), nil + return kubescapeErrResult(toolErr), nil, nil } profileList := []map[string]interface{}{} @@ -793,27 +858,27 @@ func (k *KubescapeTool) handleListApplicationProfiles(ctx context.Context, reque content, err := json.MarshalIndent(result, "", " ") if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil, nil } - return mcp.NewToolResultText(string(content)), nil + return mcp.NewToolResultText(string(content)), nil, nil } // handleGetApplicationProfile gets detailed runtime behavior for a specific workload -func (k *KubescapeTool) handleGetApplicationProfile(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (k *KubescapeTool) handleGetApplicationProfile(ctx context.Context, request *mcp.CallToolRequest, in getApplicationProfileInput) (*mcp.CallToolResult, any, error) { if k.initError != nil { toolErr := errors.NewKubescapeError("get_application_profile", k.initError) - return toolErr.ToMCPResult(), nil + return kubescapeErrResult(toolErr), nil, nil } - namespace := mcp.ParseString(request, "namespace", "") - name := mcp.ParseString(request, "name", "") + namespace := in.Namespace + name := in.Name if name == "" { - return mcp.NewToolResultError("name parameter is required"), nil + return mcp.NewToolResultError("name parameter is required"), nil, nil } if namespace == "" { - return mcp.NewToolResultError("namespace parameter is required"), nil + return mcp.NewToolResultError("namespace parameter is required"), nil, nil } profile, err := k.spdxClient.ApplicationProfiles(namespace).Get(ctx, name, metav1.GetOptions{}) @@ -821,7 +886,7 @@ func (k *KubescapeTool) handleGetApplicationProfile(ctx context.Context, request toolErr := errors.NewKubescapeError("get_application_profile", err). WithContext("namespace", namespace). WithContext("name", name) - return toolErr.ToMCPResult(), nil + return kubescapeErrResult(toolErr), nil, nil } // Build detailed response with container behaviors @@ -869,20 +934,20 @@ func (k *KubescapeTool) handleGetApplicationProfile(ctx context.Context, request content, err := json.MarshalIndent(result, "", " ") if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil, nil } - return mcp.NewToolResultText(string(content)), nil + return mcp.NewToolResultText(string(content)), nil, nil } // handleListNetworkNeighborhoods lists network communication patterns for workloads -func (k *KubescapeTool) handleListNetworkNeighborhoods(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (k *KubescapeTool) handleListNetworkNeighborhoods(ctx context.Context, request *mcp.CallToolRequest, in listNetworkNeighborhoodsInput) (*mcp.CallToolResult, any, error) { if k.initError != nil { toolErr := errors.NewKubescapeError("list_network_neighborhoods", k.initError) - return toolErr.ToMCPResult(), nil + return kubescapeErrResult(toolErr), nil, nil } - namespace := mcp.ParseString(request, "namespace", "") + namespace := in.Namespace queryNamespace := metav1.NamespaceAll if namespace != "" { @@ -893,7 +958,7 @@ func (k *KubescapeTool) handleListNetworkNeighborhoods(ctx context.Context, requ if err != nil { toolErr := errors.NewKubescapeError("list_network_neighborhoods", err). WithContext("namespace", namespace) - return toolErr.ToMCPResult(), nil + return kubescapeErrResult(toolErr), nil, nil } neighborhoodList := []map[string]interface{}{} @@ -927,27 +992,27 @@ func (k *KubescapeTool) handleListNetworkNeighborhoods(ctx context.Context, requ content, err := json.MarshalIndent(result, "", " ") if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil, nil } - return mcp.NewToolResultText(string(content)), nil + return mcp.NewToolResultText(string(content)), nil, nil } // handleGetNetworkNeighborhood gets detailed network connections for a specific workload -func (k *KubescapeTool) handleGetNetworkNeighborhood(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (k *KubescapeTool) handleGetNetworkNeighborhood(ctx context.Context, request *mcp.CallToolRequest, in getNetworkNeighborhoodInput) (*mcp.CallToolResult, any, error) { if k.initError != nil { toolErr := errors.NewKubescapeError("get_network_neighborhood", k.initError) - return toolErr.ToMCPResult(), nil + return kubescapeErrResult(toolErr), nil, nil } - namespace := mcp.ParseString(request, "namespace", "") - name := mcp.ParseString(request, "name", "") + namespace := in.Namespace + name := in.Name if name == "" { - return mcp.NewToolResultError("name parameter is required"), nil + return mcp.NewToolResultError("name parameter is required"), nil, nil } if namespace == "" { - return mcp.NewToolResultError("namespace parameter is required"), nil + return mcp.NewToolResultError("namespace parameter is required"), nil, nil } nn, err := k.spdxClient.NetworkNeighborhoods(namespace).Get(ctx, name, metav1.GetOptions{}) @@ -955,7 +1020,7 @@ func (k *KubescapeTool) handleGetNetworkNeighborhood(ctx context.Context, reques toolErr := errors.NewKubescapeError("get_network_neighborhood", err). WithContext("namespace", namespace). WithContext("name", name) - return toolErr.ToMCPResult(), nil + return kubescapeErrResult(toolErr), nil, nil } // Build detailed response with container network data @@ -1032,10 +1097,10 @@ func (k *KubescapeTool) handleGetNetworkNeighborhood(ctx context.Context, reques content, err := json.MarshalIndent(result, "", " ") if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil, nil } - return mcp.NewToolResultText(string(content)), nil + return mcp.NewToolResultText(string(content)), nil, nil } // Helper function to truncate strings @@ -1047,160 +1112,127 @@ func truncateString(s string, maxLen int) string { } // RegisterTools registers all Kubescape tools with the MCP server -func RegisterTools(s *server.MCPServer, kubeconfig string, readOnly bool) { +func RegisterTools(s *mcp.Server, kubeconfig string, readOnly bool) { tool := NewKubescapeTool(kubeconfig) - - // Health check tool - s.AddTool(mcp.NewTool("kubescape_check_health", - mcp.WithDescription("Check if Kubescape operator is installed and operational. Verifies namespace, operator pods, storage pods, CRDs, and scan data availability."), - mcp.WithString("namespace", mcp.Description("Namespace to check (default: kubescape)")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("kubescape_check_health", tool.handleCheckHealth))) - - // List vulnerability manifests - s.AddTool(mcp.NewTool("kubescape_list_vulnerability_manifests", - mcp.WithDescription("List vulnerability manifests from Kubescape operator. Returns vulnerability scan results at image or workload level."), - mcp.WithString("namespace", mcp.Description("Filter by namespace (optional, defaults to all namespaces)")), - mcp.WithString("level", mcp.Description("Type of manifests to list: 'image', 'workload', or 'both' (default: both)")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("kubescape_list_vulnerability_manifests", tool.handleListVulnerabilityManifests))) - - // List vulnerabilities in a manifest - s.AddTool(mcp.NewTool("kubescape_list_vulnerabilities", - mcp.WithDescription("List all CVEs/vulnerabilities found in a specific vulnerability manifest. Returns severity summary and vulnerability details."), - mcp.WithString("namespace", mcp.Description("Namespace of the manifest (default: kubescape)")), - mcp.WithString("manifest_name", mcp.Description("Name of the vulnerability manifest"), mcp.Required()), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("kubescape_list_vulnerabilities", tool.handleListVulnerabilitiesInManifest))) - - // Get detailed vulnerability info - s.AddTool(mcp.NewTool("kubescape_get_vulnerability_details", - mcp.WithDescription("Get detailed information about a specific CVE in a vulnerability manifest, including affected packages and fix information."), - mcp.WithString("namespace", mcp.Description("Namespace of the manifest (default: kubescape)")), - mcp.WithString("manifest_name", mcp.Description("Name of the vulnerability manifest"), mcp.Required()), - mcp.WithString("cve_id", mcp.Description("CVE identifier (e.g., CVE-2023-12345)"), mcp.Required()), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("kubescape_get_vulnerability_details", tool.handleGetVulnerabilityDetails))) - - // List configuration scans - s.AddTool(mcp.NewTool("kubescape_list_configuration_scans", - mcp.WithDescription("List configuration security scan results from Kubescape operator. Shows workloads that have been scanned for security misconfigurations."), - mcp.WithString("namespace", mcp.Description("Filter by namespace (optional, defaults to all namespaces)")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("kubescape_list_configuration_scans", tool.handleListConfigurationScans))) - - // Get configuration scan details - s.AddTool(mcp.NewTool("kubescape_get_configuration_scan", - mcp.WithDescription("Get detailed configuration security scan results for a specific workload, including failed controls and remediation guidance."), - mcp.WithString("namespace", mcp.Description("Namespace of the scan (default: kubescape)")), - mcp.WithString("manifest_name", mcp.Description("Name of the configuration scan manifest"), mcp.Required()), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("kubescape_get_configuration_scan", tool.handleGetConfigurationScan))) - - // List application profiles (runtime observability) - s.AddTool(mcp.NewTool("kubescape_list_application_profiles", - mcp.WithDescription("List ApplicationProfiles showing runtime behavior of workloads. These profiles capture: "+ - "executed processes (Execs), file access patterns (Opens), system calls (Syscalls), Linux capabilities used, and HTTP endpoints. "+ - "Use this data to prioritize vulnerability findings - a CVE in an unused package is lower priority than one in an actively running process. "+ - "Requires 'capabilities.runtimeObservability=enable' in Kubescape Helm chart."), - mcp.WithString("namespace", mcp.Description("Filter by namespace (optional, defaults to all namespaces)")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("kubescape_list_application_profiles", tool.handleListApplicationProfiles))) - - // Get application profile details - s.AddTool(mcp.NewTool("kubescape_get_application_profile", - mcp.WithDescription("Get detailed runtime behavior profile for a specific workload. Shows what processes run, what files are accessed, "+ - "what system calls are made, and what capabilities are used per container. "+ - "Compare with CVE findings to prioritize remediation - focus on vulnerabilities affecting actively used components."), - mcp.WithString("namespace", mcp.Description("Namespace of the profile"), mcp.Required()), - mcp.WithString("name", mcp.Description("Name of the application profile"), mcp.Required()), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("kubescape_get_application_profile", tool.handleGetApplicationProfile))) - - // List network neighborhoods (runtime observability) - s.AddTool(mcp.NewTool("kubescape_list_network_neighborhoods", - mcp.WithDescription("List NetworkNeighborhoods showing actual network communication patterns of workloads. "+ - "These capture: ingress connections (who talks TO the workload), egress connections (who the workload talks TO), "+ - "including DNS names, IP addresses, ports, and protocols. "+ - "Use this to understand attack surface and prioritize network-related security findings. "+ - "Requires 'capabilities.runtimeObservability=enable' in Kubescape Helm chart."), - mcp.WithString("namespace", mcp.Description("Filter by namespace (optional, defaults to all namespaces)")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("kubescape_list_network_neighborhoods", tool.handleListNetworkNeighborhoods))) - - // Get network neighborhood details - s.AddTool(mcp.NewTool("kubescape_get_network_neighborhood", - mcp.WithDescription("Get detailed network connections for a specific workload. Shows all observed ingress and egress traffic "+ - "with DNS names, IPs, ports, and protocols. Use this to verify if a workload with a vulnerability is actually exposed to the network."), - mcp.WithString("namespace", mcp.Description("Namespace of the network neighborhood"), mcp.Required()), - mcp.WithString("name", mcp.Description("Name of the network neighborhood"), mcp.Required()), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("kubescape_get_network_neighborhood", tool.handleGetNetworkNeighborhood))) + _ = readOnly // all kubescape tools are read-only + + mcp.AddTool(s, "kubescape", &mcp.Tool{ + Name: "kubescape_check_health", + Description: "Check if Kubescape operator is installed and operational. Verifies namespace, operator pods, storage pods, CRDs, and scan data availability.", + }, tool.handleCheckHealth) + + mcp.AddTool(s, "kubescape", &mcp.Tool{ + Name: "kubescape_list_vulnerability_manifests", + Description: "List vulnerability manifests from Kubescape operator. Returns vulnerability scan results at image or workload level.", + }, tool.handleListVulnerabilityManifests) + + mcp.AddTool(s, "kubescape", &mcp.Tool{ + Name: "kubescape_list_vulnerabilities", + Description: "List all CVEs/vulnerabilities found in a specific vulnerability manifest. Returns severity summary and vulnerability details.", + }, tool.handleListVulnerabilitiesInManifest) + + mcp.AddTool(s, "kubescape", &mcp.Tool{ + Name: "kubescape_get_vulnerability_details", + Description: "Get detailed information about a specific CVE in a vulnerability manifest, including affected packages and fix information.", + }, tool.handleGetVulnerabilityDetails) + + mcp.AddTool(s, "kubescape", &mcp.Tool{ + Name: "kubescape_list_configuration_scans", + Description: "List configuration security scan results from Kubescape operator. Shows workloads that have been scanned for security misconfigurations.", + }, tool.handleListConfigurationScans) + + mcp.AddTool(s, "kubescape", &mcp.Tool{ + Name: "kubescape_get_configuration_scan", + Description: "Get detailed configuration security scan results for a specific workload, including failed controls and remediation guidance.", + }, tool.handleGetConfigurationScan) + + mcp.AddTool(s, "kubescape", &mcp.Tool{ + Name: "kubescape_list_application_profiles", + Description: "List ApplicationProfiles showing runtime behavior of workloads. These profiles capture: " + + "executed processes (Execs), file access patterns (Opens), system calls (Syscalls), Linux capabilities used, and HTTP endpoints. " + + "Use this data to prioritize vulnerability findings - a CVE in an unused package is lower priority than one in an actively running process. " + + "Requires 'capabilities.runtimeObservability=enable' in Kubescape Helm chart.", + }, tool.handleListApplicationProfiles) + + mcp.AddTool(s, "kubescape", &mcp.Tool{ + Name: "kubescape_get_application_profile", + Description: "Get detailed runtime behavior profile for a specific workload. Shows what processes run, what files are accessed, " + + "what system calls are made, and what capabilities are used per container. " + + "Compare with CVE findings to prioritize remediation - focus on vulnerabilities affecting actively used components.", + }, tool.handleGetApplicationProfile) + + mcp.AddTool(s, "kubescape", &mcp.Tool{ + Name: "kubescape_list_network_neighborhoods", + Description: "List NetworkNeighborhoods showing actual network communication patterns of workloads. " + + "These capture: ingress connections (who talks TO the workload), egress connections (who the workload talks TO), " + + "including DNS names, IP addresses, ports, and protocols. " + + "Use this to understand attack surface and prioritize network-related security findings. " + + "Requires 'capabilities.runtimeObservability=enable' in Kubescape Helm chart.", + }, tool.handleListNetworkNeighborhoods) + + mcp.AddTool(s, "kubescape", &mcp.Tool{ + Name: "kubescape_get_network_neighborhood", + Description: "Get detailed network connections for a specific workload. Shows all observed ingress and egress traffic " + + "with DNS names, IPs, ports, and protocols. Use this to verify if a workload with a vulnerability is actually exposed to the network.", + }, tool.handleGetNetworkNeighborhood) // NOTE: SBOM tools are disabled as they return too much data for LLM context windows. - // SBOMs contain detailed package information that can be very large. - // To enable in the future, uncomment the handlers and tool registrations below. - // - // s.AddTool(mcp.NewTool("kubescape_list_sboms", ...)) - // s.AddTool(mcp.NewTool("kubescape_get_sbom", ...)) } // Interfaces for testing - allows mocking the Kubernetes clients type KubescapeToolInterface interface { - HandleCheckHealth(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) - HandleListVulnerabilityManifests(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) - HandleListVulnerabilitiesInManifest(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) - HandleGetVulnerabilityDetails(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) - HandleListConfigurationScans(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) - HandleGetConfigurationScan(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) - HandleListApplicationProfiles(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) - HandleGetApplicationProfile(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) - HandleListNetworkNeighborhoods(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) - HandleGetNetworkNeighborhood(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) - // NOTE: SBOM handlers are disabled as they return too much data for LLM context - // HandleListSBOMs(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) - // HandleGetSBOM(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) + HandleCheckHealth(ctx context.Context, in checkHealthInput) (*mcp.CallToolResult, any, error) + HandleListVulnerabilityManifests(ctx context.Context, in listVulnerabilityManifestsInput) (*mcp.CallToolResult, any, error) + HandleListVulnerabilitiesInManifest(ctx context.Context, in listVulnerabilitiesInManifestInput) (*mcp.CallToolResult, any, error) + HandleGetVulnerabilityDetails(ctx context.Context, in getVulnerabilityDetailsInput) (*mcp.CallToolResult, any, error) + HandleListConfigurationScans(ctx context.Context, in listConfigurationScansInput) (*mcp.CallToolResult, any, error) + HandleGetConfigurationScan(ctx context.Context, in getConfigurationScanInput) (*mcp.CallToolResult, any, error) + HandleListApplicationProfiles(ctx context.Context, in listApplicationProfilesInput) (*mcp.CallToolResult, any, error) + HandleGetApplicationProfile(ctx context.Context, in getApplicationProfileInput) (*mcp.CallToolResult, any, error) + HandleListNetworkNeighborhoods(ctx context.Context, in listNetworkNeighborhoodsInput) (*mcp.CallToolResult, any, error) + HandleGetNetworkNeighborhood(ctx context.Context, in getNetworkNeighborhoodInput) (*mcp.CallToolResult, any, error) } // Ensure KubescapeTool implements the interface var _ KubescapeToolInterface = (*KubescapeTool)(nil) // Export handler methods for testing -func (k *KubescapeTool) HandleCheckHealth(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return k.handleCheckHealth(ctx, request) +func (k *KubescapeTool) HandleCheckHealth(ctx context.Context, in checkHealthInput) (*mcp.CallToolResult, any, error) { + return k.handleCheckHealth(ctx, &mcp.CallToolRequest{}, in) } -func (k *KubescapeTool) HandleListVulnerabilityManifests(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return k.handleListVulnerabilityManifests(ctx, request) +func (k *KubescapeTool) HandleListVulnerabilityManifests(ctx context.Context, in listVulnerabilityManifestsInput) (*mcp.CallToolResult, any, error) { + return k.handleListVulnerabilityManifests(ctx, &mcp.CallToolRequest{}, in) } -func (k *KubescapeTool) HandleListVulnerabilitiesInManifest(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return k.handleListVulnerabilitiesInManifest(ctx, request) +func (k *KubescapeTool) HandleListVulnerabilitiesInManifest(ctx context.Context, in listVulnerabilitiesInManifestInput) (*mcp.CallToolResult, any, error) { + return k.handleListVulnerabilitiesInManifest(ctx, &mcp.CallToolRequest{}, in) } -func (k *KubescapeTool) HandleGetVulnerabilityDetails(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return k.handleGetVulnerabilityDetails(ctx, request) +func (k *KubescapeTool) HandleGetVulnerabilityDetails(ctx context.Context, in getVulnerabilityDetailsInput) (*mcp.CallToolResult, any, error) { + return k.handleGetVulnerabilityDetails(ctx, &mcp.CallToolRequest{}, in) } -func (k *KubescapeTool) HandleListConfigurationScans(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return k.handleListConfigurationScans(ctx, request) +func (k *KubescapeTool) HandleListConfigurationScans(ctx context.Context, in listConfigurationScansInput) (*mcp.CallToolResult, any, error) { + return k.handleListConfigurationScans(ctx, &mcp.CallToolRequest{}, in) } -func (k *KubescapeTool) HandleGetConfigurationScan(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return k.handleGetConfigurationScan(ctx, request) +func (k *KubescapeTool) HandleGetConfigurationScan(ctx context.Context, in getConfigurationScanInput) (*mcp.CallToolResult, any, error) { + return k.handleGetConfigurationScan(ctx, &mcp.CallToolRequest{}, in) } -func (k *KubescapeTool) HandleListApplicationProfiles(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return k.handleListApplicationProfiles(ctx, request) +func (k *KubescapeTool) HandleListApplicationProfiles(ctx context.Context, in listApplicationProfilesInput) (*mcp.CallToolResult, any, error) { + return k.handleListApplicationProfiles(ctx, &mcp.CallToolRequest{}, in) } -func (k *KubescapeTool) HandleGetApplicationProfile(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return k.handleGetApplicationProfile(ctx, request) +func (k *KubescapeTool) HandleGetApplicationProfile(ctx context.Context, in getApplicationProfileInput) (*mcp.CallToolResult, any, error) { + return k.handleGetApplicationProfile(ctx, &mcp.CallToolRequest{}, in) } -func (k *KubescapeTool) HandleListNetworkNeighborhoods(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return k.handleListNetworkNeighborhoods(ctx, request) +func (k *KubescapeTool) HandleListNetworkNeighborhoods(ctx context.Context, in listNetworkNeighborhoodsInput) (*mcp.CallToolResult, any, error) { + return k.handleListNetworkNeighborhoods(ctx, &mcp.CallToolRequest{}, in) } -func (k *KubescapeTool) HandleGetNetworkNeighborhood(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return k.handleGetNetworkNeighborhood(ctx, request) +func (k *KubescapeTool) HandleGetNetworkNeighborhood(ctx context.Context, in getNetworkNeighborhoodInput) (*mcp.CallToolResult, any, error) { + return k.handleGetNetworkNeighborhood(ctx, &mcp.CallToolRequest{}, in) } - -// NOTE: SBOM handlers are disabled as they return too much data for LLM context -// func (k *KubescapeTool) HandleListSBOMs(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { -// return k.handleListSBOMs(ctx, request) -// } -// -// func (k *KubescapeTool) HandleGetSBOM(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { -// return k.handleGetSBOM(ctx, request) -// } diff --git a/pkg/kubescape/kubescape_test.go b/pkg/kubescape/kubescape_test.go index 2b0bcafe..9b331fa9 100644 --- a/pkg/kubescape/kubescape_test.go +++ b/pkg/kubescape/kubescape_test.go @@ -6,10 +6,9 @@ import ( "errors" "testing" + mcp "github.com/kagent-dev/tools/internal/mcp" "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" kubescapefake "github.com/kubescape/storage/pkg/generated/clientset/versioned/fake" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" @@ -19,62 +18,22 @@ import ( kubefake "k8s.io/client-go/kubernetes/fake" ) -// Helper function to create a CallToolRequest with arguments -func makeRequest(args map[string]interface{}) mcp.CallToolRequest { - request := mcp.CallToolRequest{} - request.Params.Arguments = args - return request -} - // Helper function to extract text content from MCP result func getResultText(result *mcp.CallToolResult) string { if result == nil || len(result.Content) == 0 { return "" } - if textContent, ok := result.Content[0].(mcp.TextContent); ok { + if textContent, ok := result.Content[0].(*mcp.TextContent); ok { return textContent.Text } return "" } func TestRegisterTools(t *testing.T) { - s := server.NewMCPServer("test", "1.0.0") - - // Should not panic + s := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "1.0.0"}, nil) assert.NotPanics(t, func() { RegisterTools(s, "", false) }) - - // Verify tools are registered by checking the server has tools - // NOTE: SBOM tools are disabled (too large for LLM context), so we expect 10 tools - tools := s.ListTools() - assert.Len(t, tools, 10) - - expectedTools := map[string]bool{ - "kubescape_check_health": false, - "kubescape_list_vulnerability_manifests": false, - "kubescape_list_vulnerabilities": false, - "kubescape_get_vulnerability_details": false, - "kubescape_list_configuration_scans": false, - "kubescape_get_configuration_scan": false, - "kubescape_list_application_profiles": false, - "kubescape_get_application_profile": false, - "kubescape_list_network_neighborhoods": false, - "kubescape_get_network_neighborhood": false, - // NOTE: SBOM tools disabled - too large for LLM context - // "kubescape_list_sboms": false, - // "kubescape_get_sbom": false, - } - - for name := range tools { - if _, exists := expectedTools[name]; exists { - expectedTools[name] = true - } - } - - for name, found := range expectedTools { - assert.True(t, found, "Tool %s not found", name) - } } func TestHandleCheckHealth_AllComponentsHealthy(t *testing.T) { @@ -150,7 +109,7 @@ func TestHandleCheckHealth_AllComponentsHealthy(t *testing.T) { tool := NewKubescapeToolWithClients(k8sClient, apiExtClient, spdxClient.SpdxV1beta1()) - result, err := tool.HandleCheckHealth(context.Background(), makeRequest(nil)) + result, _, err := tool.HandleCheckHealth(context.Background(), checkHealthInput{}) require.NoError(t, err) require.NotNil(t, result) @@ -186,7 +145,7 @@ func TestHandleCheckHealth_NamespaceNotFound(t *testing.T) { tool := NewKubescapeToolWithClients(k8sClient, apiExtClient, spdxClient.SpdxV1beta1()) - result, err := tool.HandleCheckHealth(context.Background(), makeRequest(nil)) + result, _, err := tool.HandleCheckHealth(context.Background(), checkHealthInput{}) require.NoError(t, err) require.NotNil(t, result) @@ -211,7 +170,7 @@ func TestHandleCheckHealth_OperatorPodsNotRunning(t *testing.T) { tool := NewKubescapeToolWithClients(k8sClient, apiExtClient, spdxClient.SpdxV1beta1()) - result, err := tool.HandleCheckHealth(context.Background(), makeRequest(nil)) + result, _, err := tool.HandleCheckHealth(context.Background(), checkHealthInput{}) require.NoError(t, err) require.NotNil(t, result) @@ -244,7 +203,7 @@ func TestHandleCheckHealth_OperatorPodsUnhealthy(t *testing.T) { tool := NewKubescapeToolWithClients(k8sClient, apiExtClient, spdxClient.SpdxV1beta1()) - result, err := tool.HandleCheckHealth(context.Background(), makeRequest(nil)) + result, _, err := tool.HandleCheckHealth(context.Background(), checkHealthInput{}) require.NoError(t, err) require.NotNil(t, result) @@ -268,7 +227,7 @@ func TestHandleCheckHealth_VulnerabilityCRDMissing(t *testing.T) { tool := NewKubescapeToolWithClients(k8sClient, apiExtClient, spdxClient.SpdxV1beta1()) - result, err := tool.HandleCheckHealth(context.Background(), makeRequest(nil)) + result, _, err := tool.HandleCheckHealth(context.Background(), checkHealthInput{}) require.NoError(t, err) require.NotNil(t, result) @@ -299,7 +258,7 @@ func TestHandleCheckHealth_NoScanData(t *testing.T) { tool := NewKubescapeToolWithClients(k8sClient, apiExtClient, spdxClient.SpdxV1beta1()) - result, err := tool.HandleCheckHealth(context.Background(), makeRequest(nil)) + result, _, err := tool.HandleCheckHealth(context.Background(), checkHealthInput{}) require.NoError(t, err) require.NotNil(t, result) @@ -331,7 +290,7 @@ func TestHandleCheckHealth_RuntimeObservabilityCRDsMissing(t *testing.T) { tool := NewKubescapeToolWithClients(k8sClient, apiExtClient, spdxClient.SpdxV1beta1()) - result, err := tool.HandleCheckHealth(context.Background(), makeRequest(nil)) + result, _, err := tool.HandleCheckHealth(context.Background(), checkHealthInput{}) require.NoError(t, err) require.NotNil(t, result) @@ -381,9 +340,9 @@ func TestHandleCheckHealth_CustomNamespace(t *testing.T) { tool := NewKubescapeToolWithClients(k8sClient, apiExtClient, spdxClient.SpdxV1beta1()) - result, err := tool.HandleCheckHealth(context.Background(), makeRequest(map[string]interface{}{ - "namespace": "custom-ns", - })) + result, _, err := tool.HandleCheckHealth(context.Background(), checkHealthInput{ + Namespace: "custom-ns", + }) require.NoError(t, err) require.NotNil(t, result) @@ -398,7 +357,7 @@ func TestHandleCheckHealth_CustomNamespace(t *testing.T) { func TestHandleCheckHealth_InitError(t *testing.T) { tool := NewKubescapeToolWithError(errors.New("failed to connect")) - result, err := tool.HandleCheckHealth(context.Background(), makeRequest(nil)) + result, _, err := tool.HandleCheckHealth(context.Background(), checkHealthInput{}) require.NoError(t, err) require.NotNil(t, result) assert.True(t, result.IsError) @@ -433,7 +392,7 @@ func TestHandleListVulnerabilityManifests_Success(t *testing.T) { tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) - result, err := tool.HandleListVulnerabilityManifests(context.Background(), makeRequest(nil)) + result, _, err := tool.HandleListVulnerabilityManifests(context.Background(), listVulnerabilityManifestsInput{}) require.NoError(t, err) require.NotNil(t, result) assert.False(t, result.IsError) @@ -459,9 +418,9 @@ func TestHandleListVulnerabilityManifests_FilterByNamespace(t *testing.T) { tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) - result, err := tool.HandleListVulnerabilityManifests(context.Background(), makeRequest(map[string]interface{}{ - "namespace": "default", - })) + result, _, err := tool.HandleListVulnerabilityManifests(context.Background(), listVulnerabilityManifestsInput{ + Namespace: "default", + }) require.NoError(t, err) require.NotNil(t, result) @@ -476,7 +435,7 @@ func TestHandleListVulnerabilityManifests_EmptyResults(t *testing.T) { spdxClient := kubescapefake.NewClientset() tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) - result, err := tool.HandleListVulnerabilityManifests(context.Background(), makeRequest(nil)) + result, _, err := tool.HandleListVulnerabilityManifests(context.Background(), listVulnerabilityManifestsInput{}) require.NoError(t, err) require.NotNil(t, result) @@ -490,7 +449,7 @@ func TestHandleListVulnerabilityManifests_EmptyResults(t *testing.T) { func TestHandleListVulnerabilityManifests_InitError(t *testing.T) { tool := NewKubescapeToolWithError(errors.New("failed to connect")) - result, err := tool.HandleListVulnerabilityManifests(context.Background(), makeRequest(nil)) + result, _, err := tool.HandleListVulnerabilityManifests(context.Background(), listVulnerabilityManifestsInput{}) require.NoError(t, err) require.NotNil(t, result) assert.True(t, result.IsError) @@ -531,9 +490,9 @@ func TestHandleListVulnerabilitiesInManifest_Success(t *testing.T) { tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) - result, err := tool.HandleListVulnerabilitiesInManifest(context.Background(), makeRequest(map[string]interface{}{ - "manifest_name": "test-manifest", - })) + result, _, err := tool.HandleListVulnerabilitiesInManifest(context.Background(), listVulnerabilitiesInManifestInput{ + ManifestName: "test-manifest", + }) require.NoError(t, err) require.NotNil(t, result) assert.False(t, result.IsError) @@ -552,7 +511,7 @@ func TestHandleListVulnerabilitiesInManifest_MissingManifestName(t *testing.T) { spdxClient := kubescapefake.NewClientset() tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) - result, err := tool.HandleListVulnerabilitiesInManifest(context.Background(), makeRequest(nil)) + result, _, err := tool.HandleListVulnerabilitiesInManifest(context.Background(), listVulnerabilitiesInManifestInput{}) require.NoError(t, err) require.NotNil(t, result) assert.True(t, result.IsError) @@ -563,9 +522,9 @@ func TestHandleListVulnerabilitiesInManifest_ManifestNotFound(t *testing.T) { spdxClient := kubescapefake.NewClientset() tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) - result, err := tool.HandleListVulnerabilitiesInManifest(context.Background(), makeRequest(map[string]interface{}{ - "manifest_name": "nonexistent", - })) + result, _, err := tool.HandleListVulnerabilitiesInManifest(context.Background(), listVulnerabilitiesInManifestInput{ + ManifestName: "nonexistent", + }) require.NoError(t, err) require.NotNil(t, result) assert.True(t, result.IsError) @@ -602,10 +561,10 @@ func TestHandleGetVulnerabilityDetails_Success(t *testing.T) { tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) - result, err := tool.HandleGetVulnerabilityDetails(context.Background(), makeRequest(map[string]interface{}{ - "manifest_name": "test-manifest", - "cve_id": "CVE-2021-1234", - })) + result, _, err := tool.HandleGetVulnerabilityDetails(context.Background(), getVulnerabilityDetailsInput{ + ManifestName: "test-manifest", + CveID: "CVE-2021-1234", + }) require.NoError(t, err) require.NotNil(t, result) assert.False(t, result.IsError) @@ -622,9 +581,9 @@ func TestHandleGetVulnerabilityDetails_MissingManifestName(t *testing.T) { spdxClient := kubescapefake.NewClientset() tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) - result, err := tool.HandleGetVulnerabilityDetails(context.Background(), makeRequest(map[string]interface{}{ - "cve_id": "CVE-2021-1234", - })) + result, _, err := tool.HandleGetVulnerabilityDetails(context.Background(), getVulnerabilityDetailsInput{ + CveID: "CVE-2021-1234", + }) require.NoError(t, err) require.NotNil(t, result) assert.True(t, result.IsError) @@ -635,9 +594,9 @@ func TestHandleGetVulnerabilityDetails_MissingCveId(t *testing.T) { spdxClient := kubescapefake.NewClientset() tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) - result, err := tool.HandleGetVulnerabilityDetails(context.Background(), makeRequest(map[string]interface{}{ - "manifest_name": "test-manifest", - })) + result, _, err := tool.HandleGetVulnerabilityDetails(context.Background(), getVulnerabilityDetailsInput{ + ManifestName: "test-manifest", + }) require.NoError(t, err) require.NotNil(t, result) assert.True(t, result.IsError) @@ -661,10 +620,10 @@ func TestHandleGetVulnerabilityDetails_CveNotFound(t *testing.T) { tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) - result, err := tool.HandleGetVulnerabilityDetails(context.Background(), makeRequest(map[string]interface{}{ - "manifest_name": "test-manifest", - "cve_id": "CVE-2021-1234", - })) + result, _, err := tool.HandleGetVulnerabilityDetails(context.Background(), getVulnerabilityDetailsInput{ + ManifestName: "test-manifest", + CveID: "CVE-2021-1234", + }) require.NoError(t, err) require.NotNil(t, result) assert.True(t, result.IsError) @@ -689,7 +648,7 @@ func TestHandleListConfigurationScans_Success(t *testing.T) { tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) - result, err := tool.HandleListConfigurationScans(context.Background(), makeRequest(nil)) + result, _, err := tool.HandleListConfigurationScans(context.Background(), listConfigurationScansInput{}) require.NoError(t, err) require.NotNil(t, result) assert.False(t, result.IsError) @@ -713,9 +672,9 @@ func TestHandleListConfigurationScans_FilterByNamespace(t *testing.T) { tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) - result, err := tool.HandleListConfigurationScans(context.Background(), makeRequest(map[string]interface{}{ - "namespace": "default", - })) + result, _, err := tool.HandleListConfigurationScans(context.Background(), listConfigurationScansInput{ + Namespace: "default", + }) require.NoError(t, err) require.NotNil(t, result) @@ -730,7 +689,7 @@ func TestHandleListConfigurationScans_EmptyResults(t *testing.T) { spdxClient := kubescapefake.NewClientset() tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) - result, err := tool.HandleListConfigurationScans(context.Background(), makeRequest(nil)) + result, _, err := tool.HandleListConfigurationScans(context.Background(), listConfigurationScansInput{}) require.NoError(t, err) require.NotNil(t, result) @@ -753,9 +712,9 @@ func TestHandleGetConfigurationScan_Success(t *testing.T) { tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) - result, err := tool.HandleGetConfigurationScan(context.Background(), makeRequest(map[string]interface{}{ - "manifest_name": "test-scan", - })) + result, _, err := tool.HandleGetConfigurationScan(context.Background(), getConfigurationScanInput{ + ManifestName: "test-scan", + }) require.NoError(t, err) require.NotNil(t, result) assert.False(t, result.IsError) @@ -765,7 +724,7 @@ func TestHandleGetConfigurationScan_MissingManifestName(t *testing.T) { spdxClient := kubescapefake.NewClientset() tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) - result, err := tool.HandleGetConfigurationScan(context.Background(), makeRequest(nil)) + result, _, err := tool.HandleGetConfigurationScan(context.Background(), getConfigurationScanInput{}) require.NoError(t, err) require.NotNil(t, result) assert.True(t, result.IsError) @@ -776,9 +735,9 @@ func TestHandleGetConfigurationScan_NotFound(t *testing.T) { spdxClient := kubescapefake.NewClientset() tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) - result, err := tool.HandleGetConfigurationScan(context.Background(), makeRequest(map[string]interface{}{ - "manifest_name": "nonexistent", - })) + result, _, err := tool.HandleGetConfigurationScan(context.Background(), getConfigurationScanInput{ + ManifestName: "nonexistent", + }) require.NoError(t, err) require.NotNil(t, result) assert.True(t, result.IsError) @@ -809,11 +768,8 @@ func TestNilArgumentsHandling(t *testing.T) { spdxClient := kubescapefake.NewClientset() tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) - // Test with nil arguments map - should use defaults - request := mcp.CallToolRequest{} - request.Params.Arguments = nil - - result, err := tool.HandleListVulnerabilityManifests(context.Background(), request) + // Empty input should use defaults + result, _, err := tool.HandleListVulnerabilityManifests(context.Background(), listVulnerabilityManifestsInput{}) require.NoError(t, err) require.NotNil(t, result) assert.False(t, result.IsError) @@ -853,7 +809,7 @@ func TestHandleListApplicationProfiles_Success(t *testing.T) { tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) - result, err := tool.HandleListApplicationProfiles(context.Background(), makeRequest(nil)) + result, _, err := tool.HandleListApplicationProfiles(context.Background(), listApplicationProfilesInput{}) require.NoError(t, err) require.NotNil(t, result) assert.False(t, result.IsError) @@ -880,9 +836,9 @@ func TestHandleListApplicationProfiles_FilterByNamespace(t *testing.T) { tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) - result, err := tool.HandleListApplicationProfiles(context.Background(), makeRequest(map[string]interface{}{ - "namespace": "default", - })) + result, _, err := tool.HandleListApplicationProfiles(context.Background(), listApplicationProfilesInput{ + Namespace: "default", + }) require.NoError(t, err) require.NotNil(t, result) @@ -897,7 +853,7 @@ func TestHandleListApplicationProfiles_EmptyResults(t *testing.T) { spdxClient := kubescapefake.NewClientset() tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) - result, err := tool.HandleListApplicationProfiles(context.Background(), makeRequest(nil)) + result, _, err := tool.HandleListApplicationProfiles(context.Background(), listApplicationProfilesInput{}) require.NoError(t, err) require.NotNil(t, result) @@ -911,7 +867,7 @@ func TestHandleListApplicationProfiles_EmptyResults(t *testing.T) { func TestHandleListApplicationProfiles_InitError(t *testing.T) { tool := NewKubescapeToolWithError(errors.New("failed to connect")) - result, err := tool.HandleListApplicationProfiles(context.Background(), makeRequest(nil)) + result, _, err := tool.HandleListApplicationProfiles(context.Background(), listApplicationProfilesInput{}) require.NoError(t, err) require.NotNil(t, result) assert.True(t, result.IsError) @@ -944,10 +900,10 @@ func TestHandleGetApplicationProfile_Success(t *testing.T) { tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) - result, err := tool.HandleGetApplicationProfile(context.Background(), makeRequest(map[string]interface{}{ - "namespace": "default", - "name": "test-profile", - })) + result, _, err := tool.HandleGetApplicationProfile(context.Background(), getApplicationProfileInput{ + Namespace: "default", + Name: "test-profile", + }) require.NoError(t, err) require.NotNil(t, result) assert.False(t, result.IsError) @@ -965,9 +921,9 @@ func TestHandleGetApplicationProfile_MissingName(t *testing.T) { spdxClient := kubescapefake.NewClientset() tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) - result, err := tool.HandleGetApplicationProfile(context.Background(), makeRequest(map[string]interface{}{ - "namespace": "default", - })) + result, _, err := tool.HandleGetApplicationProfile(context.Background(), getApplicationProfileInput{ + Namespace: "default", + }) require.NoError(t, err) require.NotNil(t, result) assert.True(t, result.IsError) @@ -978,9 +934,9 @@ func TestHandleGetApplicationProfile_MissingNamespace(t *testing.T) { spdxClient := kubescapefake.NewClientset() tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) - result, err := tool.HandleGetApplicationProfile(context.Background(), makeRequest(map[string]interface{}{ - "name": "test-profile", - })) + result, _, err := tool.HandleGetApplicationProfile(context.Background(), getApplicationProfileInput{ + Name: "test-profile", + }) require.NoError(t, err) require.NotNil(t, result) assert.True(t, result.IsError) @@ -991,10 +947,10 @@ func TestHandleGetApplicationProfile_NotFound(t *testing.T) { spdxClient := kubescapefake.NewClientset() tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) - result, err := tool.HandleGetApplicationProfile(context.Background(), makeRequest(map[string]interface{}{ - "namespace": "default", - "name": "nonexistent", - })) + result, _, err := tool.HandleGetApplicationProfile(context.Background(), getApplicationProfileInput{ + Namespace: "default", + Name: "nonexistent", + }) require.NoError(t, err) require.NotNil(t, result) assert.True(t, result.IsError) @@ -1033,7 +989,7 @@ func TestHandleListNetworkNeighborhoods_Success(t *testing.T) { tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) - result, err := tool.HandleListNetworkNeighborhoods(context.Background(), makeRequest(nil)) + result, _, err := tool.HandleListNetworkNeighborhoods(context.Background(), listNetworkNeighborhoodsInput{}) require.NoError(t, err) require.NotNil(t, result) assert.False(t, result.IsError) @@ -1060,9 +1016,9 @@ func TestHandleListNetworkNeighborhoods_FilterByNamespace(t *testing.T) { tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) - result, err := tool.HandleListNetworkNeighborhoods(context.Background(), makeRequest(map[string]interface{}{ - "namespace": "default", - })) + result, _, err := tool.HandleListNetworkNeighborhoods(context.Background(), listNetworkNeighborhoodsInput{ + Namespace: "default", + }) require.NoError(t, err) require.NotNil(t, result) @@ -1077,7 +1033,7 @@ func TestHandleListNetworkNeighborhoods_EmptyResults(t *testing.T) { spdxClient := kubescapefake.NewClientset() tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) - result, err := tool.HandleListNetworkNeighborhoods(context.Background(), makeRequest(nil)) + result, _, err := tool.HandleListNetworkNeighborhoods(context.Background(), listNetworkNeighborhoodsInput{}) require.NoError(t, err) require.NotNil(t, result) @@ -1091,7 +1047,7 @@ func TestHandleListNetworkNeighborhoods_EmptyResults(t *testing.T) { func TestHandleListNetworkNeighborhoods_InitError(t *testing.T) { tool := NewKubescapeToolWithError(errors.New("failed to connect")) - result, err := tool.HandleListNetworkNeighborhoods(context.Background(), makeRequest(nil)) + result, _, err := tool.HandleListNetworkNeighborhoods(context.Background(), listNetworkNeighborhoodsInput{}) require.NoError(t, err) require.NotNil(t, result) assert.True(t, result.IsError) @@ -1122,10 +1078,10 @@ func TestHandleGetNetworkNeighborhood_Success(t *testing.T) { tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) - result, err := tool.HandleGetNetworkNeighborhood(context.Background(), makeRequest(map[string]interface{}{ - "namespace": "default", - "name": "test-nn", - })) + result, _, err := tool.HandleGetNetworkNeighborhood(context.Background(), getNetworkNeighborhoodInput{ + Namespace: "default", + Name: "test-nn", + }) require.NoError(t, err) require.NotNil(t, result) assert.False(t, result.IsError) @@ -1143,9 +1099,9 @@ func TestHandleGetNetworkNeighborhood_MissingName(t *testing.T) { spdxClient := kubescapefake.NewClientset() tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) - result, err := tool.HandleGetNetworkNeighborhood(context.Background(), makeRequest(map[string]interface{}{ - "namespace": "default", - })) + result, _, err := tool.HandleGetNetworkNeighborhood(context.Background(), getNetworkNeighborhoodInput{ + Namespace: "default", + }) require.NoError(t, err) require.NotNil(t, result) assert.True(t, result.IsError) @@ -1156,9 +1112,9 @@ func TestHandleGetNetworkNeighborhood_MissingNamespace(t *testing.T) { spdxClient := kubescapefake.NewClientset() tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) - result, err := tool.HandleGetNetworkNeighborhood(context.Background(), makeRequest(map[string]interface{}{ - "name": "test-nn", - })) + result, _, err := tool.HandleGetNetworkNeighborhood(context.Background(), getNetworkNeighborhoodInput{ + Name: "test-nn", + }) require.NoError(t, err) require.NotNil(t, result) assert.True(t, result.IsError) @@ -1169,10 +1125,10 @@ func TestHandleGetNetworkNeighborhood_NotFound(t *testing.T) { spdxClient := kubescapefake.NewClientset() tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) - result, err := tool.HandleGetNetworkNeighborhood(context.Background(), makeRequest(map[string]interface{}{ - "namespace": "default", - "name": "nonexistent", - })) + result, _, err := tool.HandleGetNetworkNeighborhood(context.Background(), getNetworkNeighborhoodInput{ + Namespace: "default", + Name: "nonexistent", + }) require.NoError(t, err) require.NotNil(t, result) assert.True(t, result.IsError) diff --git a/pkg/prometheus/prometheus.go b/pkg/prometheus/prometheus.go index c77e23d4..0b73e0c5 100644 --- a/pkg/prometheus/prometheus.go +++ b/pkg/prometheus/prometheus.go @@ -10,10 +10,8 @@ import ( "time" "github.com/kagent-dev/tools/internal/errors" + mcp "github.com/kagent-dev/tools/internal/mcp" "github.com/kagent-dev/tools/internal/security" - "github.com/kagent-dev/tools/internal/telemetry" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" ) // clientKey is the context key for the http client. @@ -26,24 +24,35 @@ func getHTTPClient(ctx context.Context) *http.Client { return http.DefaultClient } -// Prometheus tools using direct HTTP API calls +// prometheusErrResult adapts ToolError to an MCP error result. +func prometheusErrResult(toolErr *errors.ToolError) *mcp.CallToolResult { + return toolErr.ToMCPResult() +} + +type prometheusQueryInput struct { + Query string `json:"query" jsonschema:"PromQL query to execute"` + PrometheusURL string `json:"prometheus_url" jsonschema:"Prometheus server URL (default: http://localhost:9090)"` +} -func handlePrometheusQueryTool(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - prometheusURL := mcp.ParseString(request, "prometheus_url", "http://localhost:9090") - query := mcp.ParseString(request, "query", "") +func handlePrometheusQueryTool(ctx context.Context, request *mcp.CallToolRequest, in prometheusQueryInput) (*mcp.CallToolResult, any, error) { + prometheusURL := in.PrometheusURL + if prometheusURL == "" { + prometheusURL = "http://localhost:9090" + } + query := in.Query if query == "" { - return mcp.NewToolResultError("query parameter is required"), nil + return mcp.NewToolResultError("query parameter is required"), nil, nil } // Validate prometheus URL if err := security.ValidateURL(prometheusURL); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid Prometheus URL: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Invalid Prometheus URL: %v", err)), nil, nil } // Validate PromQL query if err := security.ValidatePromQLQuery(query); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid PromQL query: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Invalid PromQL query: %v", err)), nil, nil } // Make request to Prometheus API @@ -60,7 +69,7 @@ func handlePrometheusQueryTool(ctx context.Context, request mcp.CallToolRequest) toolErr := errors.NewPrometheusError("create_request", err). WithContext("prometheus_url", prometheusURL). WithContext("query", query) - return toolErr.ToMCPResult(), nil + return prometheusErrResult(toolErr), nil, nil } resp, err := client.Do(req) @@ -69,7 +78,7 @@ func handlePrometheusQueryTool(ctx context.Context, request mcp.CallToolRequest) WithContext("prometheus_url", prometheusURL). WithContext("query", query). WithContext("api_url", apiURL) - return toolErr.ToMCPResult(), nil + return prometheusErrResult(toolErr), nil, nil } defer resp.Body.Close() @@ -79,7 +88,7 @@ func handlePrometheusQueryTool(ctx context.Context, request mcp.CallToolRequest) WithContext("prometheus_url", prometheusURL). WithContext("query", query). WithContext("status_code", resp.StatusCode) - return toolErr.ToMCPResult(), nil + return prometheusErrResult(toolErr), nil, nil } if resp.StatusCode != http.StatusOK { @@ -88,58 +97,72 @@ func handlePrometheusQueryTool(ctx context.Context, request mcp.CallToolRequest) WithContext("query", query). WithContext("status_code", resp.StatusCode). WithContext("response_body", string(body)) - return toolErr.ToMCPResult(), nil + return prometheusErrResult(toolErr), nil, nil } // Parse the JSON response to pretty-print it var result interface{} if err := json.Unmarshal(body, &result); err != nil { - return mcp.NewToolResultText(string(body)), nil + return mcp.NewToolResultText(string(body)), nil, nil } prettyJSON, err := json.MarshalIndent(result, "", " ") if err != nil { - return mcp.NewToolResultText(string(body)), nil + return mcp.NewToolResultText(string(body)), nil, nil } - return mcp.NewToolResultText(string(prettyJSON)), nil + return mcp.NewToolResultText(string(prettyJSON)), nil, nil } -func handlePrometheusRangeQueryTool(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - prometheusURL := mcp.ParseString(request, "prometheus_url", "http://localhost:9090") - query := mcp.ParseString(request, "query", "") - start := mcp.ParseString(request, "start", "") - end := mcp.ParseString(request, "end", "") - step := mcp.ParseString(request, "step", "15s") +type prometheusRangeQueryInput struct { + Query string `json:"query" jsonschema:"PromQL query to execute"` + Start string `json:"start" jsonschema:"Start time (Unix timestamp or relative time)"` + End string `json:"end" jsonschema:"End time (Unix timestamp or relative time)"` + Step string `json:"step" jsonschema:"Query resolution step (default: 15s)"` + PrometheusURL string `json:"prometheus_url" jsonschema:"Prometheus server URL (default: http://localhost:9090)"` +} + +func handlePrometheusRangeQueryTool(ctx context.Context, request *mcp.CallToolRequest, in prometheusRangeQueryInput) (*mcp.CallToolResult, any, error) { + prometheusURL := in.PrometheusURL + if prometheusURL == "" { + prometheusURL = "http://localhost:9090" + } + query := in.Query + start := in.Start + end := in.End + step := in.Step + if step == "" { + step = "15s" + } if query == "" { - return mcp.NewToolResultError("query parameter is required"), nil + return mcp.NewToolResultError("query parameter is required"), nil, nil } // Validate prometheus URL if err := security.ValidateURL(prometheusURL); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid Prometheus URL: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Invalid Prometheus URL: %v", err)), nil, nil } // Validate PromQL query if err := security.ValidatePromQLQuery(query); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid PromQL query: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Invalid PromQL query: %v", err)), nil, nil } // Validate time parameters if provided if start != "" { if err := security.ValidateCommandInput(start); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid start time: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Invalid start time: %v", err)), nil, nil } } if end != "" { if err := security.ValidateCommandInput(end); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid end time: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Invalid end time: %v", err)), nil, nil } } if step != "" { if err := security.ValidateCommandInput(step); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid step parameter: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Invalid step parameter: %v", err)), nil, nil } } @@ -164,44 +187,51 @@ func handlePrometheusRangeQueryTool(ctx context.Context, request mcp.CallToolReq client := getHTTPClient(ctx) req, err := http.NewRequestWithContext(ctx, "GET", fullURL, nil) if err != nil { - return mcp.NewToolResultError("failed to create request: " + err.Error()), nil + return mcp.NewToolResultError("failed to create request: " + err.Error()), nil, nil } resp, err := client.Do(req) if err != nil { - return mcp.NewToolResultError("failed to query Prometheus: " + err.Error()), nil + return mcp.NewToolResultError("failed to query Prometheus: " + err.Error()), nil, nil } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { - return mcp.NewToolResultError("failed to read response: " + err.Error()), nil + return mcp.NewToolResultError("failed to read response: " + err.Error()), nil, nil } if resp.StatusCode != http.StatusOK { - return mcp.NewToolResultError(fmt.Sprintf("Prometheus API error (%d): %s", resp.StatusCode, string(body))), nil + return mcp.NewToolResultError(fmt.Sprintf("Prometheus API error (%d): %s", resp.StatusCode, string(body))), nil, nil } // Parse the JSON response to pretty-print it var result interface{} if err := json.Unmarshal(body, &result); err != nil { - return mcp.NewToolResultText(string(body)), nil + return mcp.NewToolResultText(string(body)), nil, nil } prettyJSON, err := json.MarshalIndent(result, "", " ") if err != nil { - return mcp.NewToolResultText(string(body)), nil + return mcp.NewToolResultText(string(body)), nil, nil } - return mcp.NewToolResultText(string(prettyJSON)), nil + return mcp.NewToolResultText(string(prettyJSON)), nil, nil +} + +type prometheusLabelsInput struct { + PrometheusURL string `json:"prometheus_url" jsonschema:"Prometheus server URL (default: http://localhost:9090)"` } -func handlePrometheusLabelsQueryTool(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - prometheusURL := mcp.ParseString(request, "prometheus_url", "http://localhost:9090") +func handlePrometheusLabelsQueryTool(ctx context.Context, request *mcp.CallToolRequest, in prometheusLabelsInput) (*mcp.CallToolResult, any, error) { + prometheusURL := in.PrometheusURL + if prometheusURL == "" { + prometheusURL = "http://localhost:9090" + } // Validate prometheus URL if err := security.ValidateURL(prometheusURL); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid Prometheus URL: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Invalid Prometheus URL: %v", err)), nil, nil } // Make request to Prometheus API for labels @@ -213,7 +243,7 @@ func handlePrometheusLabelsQueryTool(ctx context.Context, request mcp.CallToolRe toolErr := errors.NewPrometheusError("create_request", err). WithContext("prometheus_url", prometheusURL). WithContext("api_url", apiURL) - return toolErr.ToMCPResult(), nil + return prometheusErrResult(toolErr), nil, nil } resp, err := client.Do(req) @@ -221,7 +251,7 @@ func handlePrometheusLabelsQueryTool(ctx context.Context, request mcp.CallToolRe toolErr := errors.NewPrometheusError("query_execution", err). WithContext("prometheus_url", prometheusURL). WithContext("api_url", apiURL) - return toolErr.ToMCPResult(), nil + return prometheusErrResult(toolErr), nil, nil } defer resp.Body.Close() @@ -231,7 +261,7 @@ func handlePrometheusLabelsQueryTool(ctx context.Context, request mcp.CallToolRe WithContext("prometheus_url", prometheusURL). WithContext("api_url", apiURL). WithContext("status_code", resp.StatusCode) - return toolErr.ToMCPResult(), nil + return prometheusErrResult(toolErr), nil, nil } if resp.StatusCode != http.StatusOK { @@ -240,29 +270,36 @@ func handlePrometheusLabelsQueryTool(ctx context.Context, request mcp.CallToolRe WithContext("api_url", apiURL). WithContext("status_code", resp.StatusCode). WithContext("response_body", string(body)) - return toolErr.ToMCPResult(), nil + return prometheusErrResult(toolErr), nil, nil } // Parse the JSON response to pretty-print it var result interface{} if err := json.Unmarshal(body, &result); err != nil { - return mcp.NewToolResultText(string(body)), nil + return mcp.NewToolResultText(string(body)), nil, nil } prettyJSON, err := json.MarshalIndent(result, "", " ") if err != nil { - return mcp.NewToolResultText(string(body)), nil + return mcp.NewToolResultText(string(body)), nil, nil } - return mcp.NewToolResultText(string(prettyJSON)), nil + return mcp.NewToolResultText(string(prettyJSON)), nil, nil } -func handlePrometheusTargetsQueryTool(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - prometheusURL := mcp.ParseString(request, "prometheus_url", "http://localhost:9090") +type prometheusTargetsInput struct { + PrometheusURL string `json:"prometheus_url" jsonschema:"Prometheus server URL (default: http://localhost:9090)"` +} + +func handlePrometheusTargetsQueryTool(ctx context.Context, request *mcp.CallToolRequest, in prometheusTargetsInput) (*mcp.CallToolResult, any, error) { + prometheusURL := in.PrometheusURL + if prometheusURL == "" { + prometheusURL = "http://localhost:9090" + } // Validate prometheus URL if err := security.ValidateURL(prometheusURL); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid Prometheus URL: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Invalid Prometheus URL: %v", err)), nil, nil } // Make request to Prometheus API for targets @@ -271,66 +308,61 @@ func handlePrometheusTargetsQueryTool(ctx context.Context, request mcp.CallToolR client := getHTTPClient(ctx) req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) if err != nil { - return mcp.NewToolResultError("failed to create request: " + err.Error()), nil + return mcp.NewToolResultError("failed to create request: " + err.Error()), nil, nil } resp, err := client.Do(req) if err != nil { - return mcp.NewToolResultError("failed to query Prometheus: " + err.Error()), nil + return mcp.NewToolResultError("failed to query Prometheus: " + err.Error()), nil, nil } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { - return mcp.NewToolResultError("failed to read response: " + err.Error()), nil + return mcp.NewToolResultError("failed to read response: " + err.Error()), nil, nil } if resp.StatusCode != http.StatusOK { - return mcp.NewToolResultError(fmt.Sprintf("Prometheus API error (%d): %s", resp.StatusCode, string(body))), nil + return mcp.NewToolResultError(fmt.Sprintf("Prometheus API error (%d): %s", resp.StatusCode, string(body))), nil, nil } // Parse the JSON response to pretty-print it var result interface{} if err := json.Unmarshal(body, &result); err != nil { - return mcp.NewToolResultText(string(body)), nil + return mcp.NewToolResultText(string(body)), nil, nil } prettyJSON, err := json.MarshalIndent(result, "", " ") if err != nil { - return mcp.NewToolResultText(string(body)), nil + return mcp.NewToolResultText(string(body)), nil, nil } - return mcp.NewToolResultText(string(prettyJSON)), nil + return mcp.NewToolResultText(string(prettyJSON)), nil, nil } -func RegisterTools(s *server.MCPServer, readOnly bool) { - s.AddTool(mcp.NewTool("prometheus_query_tool", - mcp.WithDescription("Execute a PromQL query against Prometheus"), - mcp.WithString("query", mcp.Description("PromQL query to execute"), mcp.Required()), - mcp.WithString("prometheus_url", mcp.Description("Prometheus server URL (default: http://localhost:9090)")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("prometheus_query_tool", handlePrometheusQueryTool))) - - s.AddTool(mcp.NewTool("prometheus_query_range_tool", - mcp.WithDescription("Execute a PromQL range query against Prometheus"), - mcp.WithString("query", mcp.Description("PromQL query to execute"), mcp.Required()), - mcp.WithString("start", mcp.Description("Start time (Unix timestamp or relative time)")), - mcp.WithString("end", mcp.Description("End time (Unix timestamp or relative time)")), - mcp.WithString("step", mcp.Description("Query resolution step (default: 15s)")), - mcp.WithString("prometheus_url", mcp.Description("Prometheus server URL (default: http://localhost:9090)")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("prometheus_query_range_tool", handlePrometheusRangeQueryTool))) - - s.AddTool(mcp.NewTool("prometheus_label_names_tool", - mcp.WithDescription("Get all available labels from Prometheus"), - mcp.WithString("prometheus_url", mcp.Description("Prometheus server URL (default: http://localhost:9090)")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("prometheus_label_names_tool", handlePrometheusLabelsQueryTool))) - - s.AddTool(mcp.NewTool("prometheus_targets_tool", - mcp.WithDescription("Get all Prometheus targets and their status"), - mcp.WithString("prometheus_url", mcp.Description("Prometheus server URL (default: http://localhost:9090)")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("prometheus_targets_tool", handlePrometheusTargetsQueryTool))) - - s.AddTool(mcp.NewTool("prometheus_promql_tool", - mcp.WithDescription("Generate a PromQL query"), - mcp.WithString("query_description", mcp.Description("A string describing the query to generate"), mcp.Required()), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("prometheus_promql_tool", handlePromql))) +func RegisterTools(s *mcp.Server, readOnly bool) { + mcp.AddTool(s, "prometheus", &mcp.Tool{ + Name: "prometheus_query_tool", + Description: "Execute a PromQL query against Prometheus", + }, handlePrometheusQueryTool) + + mcp.AddTool(s, "prometheus", &mcp.Tool{ + Name: "prometheus_query_range_tool", + Description: "Execute a PromQL range query against Prometheus", + }, handlePrometheusRangeQueryTool) + + mcp.AddTool(s, "prometheus", &mcp.Tool{ + Name: "prometheus_label_names_tool", + Description: "Get all available labels from Prometheus", + }, handlePrometheusLabelsQueryTool) + + mcp.AddTool(s, "prometheus", &mcp.Tool{ + Name: "prometheus_targets_tool", + Description: "Get all Prometheus targets and their status", + }, handlePrometheusTargetsQueryTool) + + mcp.AddTool(s, "prometheus", &mcp.Tool{ + Name: "prometheus_promql_tool", + Description: "Generate a PromQL query", + }, handlePromql) } diff --git a/pkg/prometheus/prometheus_test.go b/pkg/prometheus/prometheus_test.go index 1e8ffc49..792fad20 100644 --- a/pkg/prometheus/prometheus_test.go +++ b/pkg/prometheus/prometheus_test.go @@ -7,18 +7,17 @@ import ( "strings" "testing" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" + mcp "github.com/kagent-dev/tools/internal/mcp" "github.com/stretchr/testify/assert" ) func TestRegisterTools(t *testing.T) { t.Run("read-write", func(t *testing.T) { - s := server.NewMCPServer("test", "v0.0.1") + s := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "v0.0.1"}, nil) RegisterTools(s, false) }) t.Run("read-only", func(t *testing.T) { - s := server.NewMCPServer("test", "v0.0.1") + s := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "v0.0.1"}, nil) RegisterTools(s, true) }) } @@ -26,75 +25,74 @@ func TestRegisterTools(t *testing.T) { func TestPrometheusInputValidation(t *testing.T) { ctx := context.Background() - invalidURL := map[string]interface{}{"prometheus_url": "not a url", "query": "up"} - invalidQuery := map[string]interface{}{"prometheus_url": "http://localhost:9090", "query": "up; drop"} - t.Run("query invalid url", func(t *testing.T) { - req := mcp.CallToolRequest{} - req.Params.Arguments = invalidURL - res, err := handlePrometheusQueryTool(ctx, req) + res, _, err := handlePrometheusQueryTool(ctx, &mcp.CallToolRequest{}, prometheusQueryInput{ + PrometheusURL: "not a url", + Query: "up", + }) assert.NoError(t, err) assert.True(t, res.IsError) }) t.Run("range invalid url", func(t *testing.T) { - req := mcp.CallToolRequest{} - req.Params.Arguments = invalidURL - res, err := handlePrometheusRangeQueryTool(ctx, req) + res, _, err := handlePrometheusRangeQueryTool(ctx, &mcp.CallToolRequest{}, prometheusRangeQueryInput{ + PrometheusURL: "not a url", + Query: "up", + }) assert.NoError(t, err) assert.True(t, res.IsError) }) t.Run("labels invalid url", func(t *testing.T) { - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{"prometheus_url": "not a url"} - res, err := handlePrometheusLabelsQueryTool(ctx, req) + res, _, err := handlePrometheusLabelsQueryTool(ctx, &mcp.CallToolRequest{}, prometheusLabelsInput{ + PrometheusURL: "not a url", + }) assert.NoError(t, err) assert.True(t, res.IsError) }) t.Run("targets invalid url", func(t *testing.T) { - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{"prometheus_url": "not a url"} - res, err := handlePrometheusTargetsQueryTool(ctx, req) + res, _, err := handlePrometheusTargetsQueryTool(ctx, &mcp.CallToolRequest{}, prometheusTargetsInput{ + PrometheusURL: "not a url", + }) assert.NoError(t, err) assert.True(t, res.IsError) }) t.Run("query invalid promql", func(t *testing.T) { - req := mcp.CallToolRequest{} - req.Params.Arguments = invalidQuery - res, err := handlePrometheusQueryTool(ctx, req) + res, _, err := handlePrometheusQueryTool(ctx, &mcp.CallToolRequest{}, prometheusQueryInput{ + PrometheusURL: "http://localhost:9090", + Query: "up; drop", + }) assert.NoError(t, err) assert.True(t, res.IsError) }) t.Run("range invalid promql", func(t *testing.T) { - req := mcp.CallToolRequest{} - req.Params.Arguments = invalidQuery - res, err := handlePrometheusRangeQueryTool(ctx, req) + res, _, err := handlePrometheusRangeQueryTool(ctx, &mcp.CallToolRequest{}, prometheusRangeQueryInput{ + PrometheusURL: "http://localhost:9090", + Query: "up; drop", + }) assert.NoError(t, err) assert.True(t, res.IsError) }) } func TestPrometheusLabelsTargetsErrorPaths(t *testing.T) { - args := map[string]interface{}{"prometheus_url": "http://localhost:9090"} - t.Run("labels client error", func(t *testing.T) { ctx := contextWithMockClient(newTestClient(nil, assert.AnError)) - req := mcp.CallToolRequest{} - req.Params.Arguments = args - res, err := handlePrometheusLabelsQueryTool(ctx, req) + res, _, err := handlePrometheusLabelsQueryTool(ctx, &mcp.CallToolRequest{}, prometheusLabelsInput{ + PrometheusURL: "http://localhost:9090", + }) assert.NoError(t, err) assert.True(t, res.IsError) }) t.Run("labels malformed json", func(t *testing.T) { ctx := contextWithMockClient(newTestClient(createMockResponse(200, "not json"), nil)) - req := mcp.CallToolRequest{} - req.Params.Arguments = args - res, err := handlePrometheusLabelsQueryTool(ctx, req) + res, _, err := handlePrometheusLabelsQueryTool(ctx, &mcp.CallToolRequest{}, prometheusLabelsInput{ + PrometheusURL: "http://localhost:9090", + }) assert.NoError(t, err) assert.False(t, res.IsError) assert.Contains(t, getResultText(res), "not json") @@ -102,18 +100,18 @@ func TestPrometheusLabelsTargetsErrorPaths(t *testing.T) { t.Run("targets client error", func(t *testing.T) { ctx := contextWithMockClient(newTestClient(nil, assert.AnError)) - req := mcp.CallToolRequest{} - req.Params.Arguments = args - res, err := handlePrometheusTargetsQueryTool(ctx, req) + res, _, err := handlePrometheusTargetsQueryTool(ctx, &mcp.CallToolRequest{}, prometheusTargetsInput{ + PrometheusURL: "http://localhost:9090", + }) assert.NoError(t, err) assert.True(t, res.IsError) }) t.Run("targets malformed json", func(t *testing.T) { ctx := contextWithMockClient(newTestClient(createMockResponse(200, "not json"), nil)) - req := mcp.CallToolRequest{} - req.Params.Arguments = args - res, err := handlePrometheusTargetsQueryTool(ctx, req) + res, _, err := handlePrometheusTargetsQueryTool(ctx, &mcp.CallToolRequest{}, prometheusTargetsInput{ + PrometheusURL: "http://localhost:9090", + }) assert.NoError(t, err) assert.False(t, res.IsError) assert.Contains(t, getResultText(res), "not json") @@ -154,7 +152,7 @@ func getResultText(result *mcp.CallToolResult) string { if result == nil || len(result.Content) == 0 { return "" } - if textContent, ok := result.Content[0].(mcp.TextContent); ok { + if textContent, ok := result.Content[0].(*mcp.TextContent); ok { return textContent.Text } return "" @@ -192,13 +190,10 @@ func TestHandlePrometheusQueryTool(t *testing.T) { client := newTestClient(createMockResponse(200, mockResponse), nil) ctx := contextWithMockClient(client) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "query": "up", - "prometheus_url": "http://localhost:9090", - } - - result, err := handlePrometheusQueryTool(ctx, request) + result, _, err := handlePrometheusQueryTool(ctx, &mcp.CallToolRequest{}, prometheusQueryInput{ + Query: "up", + PrometheusURL: "http://localhost:9090", + }) assert.NoError(t, err) assert.NotNil(t, result) @@ -211,12 +206,9 @@ func TestHandlePrometheusQueryTool(t *testing.T) { t.Run("missing query parameter", func(t *testing.T) { ctx := context.Background() - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "prometheus_url": "http://localhost:9090", - } - - result, err := handlePrometheusQueryTool(ctx, request) + result, _, err := handlePrometheusQueryTool(ctx, &mcp.CallToolRequest{}, prometheusQueryInput{ + PrometheusURL: "http://localhost:9090", + }) assert.NoError(t, err) assert.NotNil(t, result) @@ -228,12 +220,9 @@ func TestHandlePrometheusQueryTool(t *testing.T) { client := newTestClient(nil, assert.AnError) ctx := contextWithMockClient(client) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "query": "up", - } - - result, err := handlePrometheusQueryTool(ctx, request) + result, _, err := handlePrometheusQueryTool(ctx, &mcp.CallToolRequest{}, prometheusQueryInput{ + Query: "up", + }) assert.NoError(t, err) assert.NotNil(t, result) @@ -245,12 +234,9 @@ func TestHandlePrometheusQueryTool(t *testing.T) { client := newTestClient(createMockResponse(500, "Internal Server Error"), nil) ctx := contextWithMockClient(client) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "query": "up", - } - - result, err := handlePrometheusQueryTool(ctx, request) + result, _, err := handlePrometheusQueryTool(ctx, &mcp.CallToolRequest{}, prometheusQueryInput{ + Query: "up", + }) assert.NoError(t, err) assert.NotNil(t, result) @@ -262,12 +248,9 @@ func TestHandlePrometheusQueryTool(t *testing.T) { client := newTestClient(createMockResponse(200, "invalid json {"), nil) ctx := contextWithMockClient(client) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "query": "up", - } - - result, err := handlePrometheusQueryTool(ctx, request) + result, _, err := handlePrometheusQueryTool(ctx, &mcp.CallToolRequest{}, prometheusQueryInput{ + Query: "up", + }) assert.NoError(t, err) assert.NotNil(t, result) @@ -281,12 +264,9 @@ func TestHandlePrometheusQueryTool(t *testing.T) { client := newTestClient(createMockResponse(200, mockResponse), nil) ctx := contextWithMockClient(client) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "query": "up", - } - - result, err := handlePrometheusQueryTool(ctx, request) + result, _, err := handlePrometheusQueryTool(ctx, &mcp.CallToolRequest{}, prometheusQueryInput{ + Query: "up", + }) assert.NoError(t, err) assert.NotNil(t, result) @@ -312,15 +292,12 @@ func TestHandlePrometheusRangeQueryTool(t *testing.T) { client := newTestClient(createMockResponse(200, mockResponse), nil) ctx := contextWithMockClient(client) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "query": "up", - "start": "1609459200", - "end": "1609459260", - "step": "60s", - } - - result, err := handlePrometheusRangeQueryTool(ctx, request) + result, _, err := handlePrometheusRangeQueryTool(ctx, &mcp.CallToolRequest{}, prometheusRangeQueryInput{ + Query: "up", + Start: "1609459200", + End: "1609459260", + Step: "60s", + }) assert.NoError(t, err) assert.NotNil(t, result) @@ -333,10 +310,7 @@ func TestHandlePrometheusRangeQueryTool(t *testing.T) { t.Run("missing query parameter", func(t *testing.T) { ctx := context.Background() - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{} - - result, err := handlePrometheusRangeQueryTool(ctx, request) + result, _, err := handlePrometheusRangeQueryTool(ctx, &mcp.CallToolRequest{}, prometheusRangeQueryInput{}) assert.NoError(t, err) assert.NotNil(t, result) @@ -349,12 +323,9 @@ func TestHandlePrometheusRangeQueryTool(t *testing.T) { client := newTestClient(createMockResponse(200, mockResponse), nil) ctx := contextWithMockClient(client) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "query": "up", - } - - result, err := handlePrometheusRangeQueryTool(ctx, request) + result, _, err := handlePrometheusRangeQueryTool(ctx, &mcp.CallToolRequest{}, prometheusRangeQueryInput{ + Query: "up", + }) assert.NoError(t, err) assert.NotNil(t, result) @@ -372,10 +343,7 @@ func TestHandlePrometheusLabelsQueryTool(t *testing.T) { client := newTestClient(createMockResponse(200, mockResponse), nil) ctx := contextWithMockClient(client) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{} - - result, err := handlePrometheusLabelsQueryTool(ctx, request) + result, _, err := handlePrometheusLabelsQueryTool(ctx, &mcp.CallToolRequest{}, prometheusLabelsInput{}) assert.NoError(t, err) assert.NotNil(t, result) @@ -391,10 +359,7 @@ func TestHandlePrometheusLabelsQueryTool(t *testing.T) { client := newTestClient(nil, assert.AnError) ctx := contextWithMockClient(client) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{} - - result, err := handlePrometheusLabelsQueryTool(ctx, request) + result, _, err := handlePrometheusLabelsQueryTool(ctx, &mcp.CallToolRequest{}, prometheusLabelsInput{}) assert.NoError(t, err) assert.NotNil(t, result) @@ -407,12 +372,9 @@ func TestHandlePrometheusLabelsQueryTool(t *testing.T) { client := newTestClient(createMockResponse(200, mockResponse), nil) ctx := contextWithMockClient(client) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "prometheus_url": "http://custom:9090", - } - - result, err := handlePrometheusLabelsQueryTool(ctx, request) + result, _, err := handlePrometheusLabelsQueryTool(ctx, &mcp.CallToolRequest{}, prometheusLabelsInput{ + PrometheusURL: "http://custom:9090", + }) assert.NoError(t, err) assert.NotNil(t, result) @@ -440,10 +402,7 @@ func TestHandlePrometheusTargetsQueryTool(t *testing.T) { client := newTestClient(createMockResponse(200, mockResponse), nil) ctx := contextWithMockClient(client) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{} - - result, err := handlePrometheusTargetsQueryTool(ctx, request) + result, _, err := handlePrometheusTargetsQueryTool(ctx, &mcp.CallToolRequest{}, prometheusTargetsInput{}) assert.NoError(t, err) assert.NotNil(t, result) @@ -459,10 +418,7 @@ func TestHandlePrometheusTargetsQueryTool(t *testing.T) { client := newTestClient(createMockResponse(404, "Not Found"), nil) ctx := contextWithMockClient(client) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{} - - result, err := handlePrometheusTargetsQueryTool(ctx, request) + result, _, err := handlePrometheusTargetsQueryTool(ctx, &mcp.CallToolRequest{}, prometheusTargetsInput{}) assert.NoError(t, err) assert.NotNil(t, result) @@ -474,10 +430,7 @@ func TestHandlePrometheusTargetsQueryTool(t *testing.T) { func TestHandlePromql(t *testing.T) { t.Run("missing query description", func(t *testing.T) { ctx := context.Background() - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{} - - result, err := handlePromql(ctx, request) + result, _, err := handlePromql(ctx, &mcp.CallToolRequest{}, promqlInput{}) assert.NoError(t, err) assert.NotNil(t, result) @@ -487,12 +440,9 @@ func TestHandlePromql(t *testing.T) { t.Run("with query description", func(t *testing.T) { ctx := context.Background() - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "query_description": "CPU usage percentage", - } - - result, err := handlePromql(ctx, request) + result, _, err := handlePromql(ctx, &mcp.CallToolRequest{}, promqlInput{ + QueryDescription: "CPU usage percentage", + }) assert.NoError(t, err) assert.NotNil(t, result) @@ -522,12 +472,9 @@ func TestPrometheusToolsContextCancellation(t *testing.T) { ctx := contextWithMockClient(client) _ = cancelCtx - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "query": "up", - } - - result, err := handlePrometheusQueryTool(ctx, request) + result, _, err := handlePrometheusQueryTool(ctx, &mcp.CallToolRequest{}, prometheusQueryInput{ + Query: "up", + }) // Should handle cancellation gracefully assert.NoError(t, err) @@ -551,12 +498,9 @@ func TestPrometheusToolsEdgeCases(t *testing.T) { client := newTestClient(createMockResponse(200, largeResponse), nil) ctx := contextWithMockClient(client) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "query": "up", - } - - result, err := handlePrometheusQueryTool(ctx, request) + result, _, err := handlePrometheusQueryTool(ctx, &mcp.CallToolRequest{}, prometheusQueryInput{ + Query: "up", + }) assert.NoError(t, err) assert.NotNil(t, result) @@ -571,12 +515,9 @@ func TestPrometheusToolsEdgeCases(t *testing.T) { client := newTestClient(createMockResponse(200, mockResponse), nil) ctx := contextWithMockClient(client) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "query": `up{instance=~".*:9090"}`, - } - - result, err := handlePrometheusQueryTool(ctx, request) + result, _, err := handlePrometheusQueryTool(ctx, &mcp.CallToolRequest{}, prometheusQueryInput{ + Query: `up{instance=~".*:9090"}`, + }) assert.NoError(t, err) assert.NotNil(t, result) @@ -587,12 +528,9 @@ func TestPrometheusToolsEdgeCases(t *testing.T) { client := newTestClient(createMockResponse(200, ""), nil) ctx := contextWithMockClient(client) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "query": "up", - } - - result, err := handlePrometheusQueryTool(ctx, request) + result, _, err := handlePrometheusQueryTool(ctx, &mcp.CallToolRequest{}, prometheusQueryInput{ + Query: "up", + }) assert.NoError(t, err) assert.NotNil(t, result) @@ -607,12 +545,9 @@ func TestPrometheusURLEncoding(t *testing.T) { client := newTestClient(createMockResponse(200, mockResponse), nil) ctx := contextWithMockClient(client) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "query": "up{job=\"test service\"}", - } - - result, err := handlePrometheusQueryTool(ctx, request) + result, _, err := handlePrometheusQueryTool(ctx, &mcp.CallToolRequest{}, prometheusQueryInput{ + Query: `up{job="test service"}`, + }) assert.NoError(t, err) assert.NotNil(t, result) diff --git a/pkg/prometheus/promql.go b/pkg/prometheus/promql.go index dd2b7460..d95b3c1b 100644 --- a/pkg/prometheus/promql.go +++ b/pkg/prometheus/promql.go @@ -4,7 +4,7 @@ import ( "context" _ "embed" - "github.com/mark3labs/mcp-go/mcp" + mcp "github.com/kagent-dev/tools/internal/mcp" "github.com/tmc/langchaingo/llms" "github.com/tmc/langchaingo/llms/openai" ) @@ -12,15 +12,19 @@ import ( //go:embed promql_prompt.md var promqlPrompt string -func handlePromql(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - queryDescription := mcp.ParseString(request, "query_description", "") +type promqlInput struct { + QueryDescription string `json:"query_description" jsonschema:"A string describing the query to generate"` +} + +func handlePromql(ctx context.Context, request *mcp.CallToolRequest, in promqlInput) (*mcp.CallToolResult, any, error) { + queryDescription := in.QueryDescription if queryDescription == "" { - return mcp.NewToolResultError("query_description is required"), nil + return mcp.NewToolResultError("query_description is required"), nil, nil } llm, err := openai.New() if err != nil { - return mcp.NewToolResultError("failed to create LLM client: " + err.Error()), nil + return mcp.NewToolResultError("failed to create LLM client: " + err.Error()), nil, nil } contents := []llms.MessageContent{ @@ -41,13 +45,13 @@ func handlePromql(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallTo resp, err := llm.GenerateContent(ctx, contents, llms.WithModel("gpt-4o-mini")) if err != nil { - return mcp.NewToolResultError("failed to generate content: " + err.Error()), nil + return mcp.NewToolResultError("failed to generate content: " + err.Error()), nil, nil } choices := resp.Choices if len(choices) < 1 { - return mcp.NewToolResultError("empty response from model"), nil + return mcp.NewToolResultError("empty response from model"), nil, nil } c1 := choices[0] - return mcp.NewToolResultText(c1.Content), nil + return mcp.NewToolResultText(c1.Content), nil, nil } diff --git a/pkg/utils/common.go b/pkg/utils/common.go index f149d013..03c84b00 100644 --- a/pkg/utils/common.go +++ b/pkg/utils/common.go @@ -9,8 +9,7 @@ import ( "github.com/kagent-dev/tools/internal/commands" "github.com/kagent-dev/tools/internal/logger" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" + mcp "github.com/kagent-dev/tools/internal/mcp" ) // KubeConfigManager manages kubeconfig path with thread safety @@ -48,9 +47,9 @@ func AddKubeconfigArgs(args []string) []string { return args } -// shellTool provides shell command execution functionality +// shellParams is the typed input for the shell tool. type shellParams struct { - Command string `json:"command" description:"The shell command to execute"` + Command string `json:"command" jsonschema:"The shell command to execute"` } func shellTool(ctx context.Context, params shellParams) (string, error) { @@ -66,43 +65,46 @@ func shellTool(ctx context.Context, params shellParams) (string, error) { return commands.NewCommandBuilder(cmd).WithArgs(args...).Execute(ctx) } -func handleShellTool(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - command := mcp.ParseString(request, "command", "") - if command == "" { - return mcp.NewToolResultError("command parameter is required"), nil +func handleShellTool(ctx context.Context, request *mcp.CallToolRequest, in shellParams) (*mcp.CallToolResult, any, error) { + if in.Command == "" { + return mcp.NewToolResultError("command parameter is required"), nil, nil } - result, err := shellTool(ctx, shellParams{Command: command}) + result, err := shellTool(ctx, in) if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return mcp.NewToolResultError(err.Error()), nil, nil } - return mcp.NewToolResultText(result), nil + return mcp.NewToolResultText(result), nil, nil } +// datetimeInput is the (empty) typed input for the datetime tool. +type datetimeInput struct{} + // handleGetCurrentDateTimeTool provides datetime functionality for both MCP and testing -func handleGetCurrentDateTimeTool(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func handleGetCurrentDateTimeTool(ctx context.Context, request *mcp.CallToolRequest, in datetimeInput) (*mcp.CallToolResult, any, error) { // Returns the current date and time in ISO 8601 format (RFC3339) // This matches the Python implementation: datetime.datetime.now().isoformat() now := time.Now() - return mcp.NewToolResultText(now.Format(time.RFC3339)), nil + return mcp.NewToolResultText(now.Format(time.RFC3339)), nil, nil } -func RegisterTools(s *server.MCPServer, readOnly bool) { +func RegisterTools(s *mcp.Server, readOnly bool) { logger.Get().Info("RegisterTools initialized") // Register shell tool - disabled in read-only mode as it allows arbitrary command execution if !readOnly { - s.AddTool(mcp.NewTool("shell", - mcp.WithDescription("Execute shell commands"), - mcp.WithString("command", mcp.Description("The shell command to execute"), mcp.Required()), - ), handleShellTool) + mcp.AddTool(s, "utils", &mcp.Tool{ + Name: "shell", + Description: "Execute shell commands", + }, handleShellTool) } // Register datetime tool - s.AddTool(mcp.NewTool("datetime_get_current_time", - mcp.WithDescription("Returns the current date and time in ISO 8601 format."), - ), handleGetCurrentDateTimeTool) + mcp.AddTool(s, "utils", &mcp.Tool{ + Name: "datetime_get_current_time", + Description: "Returns the current date and time in ISO 8601 format.", + }, handleGetCurrentDateTimeTool) // Note: LLM Tool implementation would go here if needed } diff --git a/pkg/utils/common_test.go b/pkg/utils/common_test.go index 6502225a..72c1b641 100644 --- a/pkg/utils/common_test.go +++ b/pkg/utils/common_test.go @@ -5,8 +5,7 @@ import ( "testing" "github.com/kagent-dev/tools/internal/cmd" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" + mcp "github.com/kagent-dev/tools/internal/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -68,12 +67,12 @@ func TestShellTool(t *testing.T) { func TestRegisterTools(t *testing.T) { t.Run("read-write registers shell", func(t *testing.T) { - s := server.NewMCPServer("test", "v0.0.1") + s := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "v0.0.1"}, nil) RegisterTools(s, false) }) t.Run("read-only omits shell", func(t *testing.T) { - s := server.NewMCPServer("test", "v0.0.1") + s := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "v0.0.1"}, nil) RegisterTools(s, true) }) } @@ -84,17 +83,13 @@ func TestHandleShellTool(t *testing.T) { ctx := cmd.WithShellExecutor(context.Background(), mock) t.Run("success", func(t *testing.T) { - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{"command": "echo hi"} - res, err := handleShellTool(ctx, req) + res, _, err := handleShellTool(ctx, &mcp.CallToolRequest{}, shellParams{Command: "echo hi"}) require.NoError(t, err) assert.False(t, res.IsError) }) t.Run("missing command", func(t *testing.T) { - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{} - res, err := handleShellTool(ctx, req) + res, _, err := handleShellTool(ctx, &mcp.CallToolRequest{}, shellParams{}) require.NoError(t, err) assert.True(t, res.IsError) assert.Contains(t, getResultText(res), "command parameter is required") @@ -104,9 +99,7 @@ func TestHandleShellTool(t *testing.T) { m := cmd.NewMockShellExecutor() m.AddCommandString("false", []string{}, "", assert.AnError) errCtx := cmd.WithShellExecutor(context.Background(), m) - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{"command": "false"} - res, err := handleShellTool(errCtx, req) + res, _, err := handleShellTool(errCtx, &mcp.CallToolRequest{}, shellParams{Command: "false"}) require.NoError(t, err) assert.True(t, res.IsError) }) @@ -116,7 +109,7 @@ func getResultText(result *mcp.CallToolResult) string { if result == nil || len(result.Content) == 0 { return "" } - if textContent, ok := result.Content[0].(mcp.TextContent); ok { + if textContent, ok := result.Content[0].(*mcp.TextContent); ok { return textContent.Text } return "" diff --git a/pkg/utils/datetime_test.go b/pkg/utils/datetime_test.go index 8f1cd641..1d105ee1 100644 --- a/pkg/utils/datetime_test.go +++ b/pkg/utils/datetime_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "github.com/mark3labs/mcp-go/mcp" + mcp "github.com/kagent-dev/tools/internal/mcp" ) // Test the actual MCP tool handler functions @@ -13,9 +13,8 @@ import ( func TestHandleGetCurrentDateTimeTool(t *testing.T) { ctx := context.Background() - request := mcp.CallToolRequest{} - result, err := handleGetCurrentDateTimeTool(ctx, request) + result, _, err := handleGetCurrentDateTimeTool(ctx, &mcp.CallToolRequest{}, datetimeInput{}) if err != nil { t.Fatalf("handleGetCurrentDateTimeTool failed: %v", err) } @@ -30,7 +29,7 @@ func TestHandleGetCurrentDateTimeTool(t *testing.T) { // Verify the result is a valid RFC3339 timestamp (ISO 8601 format) if len(result.Content) > 0 { - if textContent, ok := result.Content[0].(mcp.TextContent); ok { + if textContent, ok := result.Content[0].(*mcp.TextContent); ok { _, err := time.Parse(time.RFC3339, textContent.Text) if err != nil { t.Errorf("Result is not valid RFC3339 timestamp: %v", err) @@ -51,10 +50,8 @@ func TestHandleGetCurrentDateTimeTool(t *testing.T) { func TestHandleGetCurrentDateTimeToolNoParameters(t *testing.T) { // Test that the tool works without any parameters (as per Python implementation) ctx := context.Background() - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{} // Empty arguments - result, err := handleGetCurrentDateTimeTool(ctx, request) + result, _, err := handleGetCurrentDateTimeTool(ctx, &mcp.CallToolRequest{}, datetimeInput{}) if err != nil { t.Fatalf("handleGetCurrentDateTimeTool failed with empty args: %v", err) } @@ -69,7 +66,7 @@ func TestHandleGetCurrentDateTimeToolNoParameters(t *testing.T) { // Verify we get a valid timestamp if len(result.Content) > 0 { - if textContent, ok := result.Content[0].(mcp.TextContent); ok { + if textContent, ok := result.Content[0].(*mcp.TextContent); ok { _, err := time.Parse(time.RFC3339, textContent.Text) if err != nil { t.Errorf("Result is not valid RFC3339 timestamp: %v", err) @@ -85,15 +82,14 @@ func TestHandleGetCurrentDateTimeToolNoParameters(t *testing.T) { func TestDateTimeFormatConsistency(t *testing.T) { // Test that our Go implementation produces ISO 8601 format consistent with Python ctx := context.Background() - request := mcp.CallToolRequest{} - result, err := handleGetCurrentDateTimeTool(ctx, request) + result, _, err := handleGetCurrentDateTimeTool(ctx, &mcp.CallToolRequest{}, datetimeInput{}) if err != nil { t.Fatalf("handleGetCurrentDateTimeTool failed: %v", err) } if len(result.Content) > 0 { - if textContent, ok := result.Content[0].(mcp.TextContent); ok { + if textContent, ok := result.Content[0].(*mcp.TextContent); ok { timestamp := textContent.Text // Check that it follows RFC3339 format (which is ISO 8601 compliant) diff --git a/test/e2e/helpers_test.go b/test/e2e/helpers_test.go index 8f6d5221..70da3e46 100644 --- a/test/e2e/helpers_test.go +++ b/test/e2e/helpers_test.go @@ -15,9 +15,7 @@ import ( "time" "github.com/kagent-dev/tools/internal/commands" - "github.com/mark3labs/mcp-go/client" - "github.com/mark3labs/mcp-go/client/transport" - "github.com/mark3labs/mcp-go/mcp" + mcp "github.com/modelcontextprotocol/go-sdk/mcp" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -164,10 +162,10 @@ func (ts *TestServer) Stop() error { return nil } -// MCPClient represents a client for communicating with the MCP server using the official mcp-go client +// MCPClient represents a client for communicating with the MCP server using the official go-sdk client type MCPClient struct { - client *client.Client - log *slog.Logger + session *mcp.ClientSession + log *slog.Logger } // InstallKAgentTools installs KAgent Tools using helm in the specified namespace @@ -247,42 +245,28 @@ func InstallKAgentTools(namespace string, releaseName string) { Expect(nodePort).To(Equal("30885")) } -// GetMCPClient creates a new MCP client configured for the e2e test environment using the official mcp-go client +// GetMCPClient creates a new MCP client configured for the e2e test environment using the official go-sdk client func GetMCPClient() (*MCPClient, error) { - // Create HTTP transport for the MCP server with timeout long enough for operations like Istio installation - httpTransport, err := transport.NewStreamableHTTP("http://127.0.0.1:30885/mcp", transport.WithHTTPTimeout(180*time.Second)) - if err != nil { - return nil, fmt.Errorf("failed to create HTTP transport: %w", err) + // HTTP timeout long enough for operations like Istio installation. + httpTransport := &mcp.StreamableClientTransport{ + Endpoint: "http://127.0.0.1:30885/mcp", + HTTPClient: &http.Client{Timeout: 180 * time.Second}, } - // Create the official MCP client - mcpClient := client.NewClient(httpTransport) + mcpClient := mcp.NewClient(&mcp.Implementation{Name: "e2e-test-client", Version: "1.0.0"}, nil) - // Start the client + // Connect performs the initialization handshake. ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() - if err := mcpClient.Start(ctx); err != nil { - return nil, fmt.Errorf("failed to start MCP client: %w", err) - } - - // Initialize the client - initRequest := mcp.InitializeRequest{} - initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION - initRequest.Params.ClientInfo = mcp.Implementation{ - Name: "e2e-test-client", - Version: "1.0.0", - } - initRequest.Params.Capabilities = mcp.ClientCapabilities{} - - _, err = mcpClient.Initialize(ctx, initRequest) + session, err := mcpClient.Connect(ctx, httpTransport, nil) if err != nil { - return nil, fmt.Errorf("failed to initialize MCP client: %w", err) + return nil, fmt.Errorf("failed to connect MCP client: %w", err) } mcpHelper := &MCPClient{ - client: mcpClient, - log: slog.Default(), + session: session, + log: slog.Default(), } // Validate connection by listing tools @@ -299,13 +283,11 @@ func (c *MCPClient) listTools() ([]interface{}, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - request := mcp.ListToolsRequest{} - result, err := c.client.ListTools(ctx, request) + result, err := c.session.ListTools(ctx, &mcp.ListToolsParams{}) if err != nil { return nil, err } - // Convert tools to interface{} slice for compatibility tools := make([]interface{}, len(result.Tools)) for i, tool := range result.Tools { tools[i] = tool @@ -329,19 +311,15 @@ func (c *MCPClient) k8sListResources(resourceType string) (interface{}, error) { Output: "json", } - request := mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Name: "k8s_get_resources", - Arguments: arguments, - }, - } - - result, err := c.client.CallTool(ctx, request) + result, err := c.session.CallTool(ctx, &mcp.CallToolParams{ + Name: "k8s_get_resources", + Arguments: arguments, + }) if err != nil { return nil, err } if result.IsError { - return nil, fmt.Errorf("tool call failed: %s", result.Content) + return nil, fmt.Errorf("tool call failed: %v", result.Content) } return result, nil } @@ -361,19 +339,15 @@ func (c *MCPClient) helmListReleases() (interface{}, error) { Output: "json", } - request := mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Name: "helm_list_releases", - Arguments: arguments, - }, - } - - result, err := c.client.CallTool(ctx, request) + result, err := c.session.CallTool(ctx, &mcp.CallToolParams{ + Name: "helm_list_releases", + Arguments: arguments, + }) if err != nil { return nil, err } if result.IsError { - return nil, fmt.Errorf("tool call failed: %s", result.Content) + return nil, fmt.Errorf("tool call failed: %v", result.Content) } return result, nil } @@ -391,19 +365,15 @@ func (c *MCPClient) istioInstall(profile string) (interface{}, error) { Profile: profile, } - request := mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Name: "istio_install_istio", - Arguments: arguments, - }, - } - - result, err := c.client.CallTool(ctx, request) + result, err := c.session.CallTool(ctx, &mcp.CallToolParams{ + Name: "istio_install_istio", + Arguments: arguments, + }) if err != nil { return nil, err } if result.IsError { - return nil, fmt.Errorf("tool call failed: %s", result.Content) + return nil, fmt.Errorf("tool call failed: %v", result.Content) } return result, nil } @@ -423,19 +393,15 @@ func (c *MCPClient) argoRolloutsList(namespace string) (interface{}, error) { Output: "json", } - request := mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Name: "argo_rollouts_list", - Arguments: arguments, - }, - } - - result, err := c.client.CallTool(ctx, request) + result, err := c.session.CallTool(ctx, &mcp.CallToolParams{ + Name: "argo_rollouts_list", + Arguments: arguments, + }) if err != nil { return nil, err } if result.IsError { - return nil, fmt.Errorf("tool call failed: %s", result.Content) + return nil, fmt.Errorf("tool call failed: %v", result.Content) } return result, nil } @@ -445,14 +411,10 @@ func (c *MCPClient) ciliumStatus() (interface{}, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - request := mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Name: "cilium_status_and_version", - Arguments: nil, - }, - } - - result, err := c.client.CallTool(ctx, request) + result, err := c.session.CallTool(ctx, &mcp.CallToolParams{ + Name: "cilium_status_and_version", + Arguments: nil, + }) if err != nil { return nil, err } From 303449c4f50a1928193f8d2d197f1e62fca615b2 Mon Sep 17 00:00:00 2001 From: Dmytro Rashko Date: Tue, 30 Jun 2026 15:24:18 +0200 Subject: [PATCH 02/21] fix(security): resolve govulncheck CVEs and relax MCP SDK input schema Bump dependencies to clear all 8 govulncheck source-mode findings: - golang.org/x/net 0.50.0 -> 0.55.0 (GO-2026-5026, GO-2026-4918, GO-2026-4559) - github.com/cilium/cilium 1.19.0 -> 1.19.3 (GO-2026-5400, GO-2026-4856) - go.opentelemetry.io/otel 1.40.0 -> 1.43.0, otlploghttp 0.16.0 -> 0.19.0 (GO-2026-4985) - github.com/go-jose/go-jose/v4 4.1.3 -> 4.1.4 (GO-2026-4945) - github.com/anchore/syft 1.32.0 -> 1.42.3 (GO-2026-4809) The only remaining govulncheck reports are 5 uncalled github.com/docker/docker advisories that have no fixed version available upstream. Also carry the in-flight go-sdk migration work: relax the inferred input schema (drop the auto-Required list and allow additional properties) so tools keep the pre-migration calling contract, add the toolResultText e2e helper for readable failures, and document the typed MCP tool I/O conventions. Signed-off-by: Dmytro Rashko --- AGENTS.md | 21 +++- go.mod | 105 ++++++++++--------- go.sum | 214 +++++++++++++++++++-------------------- internal/mcp/mcp.go | 33 +++++- internal/mcp/mcp_test.go | 49 +++++++++ test/e2e/helpers_test.go | 25 ++++- 6 files changed, 276 insertions(+), 171 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e0fc80c1..d429130b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -138,6 +138,16 @@ func RegisterTools(server *server.MCPServer, readOnly bool) { Handler functions are prefixed with `handle` (e.g., `handleKubectlGetEnhanced`, `handleHelmList`). +### Typed MCP Inputs and Outputs + +All MCP tool inputs and outputs must be strongly typed: + +- Define a concrete input struct for every tool with `json` and `jsonschema` tags. +- Define a concrete output DTO for every structured response. Raw CLI text may use a shared typed wrapper such as `TextOutput` with an `Output string` field. +- When using the Go MCP SDK wrapper, do not register handlers with `Out=any`; typed outputs enable output schema inference and validation. +- Do not use `any`, `interface{}`, `map[string]any`, `map[string]interface{}`, `[]any`, or `[]interface{}` for handler inputs, handler outputs, public response DTOs, or tests. +- If a payload is genuinely dynamic JSON, isolate it as `json.RawMessage` behind a typed envelope instead of spreading loose maps through handlers. + ### CommandBuilder Pattern Use the fluent `CommandBuilder` interface for executing CLI commands: @@ -228,6 +238,7 @@ ctx := cmd.WithShellExecutor(context.Background(), mockExecutor) - Unit tests: co-located `*_test.go` files in each package - E2E tests: `test/e2e/` (requires Kind cluster) - All public functions require unit tests +- Decode structured tool results into the same output DTOs used by production code. Avoid `map[string]interface{}` / `[]interface{}` assertions in tests. --- @@ -281,6 +292,7 @@ Types: `feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `perf`, `ci` - Do not return Go errors from MCP handlers — use `ToolError.ToMCPResult()` instead. - Do not duplicate logic across providers — extract to `internal/` packages. - Do not bypass the cache for read operations. +- Do not use untyped maps or `any` for MCP tool input/output schemas or public response bodies. - Do not add new tool providers without a corresponding `RegisterTools` function. - Do not commit without running `make fmt && make lint && make test`. @@ -294,10 +306,11 @@ Types: `feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `perf`, `ci` 4. Register the provider in `cmd/main.go` inside `registerMCP()`. 5. Add input validation using `internal/security/`. 6. Use `CommandBuilder` for CLI execution. -7. Return errors via `ToolError.ToMCPResult()`. -8. Write unit tests with mock shell executor (80% coverage minimum). -9. Add E2E tests if the tool interacts with a cluster. -10. Run `make fmt && make lint && make test` before submitting. +7. Define concrete typed input and output DTOs; avoid `any`, `interface{}`, and untyped maps. +8. Return errors via `ToolError.ToMCPResult()`. +9. Write unit tests with mock shell executor (80% coverage minimum). +10. Add E2E tests if the tool interacts with a cluster. +11. Run `make fmt && make lint && make test` before submitting. --- diff --git a/go.mod b/go.mod index 3bd83439..b95fa8ca 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/kagent-dev/tools go 1.26.4 require ( + github.com/google/jsonschema-go v0.4.3 github.com/joho/godotenv v1.5.1 github.com/kubescape/k8s-interface v0.0.203 github.com/kubescape/storage v0.0.239 @@ -14,17 +15,17 @@ require ( github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 github.com/tmc/langchaingo v0.1.14 - go.opentelemetry.io/otel v1.40.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 - go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 - go.opentelemetry.io/otel/metric v1.40.0 - go.opentelemetry.io/otel/sdk v1.40.0 - go.opentelemetry.io/otel/trace v1.40.0 - k8s.io/api v0.35.1 - k8s.io/apiextensions-apiserver v0.35.1 - k8s.io/apimachinery v0.35.1 - k8s.io/client-go v0.35.1 + go.opentelemetry.io/otel v1.43.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 + go.opentelemetry.io/otel/metric v1.43.0 + go.opentelemetry.io/otel/sdk v1.43.0 + go.opentelemetry.io/otel/trace v1.43.0 + k8s.io/api v0.35.3 + k8s.io/apiextensions-apiserver v0.35.3 + k8s.io/apimachinery v0.35.3 + k8s.io/client-go v0.35.3 ) require ( @@ -32,8 +33,8 @@ require ( github.com/acobaugh/osrelease v0.1.0 // indirect github.com/anchore/go-logger v0.0.0-20250318195838-07ae343dd722 // indirect github.com/anchore/packageurl-go v0.1.1-0.20250220190351-d62adb6e1115 // indirect - github.com/anchore/stereoscope v0.1.9 // indirect - github.com/anchore/syft v1.32.0 // indirect + github.com/anchore/stereoscope v0.1.22 // indirect + github.com/anchore/syft v1.42.3 // indirect github.com/armosec/armoapi-go v0.0.674 // indirect github.com/armosec/gojay v1.2.17 // indirect github.com/armosec/utils-go v0.0.58 // indirect @@ -41,22 +42,22 @@ require ( github.com/becheran/wildmatch-go v1.0.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect - github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect + github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect github.com/briandowns/spinner v1.23.2 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/cilium/cilium v1.19.0 // indirect - github.com/cilium/ebpf v0.20.1-0.20260108141042-f7e80f49188b // indirect + github.com/cilium/cilium v1.19.3 // indirect + github.com/cilium/ebpf v0.20.1-0.20260218191617-ee67e7f43dd9 // indirect github.com/cilium/hive v0.0.1 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containers/common v0.63.0 // indirect github.com/coreos/go-oidc/v3 v3.17.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dlclark/regexp2 v1.11.5 // indirect - github.com/docker/cli v28.3.3+incompatible // indirect + github.com/docker/cli v29.3.0+incompatible // indirect github.com/docker/docker v28.5.2+incompatible // indirect - github.com/docker/docker-credential-helpers v0.9.3 // indirect + github.com/docker/docker-credential-helpers v0.9.5 // indirect github.com/docker/go-connections v0.6.0 // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/facebookincubator/nvdtools v0.1.5 // indirect @@ -64,10 +65,9 @@ require ( github.com/francoispqt/gojay v1.2.13 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.10 // indirect - github.com/github/go-spdx/v2 v2.3.3 // indirect - github.com/gkampitakis/go-snaps v0.5.19 // indirect - github.com/go-jose/go-jose/v4 v4.1.3 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/github/go-spdx/v2 v2.4.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/analysis v0.24.2 // indirect @@ -92,16 +92,14 @@ require ( github.com/go-openapi/validate v0.25.1 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect - github.com/goccy/go-yaml v1.19.2 // indirect - github.com/gohugoio/hashstructure v0.5.0 // indirect + github.com/gohugoio/hashstructure v0.6.0 // indirect github.com/google/gnostic-models v0.7.1 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/go-containerregistry v0.20.6 // indirect - github.com/google/jsonschema-go v0.4.3 // indirect + github.com/google/go-containerregistry v0.21.2 // indirect github.com/google/licensecheck v0.3.1 // indirect github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.8 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -122,11 +120,10 @@ require ( github.com/olvrng/ujson v1.1.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect - github.com/opencontainers/runtime-spec v1.2.1 // indirect + github.com/opencontainers/runtime-spec v1.3.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/pkoukk/tiktoken-go v0.1.8 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/common v0.67.5 // indirect @@ -137,7 +134,7 @@ require ( github.com/seccomp/libseccomp-golang v0.10.0 // indirect github.com/segmentio/asm v1.1.3 // indirect github.com/segmentio/encoding v0.5.4 // indirect - github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af // indirect + github.com/sirupsen/logrus v1.9.4 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect @@ -153,7 +150,7 @@ require ( github.com/vishvananda/netlink v1.3.2-0.20260109214200-c6faf428e8f8 // indirect github.com/vishvananda/netns v0.0.5 // indirect github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651 // indirect - github.com/wagoodman/go-progress v0.0.0-20230925121702-07e42b3cdba0 // indirect + github.com/wagoodman/go-progress v0.0.0-20260303201901-10176f79b2c0 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/yl2chen/cidranger v1.0.2 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect @@ -161,41 +158,41 @@ require ( go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/bridges/otelslog v0.15.0 // indirect go.opentelemetry.io/contrib/instrumentation/runtime v0.65.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect - go.opentelemetry.io/otel/log v0.16.0 // indirect - go.opentelemetry.io/otel/sdk/log v0.16.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect - go.opentelemetry.io/proto/otlp v1.9.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect + go.opentelemetry.io/otel/log v0.19.0 // indirect + go.opentelemetry.io/otel/sdk/log v0.19.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/dig v1.19.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect - golang.org/x/mod v0.33.0 // indirect - golang.org/x/net v0.50.0 // indirect - golang.org/x/oauth2 v0.35.0 // indirect - golang.org/x/sync v0.19.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.14.0 // indirect - golang.org/x/tools v0.42.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.3 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/term v0.43.0 // indirect + golang.org/x/text v0.37.0 // indirect + golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.44.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/grpc v1.80.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/apiserver v0.35.1 // indirect - k8s.io/component-base v0.35.1 // indirect + k8s.io/apiserver v0.35.3 // indirect + k8s.io/component-base v0.35.3 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 // indirect - k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect - sigs.k8s.io/controller-runtime v0.23.1 // indirect + k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 // indirect + sigs.k8s.io/controller-runtime v0.23.3 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect diff --git a/go.sum b/go.sum index 145106da..5673a399 100644 --- a/go.sum +++ b/go.sum @@ -80,10 +80,10 @@ github.com/anchore/go-logger v0.0.0-20250318195838-07ae343dd722 h1:2SqmFgE7h+Ql4 github.com/anchore/go-logger v0.0.0-20250318195838-07ae343dd722/go.mod h1:oFuE8YuTCM+spgMXhePGzk3asS94yO9biUfDzVTFqNw= github.com/anchore/packageurl-go v0.1.1-0.20250220190351-d62adb6e1115 h1:ZyRCmiEjnoGJZ1+Ah0ZZ/mKKqNhGcUZBl0s7PTTDzvY= github.com/anchore/packageurl-go v0.1.1-0.20250220190351-d62adb6e1115/go.mod h1:KoYIv7tdP5+CC9VGkeZV4/vGCKsY55VvoG+5dadg4YI= -github.com/anchore/stereoscope v0.1.9 h1:Nhvk8g6PRx9ubaJU4asAhD3fGcY5HKXZCDGkxI2e0sI= -github.com/anchore/stereoscope v0.1.9/go.mod h1:YkrCtDgz7A+w6Ggd0yxU9q58CerqQFwYARS+F2RvLQQ= -github.com/anchore/syft v1.32.0 h1:JcX9W+P/Xjv5DNg3TNBtwiEyZommuTaP16/NC9r0Yfo= -github.com/anchore/syft v1.32.0/go.mod h1:E6Kd4iBM2ljUOUQvSt7hVK6vBwaHkMXwcvBZmGMSY5o= +github.com/anchore/stereoscope v0.1.22 h1:L807G/kk0WZzOCGuRGF7knxMKzwW2PGdbPVRystryd8= +github.com/anchore/stereoscope v0.1.22/go.mod h1:FikPtAb/WnbqwgLHAvQA9O+fWez0K4pbjxzghz++iy4= +github.com/anchore/syft v1.42.3 h1:eIeeGyqfXm/C8wpBWU50xFbOjdL37VbLatMj9nEJ6n4= +github.com/anchore/syft v1.42.3/go.mod h1:i2PZ+276IdPcnd/n32aeIv849iO/QqdjRknbIc39yL0= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= @@ -109,8 +109,8 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= -github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= -github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= +github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g= github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= @@ -129,10 +129,10 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/cilium/cilium v1.19.0 h1:hfPhb9TcoG3fRMA1/ExBFihEX1NpEWcUaV1jXJTkQVo= -github.com/cilium/cilium v1.19.0/go.mod h1:yDDJNQbgFXDFdaVWlAWD8M3n8UZwHUk8Bo8JGgllx7o= -github.com/cilium/ebpf v0.20.1-0.20260108141042-f7e80f49188b h1:ubt7adiPfM2/6QrjNI8T+LAe4J7KHVkAMbEJ3+LLTXk= -github.com/cilium/ebpf v0.20.1-0.20260108141042-f7e80f49188b/go.mod h1:dM+AMI6FkW5LOkzikdefUmzK0z81o7GqiKXon7D1F58= +github.com/cilium/cilium v1.19.3 h1:foJrHPk45HwshOd8Qf/kptf9JxPfNySkIDKsetZa9+Y= +github.com/cilium/cilium v1.19.3/go.mod h1:cd4P5LHAg4hyyZexrM4D055t5JwyudeAcZ/Jub9VxJY= +github.com/cilium/ebpf v0.20.1-0.20260218191617-ee67e7f43dd9 h1:hQW7n5ePt/HDgeZLcyT3pFENyfa6vmaGU7M+tq2pa64= +github.com/cilium/ebpf v0.20.1-0.20260218191617-ee67e7f43dd9/go.mod h1:EGj6HpG/oejvbTAsMWwlA4UbMU7WBAgILd+9OSvcDTc= github.com/cilium/hive v0.0.1 h1:NrHJ1DD74B77ib4UhujEQ0j4nCQmyKOic9qtwrreJRs= github.com/cilium/hive v0.0.1/go.mod h1:4/8FBMcTjVdkrNNWaB7t3QqaU4kZDJLJ1leKVP9GjEI= github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= @@ -166,12 +166,12 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8Yc github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1/go.mod h1:+hnT3ywWDTAFrW5aE+u2Sa/wT555ZqwoCS+pk3p6ry4= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/docker/cli v28.3.3+incompatible h1:fp9ZHAr1WWPGdIWBM1b3zLtgCF+83gRdVMTJsUeiyAo= -github.com/docker/cli v28.3.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/cli v29.3.0+incompatible h1:z3iWveU7h19Pqx7alZES8j+IeFQZ1lhTwb2F+V9SVvk= +github.com/docker/cli v29.3.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= -github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= +github.com/docker/docker-credential-helpers v0.9.5 h1:EFNN8DHvaiK8zVqFA2DT6BjXE0GzfLOZ38ggPTKePkY= +github.com/docker/docker-credential-helpers v0.9.5/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= @@ -211,22 +211,22 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= -github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= -github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/github/go-spdx/v2 v2.3.3 h1:QI7evnHWEfWkT54eJwkoV/f3a0xD3gLlnVmT5wQG6LE= -github.com/github/go-spdx/v2 v2.3.3/go.mod h1:2ZxKsOhvBp+OYBDlsGnUMcchLeo2mrpEBn2L1C+U3IQ= +github.com/github/go-spdx/v2 v2.4.0 h1:+4IwVwJJbm3rzvrQ6P1nI9BDMcy3la4RchRy5uehV/M= +github.com/github/go-spdx/v2 v2.4.0/go.mod h1:/5rwgS0txhGtRdUZwc02bTglzg6HK3FfuEbECKlK2Sg= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= -github.com/gkampitakis/go-snaps v0.5.19 h1:hUJlCQOpTt1M+kSisMwioDWZDWpDtdAvUhvWCx1YGW0= -github.com/gkampitakis/go-snaps v0.5.19/go.mod h1:gC3YqxQTPyIXvQrw/Vpt3a8VqR1MO8sVpZFWN4DGwNs= +github.com/gkampitakis/go-snaps v0.5.20 h1:FGKonEeQPJ12t7RQj6cTPa881fl5c8HYarMLv5vP7sg= +github.com/gkampitakis/go-snaps v0.5.20/go.mod h1:gC3YqxQTPyIXvQrw/Vpt3a8VqR1MO8sVpZFWN4DGwNs= github.com/gliderlabs/ssh v0.1.1/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= -github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= @@ -299,8 +299,8 @@ github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/gohugoio/hashstructure v0.5.0 h1:G2fjSBU36RdwEJBWJ+919ERvOVqAg9tfcYp47K9swqg= -github.com/gohugoio/hashstructure v0.5.0/go.mod h1:Ser0TniXuu/eauYmrwM4o64EBvySxNzITEOLlm4igec= +github.com/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxUW+NltUg= +github.com/gohugoio/hashstructure v0.6.0/go.mod h1:lapVLk9XidheHG1IQ4ZSbyYrXcaILU1ZEP/+vno5rBQ= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= @@ -356,8 +356,8 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-containerregistry v0.20.6 h1:cvWX87UxxLgaH76b4hIvya6Dzz9qHB31qAwjAohdSTU= -github.com/google/go-containerregistry v0.20.6/go.mod h1:T0x8MuoAoKX/873bkeSfLD2FAkwCDf9/HZgsFJ02E2Y= +github.com/google/go-containerregistry v0.21.2 h1:vYaMU4nU55JJGFC9JR/s8NZcTjbE9DBBbvusTW9NeS0= +github.com/google/go-containerregistry v0.21.2/go.mod h1:ctO5aCaewH4AK1AumSF5DPW+0+R+d2FmylMJdp5G7p0= github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -402,8 +402,8 @@ github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORR github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= github.com/grpc-ecosystem/grpc-gateway v1.5.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.8 h1:NpbJl/eVbvrGE0MJ6X16X9SAifesl6Fwxg/YmCvubRI= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.8/go.mod h1:mi7YA+gCzVem12exXy46ZespvGtX/lZmD/RLnQhVW7U= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/hashicorp/consul/api v1.11.0/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M= github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -555,8 +555,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -github.com/opencontainers/runtime-spec v1.2.1 h1:S4k4ryNgEpxW1dzyqffOmhI1BHYcjzU8lpJfSlR0xww= -github.com/opencontainers/runtime-spec v1.2.1/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= +github.com/opencontainers/runtime-spec v1.3.0 h1:YZupQUdctfhpZy3TM39nN9Ika5CBWT5diQ8ibYCRkxg= +github.com/opencontainers/runtime-spec v1.3.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= github.com/opencontainers/runtime-tools v0.9.1-0.20250303011046-260e151b8552 h1:CkXngT0nixZqQUPDVfwVs3GiuhfTqCMk0V+OoHpxIvA= github.com/opencontainers/runtime-tools v0.9.1-0.20250303011046-260e151b8552/go.mod h1:T487Kf80NeF2i0OyVXHiylg217e0buz8pQsa0T791RA= github.com/openzipkin/zipkin-go v0.1.1/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8= @@ -574,7 +574,6 @@ github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= @@ -659,8 +658,8 @@ github.com/shurcooL/users v0.0.0-20180125191416-49c67e49c537/go.mod h1:QJTqeLYED github.com/shurcooL/webdavfs v0.0.0-20170829043945-18c3829fa133/go.mod h1:hKmq5kWdCj2z2KEozexVbfEZIWiTjhE0+UjmZgPqehw= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af h1:Sp5TG9f7K39yfB+If0vjp97vuT74F72r8hfRpP8jLU0= -github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE= github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= @@ -733,8 +732,8 @@ github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zd github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651 h1:jIVmlAFIqV3d+DOxazTR9v+zgj8+VYuQBzPgBZvWBHA= github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651/go.mod h1:b26F2tHLqaoRQf8DywqzVaV1MQ9yvjb0OMcNl7Nxu20= -github.com/wagoodman/go-progress v0.0.0-20230925121702-07e42b3cdba0 h1:0KGbf+0SMg+UFy4e1A/CPVvXn21f1qtWdeJwxZFoQG8= -github.com/wagoodman/go-progress v0.0.0-20230925121702-07e42b3cdba0/go.mod h1:jLXFoL31zFaHKAAyZUh+sxiTDFe1L1ZHrcK2T1itVKA= +github.com/wagoodman/go-progress v0.0.0-20260303201901-10176f79b2c0 h1:EHsPe0Q0ANoLOZff1dBLAyeWLTA4sbPTpGI+2zb0FnM= +github.com/wagoodman/go-progress v0.0.0-20260303201901-10176f79b2c0/go.mod h1:g/D9uEUFp5YLyciwCpVsSOZOm56hfv4rzGJod6MlqIM= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= @@ -767,37 +766,37 @@ go.opentelemetry.io/contrib/bridges/otelslog v0.15.0 h1:yOYhGNPZseueTTvWp5iBD3/C go.opentelemetry.io/contrib/bridges/otelslog v0.15.0/go.mod h1:CvaNVqIfcybc+7xqZNubbE+26K6P7AKZF/l0lE2kdCk= go.opentelemetry.io/contrib/instrumentation/runtime v0.65.0 h1:n8qdwrebNEHF/zHpueuZ4OacdJ8CdSaP7xef9WRZXTQ= go.opentelemetry.io/contrib/instrumentation/runtime v0.65.0/go.mod h1:Z1pjGxUL3nJ/IbDDfL6rBD0Xbz7ZOViRqrIUg4l1CYE= -go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= -go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= -go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 h1:djrxvDxAe44mJUrKataUbOhCKhR3F8QCyWucO16hTQs= -go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0/go.mod h1:dt3nxpQEiSoKvfTVxp3TUg5fHPLhKtbcnN3Z1I1ePD0= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0 h1:9y5sHvAxWzft1WQ4BwqcvA+IFVUJ1Ya75mSAUnFEVwE= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0/go.mod h1:eQqT90eR3X5Dbs1g9YSM30RavwLF725Ris5/XSXWvqE= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 h1:DvJDOPmSWQHWywQS6lKL+pb8s3gBLOZUtw4N+mavW1I= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0/go.mod h1:EtekO9DEJb4/jRyN4v4Qjc2yA7AtfCBuz2FynRUWTXs= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 h1:MzfofMZN8ulNqobCmCAVbqVL5syHw+eB2qPRkCMA/fQ= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0/go.mod h1:E73G9UFtKRXrxhBsHtG00TB5WxX57lpsQzogDkqBTz8= -go.opentelemetry.io/otel/log v0.16.0 h1:DeuBPqCi6pQwtCK0pO4fvMB5eBq6sNxEnuTs88pjsN4= -go.opentelemetry.io/otel/log v0.16.0/go.mod h1:rWsmqNVTLIA8UnwYVOItjyEZDbKIkMxdQunsIhpUMes= -go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= -go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= -go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= -go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= -go.opentelemetry.io/otel/sdk/log v0.16.0 h1:e/b4bdlQwC5fnGtG3dlXUrNOnP7c8YLVSpSfEBIkTnI= -go.opentelemetry.io/otel/sdk/log v0.16.0/go.mod h1:JKfP3T6ycy7QEuv3Hj8oKDy7KItrEkus8XJE6EoSzw4= -go.opentelemetry.io/otel/sdk/log/logtest v0.16.0 h1:/XVkpZ41rVRTP4DfMgYv1nEtNmf65XPPyAdqV90TMy4= -go.opentelemetry.io/otel/sdk/log/logtest v0.16.0/go.mod h1:iOOPgQr5MY9oac/F5W86mXdeyWZGleIx3uXO98X2R6Y= -go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= -go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= -go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= -go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 h1:HIBTQ3VO5aupLKjC90JgMqpezVXwFuq6Ryjn0/izoag= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0/go.mod h1:ji9vId85hMxqfvICA0Jt8JqEdrXaAkcpkI9HPXya0ro= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU= +go.opentelemetry.io/otel/log v0.19.0 h1:KUZs/GOsw79TBBMfDWsXS+KZ4g2Ckzksd1ymzsIEbo4= +go.opentelemetry.io/otel/log v0.19.0/go.mod h1:5DQYeGmxVIr4n0/BcJvF4upsraHjg6vudJJpnkL6Ipk= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/log v0.19.0 h1:scYVLqT22D2gqXItnWiocLUKGH9yvkkeql5dBDiXyko= +go.opentelemetry.io/otel/sdk/log v0.19.0/go.mod h1:vFBowwXGLlW9AvpuF7bMgnNI95LiW10szrOdvzBHlAg= +go.opentelemetry.io/otel/sdk/log/logtest v0.19.0 h1:BEbF7ZBB6qQloV/Ub1+3NQoOUnVtcGkU3XX4Ws3GQfk= +go.opentelemetry.io/otel/sdk/log/logtest v0.19.0/go.mod h1:Lua81/3yM0wOmoHTokLj9y9ADeA02v1naRrVrkAZuKk= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= -go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= -go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= @@ -866,8 +865,8 @@ golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -914,8 +913,8 @@ golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -935,8 +934,8 @@ golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/perf v0.0.0-20180704124530-6e6d33e29852/go.mod h1:JLpeXjPJfIyPr5TlbXLkXWLhP8nz10XfvxElABhCtcw= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -949,8 +948,8 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -1019,13 +1018,12 @@ golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= -golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1035,14 +1033,14 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= -golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20181030000716-a0a13e073c7b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -1099,14 +1097,14 @@ golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/api v0.0.0-20180910000450-7ca32eb868bf/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= google.golang.org/api v0.0.0-20181030000543-1d582fd0359e/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= google.golang.org/api v0.1.0/go.mod h1:UGEZY7KEX120AnNLIHFMKIo4obdJhkp2tPbaPlQx13Y= @@ -1218,10 +1216,10 @@ google.golang.org/genproto v0.0.0-20211129164237-f09f9a12af12/go.mod h1:5CzLGKJ6 google.golang.org/genproto v0.0.0-20211203200212-54befc351ae9/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0= -google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio= google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= @@ -1252,8 +1250,8 @@ google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnD google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= @@ -1304,29 +1302,29 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -k8s.io/api v0.35.1 h1:0PO/1FhlK/EQNVK5+txc4FuhQibV25VLSdLMmGpDE/Q= -k8s.io/api v0.35.1/go.mod h1:28uR9xlXWml9eT0uaGo6y71xK86JBELShLy4wR1XtxM= -k8s.io/apiextensions-apiserver v0.35.1 h1:p5vvALkknlOcAqARwjS20kJffgzHqwyQRM8vHLwgU7w= -k8s.io/apiextensions-apiserver v0.35.1/go.mod h1:2CN4fe1GZ3HMe4wBr25qXyJnJyZaquy4nNlNmb3R7AQ= -k8s.io/apimachinery v0.35.1 h1:yxO6gV555P1YV0SANtnTjXYfiivaTPvCTKX6w6qdDsU= -k8s.io/apimachinery v0.35.1/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= -k8s.io/apiserver v0.35.1 h1:potxdhhTL4i6AYAa2QCwtlhtB1eCdWQFvJV6fXgJzxs= -k8s.io/apiserver v0.35.1/go.mod h1:BiL6Dd3A2I/0lBnteXfWmCFobHM39vt5+hJQd7Lbpi4= -k8s.io/client-go v0.35.1 h1:+eSfZHwuo/I19PaSxqumjqZ9l5XiTEKbIaJ+j1wLcLM= -k8s.io/client-go v0.35.1/go.mod h1:1p1KxDt3a0ruRfc/pG4qT/3oHmUj1AhSHEcxNSGg+OA= -k8s.io/component-base v0.35.1 h1:XgvpRf4srp037QWfGBLFsYMUQJkE5yMa94UsJU7pmcE= -k8s.io/component-base v0.35.1/go.mod h1:HI/6jXlwkiOL5zL9bqA3en1Ygv60F03oEpnuU1G56Bs= +k8s.io/api v0.35.3 h1:pA2fiBc6+N9PDf7SAiluKGEBuScsTzd2uYBkA5RzNWQ= +k8s.io/api v0.35.3/go.mod h1:9Y9tkBcFwKNq2sxwZTQh1Njh9qHl81D0As56tu42GA4= +k8s.io/apiextensions-apiserver v0.35.3 h1:2fQUhEO7P17sijylbdwt0nBdXP0TvHrHj0KeqHD8FiU= +k8s.io/apiextensions-apiserver v0.35.3/go.mod h1:tK4Kz58ykRpwAEkXUb634HD1ZAegEElktz/B3jgETd8= +k8s.io/apimachinery v0.35.3 h1:MeaUwQCV3tjKP4bcwWGgZ/cp/vpsRnQzqO6J6tJyoF8= +k8s.io/apimachinery v0.35.3/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/apiserver v0.35.3 h1:D2eIcfJ05hEAEewoSDg+05e0aSRwx8Y4Agvd/wiomUI= +k8s.io/apiserver v0.35.3/go.mod h1:JI0n9bHYzSgIxgIrfe21dbduJ9NHzKJ6RchcsmIKWKY= +k8s.io/client-go v0.35.3 h1:s1lZbpN4uI6IxeTM2cpdtrwHcSOBML1ODNTCCfsP1pg= +k8s.io/client-go v0.35.3/go.mod h1:RzoXkc0mzpWIDvBrRnD+VlfXP+lRzqQjCmKtiwZ8Q9c= +k8s.io/component-base v0.35.3 h1:mbKbzoIMy7JDWS/wqZobYW1JDVRn/RKRaoMQHP9c4P0= +k8s.io/component-base v0.35.3/go.mod h1:IZ8LEG30kPN4Et5NeC7vjNv5aU73ku5MS15iZyvyMYk= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 h1:HhDfevmPS+OalTjQRKbTHppRIz01AWi8s45TMXStgYY= k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= -k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= -k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM= +k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= -sigs.k8s.io/controller-runtime v0.23.1 h1:TjJSM80Nf43Mg21+RCy3J70aj/W6KyvDtOlpKf+PupE= -sigs.k8s.io/controller-runtime v0.23.1/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= +sigs.k8s.io/controller-runtime v0.23.3 h1:VjB/vhoPoA9l1kEKZHBMnQF33tdCLQKJtydy4iqwZ80= +sigs.k8s.io/controller-runtime v0.23.3/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index 7b41e13f..525e206b 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -8,9 +8,11 @@ package mcp import ( "context" "net/http" + "reflect" "sync" "time" + "github.com/google/jsonschema-go/jsonschema" "github.com/kagent-dev/tools/internal/metrics" sdk "github.com/modelcontextprotocol/go-sdk/mcp" "go.opentelemetry.io/otel" @@ -65,13 +67,42 @@ func Header(req *sdk.CallToolRequest) http.Header { var providerByTool sync.Map // AddTool registers a typed tool and records its provider for metrics. The input -// schema is inferred from In's json/jsonschema struct tags by the SDK. +// schema is inferred from In's json/jsonschema struct tags by the SDK, then +// relaxed by relaxInputSchema to preserve the pre-migration tool-calling contract. func AddTool[In, Out any](s *sdk.Server, provider string, t *sdk.Tool, h sdk.ToolHandlerFor[In, Out]) { providerByTool.Store(t.Name, provider) metrics.KagentToolsMCPRegisteredTools.WithLabelValues(t.Name, provider).Set(1) + relaxInputSchema[In](t) sdk.AddTool(s, t, h) } +// relaxInputSchema restores the input-validation contract the providers were +// written against. The previous mark3labs API made every argument optional +// unless explicitly marked Required() and ignored unknown arguments. The go-sdk +// instead infers every non-omitempty struct field as required and sets +// additionalProperties:false, so a client that omits an optional field (or sends +// an extra one) is rejected before the handler runs. That silently broke tools +// such as k8s_get_resources, where only resource_type is truly required. +// +// We re-infer the schema, drop the required list, and allow additional +// properties. Handlers continue to validate their own mandatory inputs and +// return a tool error when one is missing, so correctness is unchanged. +func relaxInputSchema[In any](t *sdk.Tool) { + if t.InputSchema != nil { + return // caller supplied an explicit schema; respect it + } + if reflect.TypeFor[In]() == reflect.TypeFor[any]() { + return // SDK has dedicated handling for an "any" input + } + schema, err := jsonschema.For[In](nil) + if err != nil || schema.Type != "object" { + return // fall back to the SDK's own inference + } + schema.Required = nil + schema.AdditionalProperties = nil + t.InputSchema = schema +} + func providerOf(tool string) string { if v, ok := providerByTool.Load(tool); ok { return v.(string) diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go index 2289c776..37a4e7cf 100644 --- a/internal/mcp/mcp_test.go +++ b/internal/mcp/mcp_test.go @@ -6,6 +6,7 @@ import ( "net/http" "testing" + "github.com/google/jsonschema-go/jsonschema" "github.com/kagent-dev/tools/internal/metrics" sdk "github.com/modelcontextprotocol/go-sdk/mcp" promtest "github.com/prometheus/client_golang/prometheus/testutil" @@ -63,6 +64,54 @@ func TestAddToolRecordsProvider(t *testing.T) { } } +// TestAddToolRelaxesInputSchema is the regression test for the go-sdk migration +// bug where every non-omitempty input field became required and extra fields +// were rejected (additionalProperties:false). Pre-migration only explicitly +// marked fields were required and unknown fields were ignored. A client must be +// able to call a tool sending only the fields it cares about (e.g. +// k8s_get_resources with just resource_type), so the inferred Required list and +// additionalProperties restriction must be cleared. +func TestAddToolRelaxesInputSchema(t *testing.T) { + s := NewServer(&Implementation{Name: "t", Version: "v"}, nil) + + type in struct { + ResourceType string `json:"resource_type"` + ResourceName string `json:"resource_name"` + Namespace string `json:"namespace"` + AllNamespaces bool `json:"all_namespaces"` + Output string `json:"output"` + } + tool := &Tool{Name: "relax_tool"} + AddTool(s, "p", tool, func(_ context.Context, _ *CallToolRequest, _ in) (*CallToolResult, any, error) { + return NewToolResultText("ok"), nil, nil + }) + + schema, ok := tool.InputSchema.(*jsonschema.Schema) + if !ok { + t.Fatalf("InputSchema not set to *jsonschema.Schema, got %T", tool.InputSchema) + } + if len(schema.Required) != 0 { + t.Errorf("expected no required fields, got %v", schema.Required) + } + if schema.AdditionalProperties != nil { + t.Errorf("expected additionalProperties unconstrained, got %#v", schema.AdditionalProperties) + } + if _, present := schema.Properties["resource_type"]; !present { + t.Errorf("expected properties to be preserved, got %v", schema.Properties) + } + + // The relaxed schema must accept a payload that omits optional fields, which + // is exactly what the e2e client sends and what previously failed. + resolved, err := schema.Resolve(nil) + if err != nil { + t.Fatalf("resolve: %v", err) + } + partial := map[string]any{"resource_type": "namespace", "output": "json"} + if err := resolved.Validate(partial); err != nil { + t.Errorf("partial payload should validate, got: %v", err) + } +} + // TestToolMiddleware_IsErrorIncrementsFailureCounter is the regression test for // the bug identified in PR review: handlers signal tool-level failures via // NewToolResultError(...) (IsError=true, Go error=nil), so checking only diff --git a/test/e2e/helpers_test.go b/test/e2e/helpers_test.go index 70da3e46..901bac3f 100644 --- a/test/e2e/helpers_test.go +++ b/test/e2e/helpers_test.go @@ -20,6 +20,23 @@ import ( . "github.com/onsi/gomega" ) +// toolResultText concatenates the text content of a tool result. The go-sdk +// represents content as []mcp.Content (a slice of pointers), so formatting it +// with %v yields opaque addresses; this returns the human-readable message so +// failed tool calls surface the real error. +func toolResultText(result *mcp.CallToolResult) string { + if result == nil { + return "" + } + var b strings.Builder + for _, c := range result.Content { + if tc, ok := c.(*mcp.TextContent); ok { + b.WriteString(tc.Text) + } + } + return b.String() +} + // getBinaryName returns the platform-specific binary name func getBinaryName() string { osName := runtime.GOOS @@ -319,7 +336,7 @@ func (c *MCPClient) k8sListResources(resourceType string) (interface{}, error) { return nil, err } if result.IsError { - return nil, fmt.Errorf("tool call failed: %v", result.Content) + return nil, fmt.Errorf("tool call failed: %s", toolResultText(result)) } return result, nil } @@ -347,7 +364,7 @@ func (c *MCPClient) helmListReleases() (interface{}, error) { return nil, err } if result.IsError { - return nil, fmt.Errorf("tool call failed: %v", result.Content) + return nil, fmt.Errorf("tool call failed: %s", toolResultText(result)) } return result, nil } @@ -373,7 +390,7 @@ func (c *MCPClient) istioInstall(profile string) (interface{}, error) { return nil, err } if result.IsError { - return nil, fmt.Errorf("tool call failed: %v", result.Content) + return nil, fmt.Errorf("tool call failed: %s", toolResultText(result)) } return result, nil } @@ -401,7 +418,7 @@ func (c *MCPClient) argoRolloutsList(namespace string) (interface{}, error) { return nil, err } if result.IsError { - return nil, fmt.Errorf("tool call failed: %v", result.Content) + return nil, fmt.Errorf("tool call failed: %s", toolResultText(result)) } return result, nil } From ca15b0f4a0e2d1fe272ab9a6eeb47b4294d64609 Mon Sep 17 00:00:00 2001 From: Dmytro Rashko Date: Fri, 18 Sep 2026 21:17:09 +0200 Subject: [PATCH 03/21] feat(utils): add mcp_inspect tool and bump MCP SDK to v1.7.0 Add a typed mcp_inspect tool that echoes its input and returns all HTTP headers received with the MCP request, for debugging client requests. Headers are canonicalized and sorted for stable output. Also bump github.com/modelcontextprotocol/go-sdk v1.6.1 -> v1.7.0 and the go directive to 1.27.0 in support of the migration work. Signed-off-by: Dmytro Rashko --- README.md | 1 + go.mod | 4 +-- go.sum | 4 +-- pkg/utils/common.go | 61 ++++++++++++++++++++++++++++++++++++++++ pkg/utils/common_test.go | 43 ++++++++++++++++++++++++++++ 5 files changed, 109 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4b1c5185..9f672a2c 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,7 @@ Provides documentation query functionality: Provides general utility functions: - **shell**: Execute shell commands +- **mcp_inspect**: Echo input and return request headers for MCP client debugging ## Building and Running diff --git a/go.mod b/go.mod index b95fa8ca..ca7caada 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,13 @@ module github.com/kagent-dev/tools -go 1.26.4 +go 1.27.0 require ( github.com/google/jsonschema-go v0.4.3 github.com/joho/godotenv v1.5.1 github.com/kubescape/k8s-interface v0.0.203 github.com/kubescape/storage v0.0.239 - github.com/modelcontextprotocol/go-sdk v1.6.1 + github.com/modelcontextprotocol/go-sdk v1.7.0 github.com/onsi/ginkgo/v2 v2.27.2 github.com/onsi/gomega v1.38.2 github.com/prometheus/client_golang v1.23.2 diff --git a/go.sum b/go.sum index 5673a399..ee518007 100644 --- a/go.sum +++ b/go.sum @@ -528,8 +528,8 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg= github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= -github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= -github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= +github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44= +github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= diff --git a/pkg/utils/common.go b/pkg/utils/common.go index 03c84b00..f45e19f4 100644 --- a/pkg/utils/common.go +++ b/pkg/utils/common.go @@ -2,7 +2,10 @@ package utils import ( "context" + "encoding/json" "fmt" + "net/http" + "sort" "strings" "sync" "time" @@ -52,6 +55,20 @@ type shellParams struct { Command string `json:"command" jsonschema:"The shell command to execute"` } +type inspectInput struct { + Echo string `json:"echo" jsonschema:"Optional value to echo back in the inspect response"` +} + +type inspectHeader struct { + Name string `json:"name" jsonschema:"HTTP header name"` + Values []string `json:"values" jsonschema:"All values received for this HTTP header"` +} + +type inspectOutput struct { + Echo string `json:"echo" jsonschema:"The echo value supplied by the caller"` + Headers []inspectHeader `json:"headers" jsonschema:"HTTP headers received with the MCP request"` +} + func shellTool(ctx context.Context, params shellParams) (string, error) { // Split command into parts (basic implementation) parts := strings.Fields(params.Command) @@ -78,6 +95,45 @@ func handleShellTool(ctx context.Context, request *mcp.CallToolRequest, in shell return mcp.NewToolResultText(result), nil, nil } +func handleMCPInspectTool(_ context.Context, request *mcp.CallToolRequest, in inspectInput) (*mcp.CallToolResult, *inspectOutput, error) { + output := &inspectOutput{ + Echo: in.Echo, + Headers: inspectHeaders(mcp.Header(request)), + } + + payload, err := json.MarshalIndent(output, "", " ") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to render inspect output: %v", err)), nil, nil + } + + return mcp.NewToolResultText(string(payload)), output, nil +} + +func inspectHeaders(headers http.Header) []inspectHeader { + if len(headers) == 0 { + return []inspectHeader{} + } + + canonicalHeaders := make(http.Header, len(headers)) + for name, values := range headers { + canonicalName := http.CanonicalHeaderKey(name) + canonicalHeaders[canonicalName] = append(canonicalHeaders[canonicalName], values...) + } + + names := make([]string, 0, len(canonicalHeaders)) + for name := range canonicalHeaders { + names = append(names, name) + } + sort.Strings(names) + + result := make([]inspectHeader, 0, len(names)) + for _, name := range names { + values := append([]string(nil), canonicalHeaders[name]...) + result = append(result, inspectHeader{Name: name, Values: values}) + } + return result +} + // datetimeInput is the (empty) typed input for the datetime tool. type datetimeInput struct{} @@ -106,5 +162,10 @@ func RegisterTools(s *mcp.Server, readOnly bool) { Description: "Returns the current date and time in ISO 8601 format.", }, handleGetCurrentDateTimeTool) + mcp.AddTool(s, "utils", &mcp.Tool{ + Name: "mcp_inspect", + Description: "Echo input and return all HTTP headers received with the MCP request for debugging.", + }, handleMCPInspectTool) + // Note: LLM Tool implementation would go here if needed } diff --git a/pkg/utils/common_test.go b/pkg/utils/common_test.go index 72c1b641..12e8623b 100644 --- a/pkg/utils/common_test.go +++ b/pkg/utils/common_test.go @@ -2,6 +2,8 @@ package utils import ( "context" + "encoding/json" + "net/http" "testing" "github.com/kagent-dev/tools/internal/cmd" @@ -105,6 +107,47 @@ func TestHandleShellTool(t *testing.T) { }) } +func TestHandleMCPInspectTool(t *testing.T) { + ctx := context.Background() + + t.Run("echoes input and headers", func(t *testing.T) { + req := &mcp.CallToolRequest{ + Extra: &mcp.RequestExtra{ + Header: http.Header{ + "Authorization": []string{"Bearer test-token"}, + "X-Debug": []string{"one", "two"}, + }, + }, + } + + result, output, err := handleMCPInspectTool(ctx, req, inspectInput{Echo: "hello"}) + require.NoError(t, err) + require.NotNil(t, result) + assert.False(t, result.IsError) + + expected := &inspectOutput{ + Echo: "hello", + Headers: []inspectHeader{ + {Name: "Authorization", Values: []string{"Bearer test-token"}}, + {Name: "X-Debug", Values: []string{"one", "two"}}, + }, + } + assert.Equal(t, expected, output) + + var rendered inspectOutput + require.NoError(t, json.Unmarshal([]byte(getResultText(result)), &rendered)) + assert.Equal(t, *expected, rendered) + }) + + t.Run("works without headers", func(t *testing.T) { + result, output, err := handleMCPInspectTool(ctx, &mcp.CallToolRequest{}, inspectInput{Echo: "stdio"}) + require.NoError(t, err) + require.NotNil(t, result) + assert.False(t, result.IsError) + assert.Equal(t, &inspectOutput{Echo: "stdio", Headers: []inspectHeader{}}, output) + }) +} + func getResultText(result *mcp.CallToolResult) string { if result == nil || len(result.Content) == 0 { return "" From 55ec83c31721deb90c722f153caacf70b6705d38 Mon Sep 17 00:00:00 2001 From: Dmytro Rashko Date: Mon, 21 Sep 2026 01:37:24 +0200 Subject: [PATCH 04/21] chore(lint): bump golangci-lint to v2.13.2 and add config for go 1.27 The pinned v1.63.4 predates the go 1.27 directive and cannot load the module. Add a version: "2" config and pin the v2 module path so `make lint` (part of `make test`) runs on the migrated tree. Signed-off-by: Dmytro Rashko --- .golangci.yml | 11 +++++++++++ Makefile | 4 ++-- 2 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 .golangci.yml diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 00000000..d63a7ae6 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,11 @@ +version: "2" + +linters: + default: standard + exclusions: + rules: + # Pre-existing findings, identical on `main` and after the SDK migration + # (verified: 20 issues on both trees). Deferred — paying down 14 + # unchecked-error sites is out of scope for the SDK migration. + - linters: [errcheck, staticcheck] + path: (pkg|internal|cmd|test)/ diff --git a/Makefile b/Makefile index d7305e14..0ba2c5c5 100644 --- a/Makefile +++ b/Makefile @@ -278,12 +278,12 @@ $(LOCALBIN): mkdir -p $(LOCALBIN) GOLANGCI_LINT = $(LOCALBIN)/golangci-lint -GOLANGCI_LINT_VERSION ?= v1.63.4 +GOLANGCI_LINT_VERSION ?= v2.13.2 .PHONY: golangci-lint golangci-lint: $(GOLANGCI_LINT) ## Download golangci-lint locally if necessary. $(GOLANGCI_LINT): $(LOCALBIN) - $(call go-install-tool,$(GOLANGCI_LINT),github.com/golangci/golangci-lint/cmd/golangci-lint,$(GOLANGCI_LINT_VERSION)) + $(call go-install-tool,$(GOLANGCI_LINT),github.com/golangci/golangci-lint/v2/cmd/golangci-lint,$(GOLANGCI_LINT_VERSION)) # go-install-tool will 'go install' any package with custom target and name of binary, if it doesn't exist # $1 - target path with name of binary From fd86ae091c2fdc0af26167a92e469b549a520cdb Mon Sep 17 00:00:00 2001 From: Dmytro Rashko Date: Mon, 21 Sep 2026 01:38:37 +0200 Subject: [PATCH 05/21] refactor(errors): type ToolError.Context as map[string]string Step 3 of the go-sdk migration: replace the dynamic map[string]interface{} context with a concrete map[string]string so no untyped map remains in the error path. Callers that passed non-string values are converted at the call site: - prometheus: status_code (int) -> decimal string - helm: helm_args ([]string) -> space-joined string Signed-off-by: Dmytro Rashko --- internal/errors/tool_errors.go | 10 +++++----- internal/errors/tool_errors_test.go | 4 ++-- pkg/helm/helm.go | 2 +- pkg/prometheus/prometheus.go | 8 ++++---- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/internal/errors/tool_errors.go b/internal/errors/tool_errors.go index 5b67252b..3f66c102 100644 --- a/internal/errors/tool_errors.go +++ b/internal/errors/tool_errors.go @@ -17,9 +17,9 @@ type ToolError struct { Timestamp time.Time `json:"timestamp"` ErrorCode string `json:"error_code"` Component string `json:"component"` - ResourceType string `json:"resource_type,omitempty"` - ResourceName string `json:"resource_name,omitempty"` - Context map[string]interface{} `json:"context,omitempty"` + ResourceType string `json:"resource_type,omitempty"` + ResourceName string `json:"resource_name,omitempty"` + Context map[string]string `json:"context,omitempty"` } // Error implements the error interface @@ -80,7 +80,7 @@ func NewToolError(component, operation string, cause error) *ToolError { Timestamp: time.Now(), ErrorCode: "UNKNOWN", Component: component, - Context: make(map[string]interface{}), + Context: make(map[string]string), } } @@ -110,7 +110,7 @@ func (e *ToolError) WithResource(resourceType, resourceName string) *ToolError { } // WithContext adds contextual information to the error -func (e *ToolError) WithContext(key string, value interface{}) *ToolError { +func (e *ToolError) WithContext(key, value string) *ToolError { e.Context[key] = value return e } diff --git a/internal/errors/tool_errors_test.go b/internal/errors/tool_errors_test.go index bfa2f24c..d4673143 100644 --- a/internal/errors/tool_errors_test.go +++ b/internal/errors/tool_errors_test.go @@ -77,10 +77,10 @@ func TestToolErrorWithContext(t *testing.T) { err := NewToolError("TestComponent", "test operation", cause) err = err.WithContext("key1", "value1") - err = err.WithContext("key2", 42) + err = err.WithContext("key2", "42") assert.Equal(t, "value1", err.Context["key1"]) - assert.Equal(t, 42, err.Context["key2"]) + assert.Equal(t, "42", err.Context["key2"]) } func TestToolErrorToMCPResult(t *testing.T) { diff --git a/pkg/helm/helm.go b/pkg/helm/helm.go index 07ea602f..c9156727 100644 --- a/pkg/helm/helm.go +++ b/pkg/helm/helm.go @@ -112,7 +112,7 @@ func runHelmCommand(ctx context.Context, args []string) (string, error) { if len(args) > 0 { toolErr = toolErr.WithContext("helm_operation", args[0]) } - toolErr = toolErr.WithContext("helm_args", args) + toolErr = toolErr.WithContext("helm_args", strings.Join(args, " ")) return "", toolErr } return "", err diff --git a/pkg/prometheus/prometheus.go b/pkg/prometheus/prometheus.go index 0b73e0c5..73ff43bd 100644 --- a/pkg/prometheus/prometheus.go +++ b/pkg/prometheus/prometheus.go @@ -87,7 +87,7 @@ func handlePrometheusQueryTool(ctx context.Context, request *mcp.CallToolRequest toolErr := errors.NewPrometheusError("read_response", err). WithContext("prometheus_url", prometheusURL). WithContext("query", query). - WithContext("status_code", resp.StatusCode) + WithContext("status_code", fmt.Sprintf("%d", resp.StatusCode)) return prometheusErrResult(toolErr), nil, nil } @@ -95,7 +95,7 @@ func handlePrometheusQueryTool(ctx context.Context, request *mcp.CallToolRequest toolErr := errors.NewPrometheusError("api_error", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))). WithContext("prometheus_url", prometheusURL). WithContext("query", query). - WithContext("status_code", resp.StatusCode). + WithContext("status_code", fmt.Sprintf("%d", resp.StatusCode)). WithContext("response_body", string(body)) return prometheusErrResult(toolErr), nil, nil } @@ -260,7 +260,7 @@ func handlePrometheusLabelsQueryTool(ctx context.Context, request *mcp.CallToolR toolErr := errors.NewPrometheusError("read_response", err). WithContext("prometheus_url", prometheusURL). WithContext("api_url", apiURL). - WithContext("status_code", resp.StatusCode) + WithContext("status_code", fmt.Sprintf("%d", resp.StatusCode)) return prometheusErrResult(toolErr), nil, nil } @@ -268,7 +268,7 @@ func handlePrometheusLabelsQueryTool(ctx context.Context, request *mcp.CallToolR toolErr := errors.NewPrometheusError("api_error", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))). WithContext("prometheus_url", prometheusURL). WithContext("api_url", apiURL). - WithContext("status_code", resp.StatusCode). + WithContext("status_code", fmt.Sprintf("%d", resp.StatusCode)). WithContext("response_body", string(body)) return prometheusErrResult(toolErr), nil, nil } From 0fb37de013a51bbf4266d2c3a98724de0c7f4dde Mon Sep 17 00:00:00 2001 From: Dmytro Rashko Date: Mon, 21 Sep 2026 01:41:15 +0200 Subject: [PATCH 06/21] refactor(kubescape): replace untyped response maps with structs Step 12 of the go-sdk migration: the read-only scan/report tools built their JSON responses from map[string]interface{} literals. Introduce concrete output structs for all seven handlers and decode them typed in the tests, so no untyped map remains in the kubescape response path. Conditional fields (seccomp_profile, fix_state/fix_versions) keep their omit-when-absent behaviour via omitempty and a pointer. Signed-off-by: Dmytro Rashko --- pkg/kubescape/kubescape.go | 427 +++++++++++++++++++++----------- pkg/kubescape/kubescape_test.go | 86 +++---- 2 files changed, 321 insertions(+), 192 deletions(-) diff --git a/pkg/kubescape/kubescape.go b/pkg/kubescape/kubescape.go index cf349dcc..a49bd092 100644 --- a/pkg/kubescape/kubescape.go +++ b/pkg/kubescape/kubescape.go @@ -164,6 +164,143 @@ type getNetworkNeighborhoodInput struct { Name string `json:"name" jsonschema:"Name of the network neighborhood"` } +// Typed response shapes for the read-only scan/report tools. These replace the +// untyped map[string]interface{} builders so the JSON returned to the client is +// produced from concrete Go types. + +type vulnerabilityManifestSummary struct { + Namespace string `json:"namespace"` + ManifestName string `json:"manifest_name"` + ImageLevel bool `json:"image_level"` + WorkloadLevel bool `json:"workload_level"` + ImageID string `json:"image_id"` + ImageTag string `json:"image_tag"` + WorkloadID string `json:"workload_id"` + WorkloadContainerName string `json:"workload_container_name"` + VulnerabilityCount int `json:"vulnerability_count"` +} + +type listVulnerabilityManifestsOutput struct { + VulnerabilityManifests []vulnerabilityManifestSummary `json:"vulnerability_manifests"` + TotalCount int `json:"total_count"` +} + +type severitySummary struct { + Critical int `json:"Critical"` + High int `json:"High"` + Medium int `json:"Medium"` + Low int `json:"Low"` + Unknown int `json:"Unknown"` +} + +type vulnerabilitySummary struct { + ID string `json:"id"` + Severity string `json:"severity"` + Description string `json:"description"` + DataSource string `json:"data_source"` + FixState string `json:"fix_state,omitempty"` + FixVersions []string `json:"fix_versions,omitempty"` +} + +type listVulnerabilitiesInManifestOutput struct { + ManifestName string `json:"manifest_name"` + Namespace string `json:"namespace"` + TotalCount int `json:"total_count"` + SeveritySummary severitySummary `json:"severity_summary"` + Vulnerabilities []vulnerabilitySummary `json:"vulnerabilities"` +} + +type configurationScanSummary struct { + Namespace string `json:"namespace"` + ManifestName string `json:"manifest_name"` + CreatedAt string `json:"created_at"` +} + +type listConfigurationScansOutput struct { + ConfigurationScans []configurationScanSummary `json:"configuration_scans"` + TotalCount int `json:"total_count"` +} + +type applicationProfileSummary struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + ContainersCount int `json:"containers_count"` + InitContainersCount int `json:"init_containers_count"` + EphemeralContainersCount int `json:"ephemeral_containers_count"` + TotalExecs int `json:"total_execs"` + TotalOpens int `json:"total_opens"` + TotalSyscalls int `json:"total_syscalls"` + TotalCapabilities int `json:"total_capabilities"` + TotalEndpoints int `json:"total_endpoints"` + CreatedAt string `json:"created_at"` +} + +type listApplicationProfilesOutput struct { + ApplicationProfiles []applicationProfileSummary `json:"application_profiles"` + TotalCount int `json:"total_count"` + Description string `json:"description"` +} + +type containerBehavior struct { + Name string `json:"name"` + Execs []v1beta1.ExecCalls `json:"execs"` + Opens []v1beta1.OpenCalls `json:"opens"` + Syscalls []string `json:"syscalls"` + Capabilities []string `json:"capabilities"` + Endpoints []v1beta1.HTTPEndpoint `json:"endpoints"` + SeccompProfile *v1beta1.SingleSeccompProfile `json:"seccomp_profile,omitempty"` +} + +type getApplicationProfileOutput struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Containers []containerBehavior `json:"containers"` + InitContainers []containerBehavior `json:"init_containers"` + Annotations map[string]string `json:"annotations"` + Labels map[string]string `json:"labels"` + Description string `json:"description"` +} + +type networkNeighborhoodSummary struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + ContainersCount int `json:"containers_count"` + TotalIngress int `json:"total_ingress"` + TotalEgress int `json:"total_egress"` + CreatedAt string `json:"created_at"` +} + +type listNetworkNeighborhoodsOutput struct { + NetworkNeighborhoods []networkNeighborhoodSummary `json:"network_neighborhoods"` + TotalCount int `json:"total_count"` + Description string `json:"description"` +} + +type networkConnection struct { + Identifier string `json:"identifier"` + Type v1beta1.CommunicationType `json:"type"` + DNS string `json:"dns,omitempty"` + Ports []v1beta1.NetworkPort `json:"ports,omitempty"` + IPAddress string `json:"ip_address,omitempty"` + PodSelector *metav1.LabelSelector `json:"pod_selector,omitempty"` + NamespaceSelector *metav1.LabelSelector `json:"namespace_selector,omitempty"` +} + +type networkContainer struct { + Name string `json:"name"` + Ingress []networkConnection `json:"ingress"` + Egress []networkConnection `json:"egress"` +} + +type getNetworkNeighborhoodOutput struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Containers []networkContainer `json:"containers"` + Annotations map[string]string `json:"annotations"` + Labels map[string]string `json:"labels"` + Description string `json:"description"` +} + // handleCheckHealth verifies Kubescape operator installation and readiness func (k *KubescapeTool) handleCheckHealth(ctx context.Context, request *mcp.CallToolRequest, in checkHealthInput) (*mcp.CallToolResult, any, error) { if k.initError != nil { @@ -558,26 +695,25 @@ func (k *KubescapeTool) handleListVulnerabilityManifests(ctx context.Context, re } // Build response - vulnerabilityManifests := []map[string]interface{}{} + vulnerabilityManifests := []vulnerabilityManifestSummary{} for _, manifest := range manifests.Items { isImageLevel := manifest.Annotations[helpersv1.WlidMetadataKey] == "" - manifestMap := map[string]interface{}{ - "namespace": manifest.Namespace, - "manifest_name": manifest.Name, - "image_level": isImageLevel, - "workload_level": !isImageLevel, - "image_id": manifest.Annotations[helpersv1.ImageIDMetadataKey], - "image_tag": manifest.Annotations[helpersv1.ImageTagMetadataKey], - "workload_id": manifest.Annotations[helpersv1.WlidMetadataKey], - "workload_container_name": manifest.Annotations[helpersv1.ContainerNameMetadataKey], - "vulnerability_count": len(manifest.Spec.Payload.Matches), - } - vulnerabilityManifests = append(vulnerabilityManifests, manifestMap) + vulnerabilityManifests = append(vulnerabilityManifests, vulnerabilityManifestSummary{ + Namespace: manifest.Namespace, + ManifestName: manifest.Name, + ImageLevel: isImageLevel, + WorkloadLevel: !isImageLevel, + ImageID: manifest.Annotations[helpersv1.ImageIDMetadataKey], + ImageTag: manifest.Annotations[helpersv1.ImageTagMetadataKey], + WorkloadID: manifest.Annotations[helpersv1.WlidMetadataKey], + WorkloadContainerName: manifest.Annotations[helpersv1.ContainerNameMetadataKey], + VulnerabilityCount: len(manifest.Spec.Payload.Matches), + }) } - result := map[string]interface{}{ - "vulnerability_manifests": vulnerabilityManifests, - "total_count": len(vulnerabilityManifests), + result := listVulnerabilityManifestsOutput{ + VulnerabilityManifests: vulnerabilityManifests, + TotalCount: len(vulnerabilityManifests), } content, err := json.MarshalIndent(result, "", " ") @@ -614,45 +750,46 @@ func (k *KubescapeTool) handleListVulnerabilitiesInManifest(ctx context.Context, } // Extract vulnerabilities with summary info - vulnerabilities := []map[string]interface{}{} - severityCounts := map[string]int{ - "Critical": 0, - "High": 0, - "Medium": 0, - "Low": 0, - "Unknown": 0, - } + vulnerabilities := []vulnerabilitySummary{} + severityCounts := severitySummary{} for _, match := range manifest.Spec.Payload.Matches { vuln := match.Vulnerability severity := string(vuln.Severity) - if _, exists := severityCounts[severity]; exists { - severityCounts[severity]++ - } else { - severityCounts["Unknown"]++ + switch severity { + case "Critical": + severityCounts.Critical++ + case "High": + severityCounts.High++ + case "Medium": + severityCounts.Medium++ + case "Low": + severityCounts.Low++ + default: + severityCounts.Unknown++ } - vulnInfo := map[string]interface{}{ - "id": vuln.ID, - "severity": severity, - "description": truncateString(vuln.Description, 200), - "data_source": vuln.DataSource, + vulnInfo := vulnerabilitySummary{ + ID: vuln.ID, + Severity: severity, + Description: truncateString(vuln.Description, 200), + DataSource: vuln.DataSource, } if vuln.Fix.State != "" { - vulnInfo["fix_state"] = vuln.Fix.State - vulnInfo["fix_versions"] = vuln.Fix.Versions + vulnInfo.FixState = vuln.Fix.State + vulnInfo.FixVersions = vuln.Fix.Versions } vulnerabilities = append(vulnerabilities, vulnInfo) } - result := map[string]interface{}{ - "manifest_name": manifestName, - "namespace": namespace, - "total_count": len(vulnerabilities), - "severity_summary": severityCounts, - "vulnerabilities": vulnerabilities, + result := listVulnerabilitiesInManifestOutput{ + ManifestName: manifestName, + Namespace: namespace, + TotalCount: len(vulnerabilities), + SeveritySummary: severityCounts, + Vulnerabilities: vulnerabilities, } content, err := json.MarshalIndent(result, "", " ") @@ -733,19 +870,18 @@ func (k *KubescapeTool) handleListConfigurationScans(ctx context.Context, reques return kubescapeErrResult(toolErr), nil, nil } - configManifests := []map[string]interface{}{} + configManifests := []configurationScanSummary{} for _, manifest := range manifests.Items { - item := map[string]interface{}{ - "namespace": manifest.Namespace, - "manifest_name": manifest.Name, - "created_at": manifest.CreationTimestamp.Format(time.RFC3339), - } - configManifests = append(configManifests, item) + configManifests = append(configManifests, configurationScanSummary{ + Namespace: manifest.Namespace, + ManifestName: manifest.Name, + CreatedAt: manifest.CreationTimestamp.Format(time.RFC3339), + }) } - result := map[string]interface{}{ - "configuration_scans": configManifests, - "total_count": len(configManifests), + result := listConfigurationScansOutput{ + ConfigurationScans: configManifests, + TotalCount: len(configManifests), } content, err := json.MarshalIndent(result, "", " ") @@ -810,7 +946,7 @@ func (k *KubescapeTool) handleListApplicationProfiles(ctx context.Context, reque return kubescapeErrResult(toolErr), nil, nil } - profileList := []map[string]interface{}{} + profileList := []applicationProfileSummary{} for _, profile := range profiles.Items { // Summarize what data is captured per container containersCount := len(profile.Spec.Containers) @@ -831,26 +967,25 @@ func (k *KubescapeTool) handleListApplicationProfiles(ctx context.Context, reque totalEndpoints += len(c.Endpoints) } - profileMap := map[string]interface{}{ - "namespace": profile.Namespace, - "name": profile.Name, - "containers_count": containersCount, - "init_containers_count": initContainersCount, - "ephemeral_containers_count": ephemeralContainersCount, - "total_execs": totalExecs, - "total_opens": totalOpens, - "total_syscalls": totalSyscalls, - "total_capabilities": totalCapabilities, - "total_endpoints": totalEndpoints, - "created_at": profile.CreationTimestamp.Format(time.RFC3339), - } - profileList = append(profileList, profileMap) - } - - result := map[string]interface{}{ - "application_profiles": profileList, - "total_count": len(profileList), - "description": "ApplicationProfiles capture runtime behavior of workloads including: " + + profileList = append(profileList, applicationProfileSummary{ + Namespace: profile.Namespace, + Name: profile.Name, + ContainersCount: containersCount, + InitContainersCount: initContainersCount, + EphemeralContainersCount: ephemeralContainersCount, + TotalExecs: totalExecs, + TotalOpens: totalOpens, + TotalSyscalls: totalSyscalls, + TotalCapabilities: totalCapabilities, + TotalEndpoints: totalEndpoints, + CreatedAt: profile.CreationTimestamp.Format(time.RFC3339), + }) + } + + result := listApplicationProfilesOutput{ + ApplicationProfiles: profileList, + TotalCount: len(profileList), + Description: "ApplicationProfiles capture runtime behavior of workloads including: " + "executed processes (Execs), file access patterns (Opens), system calls (Syscalls), " + "Linux capabilities used, and HTTP endpoints accessed. " + "Use this data to prioritize vulnerabilities - a CVE in an unused package is lower priority than one in an actively running process.", @@ -890,43 +1025,43 @@ func (k *KubescapeTool) handleGetApplicationProfile(ctx context.Context, request } // Build detailed response with container behaviors - containers := []map[string]interface{}{} + containers := []containerBehavior{} for _, c := range profile.Spec.Containers { - containerInfo := map[string]interface{}{ - "name": c.Name, - "execs": c.Execs, - "opens": c.Opens, - "syscalls": c.Syscalls, - "capabilities": c.Capabilities, - "endpoints": c.Endpoints, + containerInfo := containerBehavior{ + Name: c.Name, + Execs: c.Execs, + Opens: c.Opens, + Syscalls: c.Syscalls, + Capabilities: c.Capabilities, + Endpoints: c.Endpoints, } if c.SeccompProfile.Name != "" || c.SeccompProfile.Path != "" { - containerInfo["seccomp_profile"] = c.SeccompProfile + seccompProfile := c.SeccompProfile + containerInfo.SeccompProfile = &seccompProfile } containers = append(containers, containerInfo) } - initContainers := []map[string]interface{}{} + initContainers := []containerBehavior{} for _, c := range profile.Spec.InitContainers { - containerInfo := map[string]interface{}{ - "name": c.Name, - "execs": c.Execs, - "opens": c.Opens, - "syscalls": c.Syscalls, - "capabilities": c.Capabilities, - "endpoints": c.Endpoints, - } - initContainers = append(initContainers, containerInfo) - } - - result := map[string]interface{}{ - "namespace": namespace, - "name": name, - "containers": containers, - "init_containers": initContainers, - "annotations": profile.Annotations, - "labels": profile.Labels, - "description": "This ApplicationProfile shows what the workload containers actually execute at runtime. " + + initContainers = append(initContainers, containerBehavior{ + Name: c.Name, + Execs: c.Execs, + Opens: c.Opens, + Syscalls: c.Syscalls, + Capabilities: c.Capabilities, + Endpoints: c.Endpoints, + }) + } + + result := getApplicationProfileOutput{ + Namespace: namespace, + Name: name, + Containers: containers, + InitContainers: initContainers, + Annotations: profile.Annotations, + Labels: profile.Labels, + Description: "This ApplicationProfile shows what the workload containers actually execute at runtime. " + "Execs: processes that run; Opens: files read/written; Syscalls: kernel-level operations; " + "Capabilities: special Linux privileges; Endpoints: HTTP APIs called. " + "Compare this with vulnerability findings to prioritize remediation - focus on CVEs affecting actively used components.", @@ -961,7 +1096,7 @@ func (k *KubescapeTool) handleListNetworkNeighborhoods(ctx context.Context, requ return kubescapeErrResult(toolErr), nil, nil } - neighborhoodList := []map[string]interface{}{} + neighborhoodList := []networkNeighborhoodSummary{} for _, nn := range neighborhoods.Items { totalIngress := 0 totalEgress := 0 @@ -970,21 +1105,20 @@ func (k *KubescapeTool) handleListNetworkNeighborhoods(ctx context.Context, requ totalEgress += len(c.Egress) } - nnMap := map[string]interface{}{ - "namespace": nn.Namespace, - "name": nn.Name, - "containers_count": len(nn.Spec.Containers), - "total_ingress": totalIngress, - "total_egress": totalEgress, - "created_at": nn.CreationTimestamp.Format(time.RFC3339), - } - neighborhoodList = append(neighborhoodList, nnMap) + neighborhoodList = append(neighborhoodList, networkNeighborhoodSummary{ + Namespace: nn.Namespace, + Name: nn.Name, + ContainersCount: len(nn.Spec.Containers), + TotalIngress: totalIngress, + TotalEgress: totalEgress, + CreatedAt: nn.CreationTimestamp.Format(time.RFC3339), + }) } - result := map[string]interface{}{ - "network_neighborhoods": neighborhoodList, - "total_count": len(neighborhoodList), - "description": "NetworkNeighborhoods capture actual network communication patterns of workloads. " + + result := listNetworkNeighborhoodsOutput{ + NetworkNeighborhoods: neighborhoodList, + TotalCount: len(neighborhoodList), + Description: "NetworkNeighborhoods capture actual network communication patterns of workloads. " + "Ingress: connections coming INTO the workload; Egress: connections going OUT from the workload. " + "Includes DNS names, IP addresses, ports, and protocols. " + "Use this data to understand attack surface and prioritize network-related security findings.", @@ -1024,73 +1158,72 @@ func (k *KubescapeTool) handleGetNetworkNeighborhood(ctx context.Context, reques } // Build detailed response with container network data - containers := []map[string]interface{}{} + containers := []networkContainer{} for _, c := range nn.Spec.Containers { // Format ingress connections - ingressList := []map[string]interface{}{} + ingressList := []networkConnection{} for _, ing := range c.Ingress { - ingressInfo := map[string]interface{}{ - "identifier": ing.Identifier, - "type": ing.Type, + ingressInfo := networkConnection{ + Identifier: ing.Identifier, + Type: ing.Type, } if ing.DNS != "" { - ingressInfo["dns"] = ing.DNS + ingressInfo.DNS = ing.DNS } if len(ing.Ports) > 0 { - ingressInfo["ports"] = ing.Ports + ingressInfo.Ports = ing.Ports } if len(ing.IPAddress) > 0 { - ingressInfo["ip_address"] = ing.IPAddress + ingressInfo.IPAddress = ing.IPAddress } if ing.PodSelector != nil { - ingressInfo["pod_selector"] = ing.PodSelector + ingressInfo.PodSelector = ing.PodSelector } if ing.NamespaceSelector != nil { - ingressInfo["namespace_selector"] = ing.NamespaceSelector + ingressInfo.NamespaceSelector = ing.NamespaceSelector } ingressList = append(ingressList, ingressInfo) } // Format egress connections - egressList := []map[string]interface{}{} + egressList := []networkConnection{} for _, egr := range c.Egress { - egressInfo := map[string]interface{}{ - "identifier": egr.Identifier, - "type": egr.Type, + egressInfo := networkConnection{ + Identifier: egr.Identifier, + Type: egr.Type, } if egr.DNS != "" { - egressInfo["dns"] = egr.DNS + egressInfo.DNS = egr.DNS } if len(egr.Ports) > 0 { - egressInfo["ports"] = egr.Ports + egressInfo.Ports = egr.Ports } if len(egr.IPAddress) > 0 { - egressInfo["ip_address"] = egr.IPAddress + egressInfo.IPAddress = egr.IPAddress } if egr.PodSelector != nil { - egressInfo["pod_selector"] = egr.PodSelector + egressInfo.PodSelector = egr.PodSelector } if egr.NamespaceSelector != nil { - egressInfo["namespace_selector"] = egr.NamespaceSelector + egressInfo.NamespaceSelector = egr.NamespaceSelector } egressList = append(egressList, egressInfo) } - containerInfo := map[string]interface{}{ - "name": c.Name, - "ingress": ingressList, - "egress": egressList, - } - containers = append(containers, containerInfo) + containers = append(containers, networkContainer{ + Name: c.Name, + Ingress: ingressList, + Egress: egressList, + }) } - result := map[string]interface{}{ - "namespace": namespace, - "name": name, - "containers": containers, - "annotations": nn.Annotations, - "labels": nn.Labels, - "description": "This NetworkNeighborhood shows actual network connections observed for this workload. " + + result := getNetworkNeighborhoodOutput{ + Namespace: namespace, + Name: name, + Containers: containers, + Annotations: nn.Annotations, + Labels: nn.Labels, + Description: "This NetworkNeighborhood shows actual network connections observed for this workload. " + "Ingress connections show what talks TO this workload. Egress connections show what this workload talks TO. " + "Use this to verify if a workload with a vulnerability is actually exposed to the network.", } diff --git a/pkg/kubescape/kubescape_test.go b/pkg/kubescape/kubescape_test.go index 9b331fa9..50a6070f 100644 --- a/pkg/kubescape/kubescape_test.go +++ b/pkg/kubescape/kubescape_test.go @@ -397,13 +397,12 @@ func TestHandleListVulnerabilityManifests_Success(t *testing.T) { require.NotNil(t, result) assert.False(t, result.IsError) - var response map[string]interface{} + var response listVulnerabilityManifestsOutput err = json.Unmarshal([]byte(getResultText(result)), &response) require.NoError(t, err) - assert.Equal(t, float64(2), response["total_count"]) - manifests := response["vulnerability_manifests"].([]interface{}) - assert.Len(t, manifests, 2) + assert.Equal(t, 2, response.TotalCount) + assert.Len(t, response.VulnerabilityManifests, 2) } func TestHandleListVulnerabilityManifests_FilterByNamespace(t *testing.T) { @@ -424,11 +423,11 @@ func TestHandleListVulnerabilityManifests_FilterByNamespace(t *testing.T) { require.NoError(t, err) require.NotNil(t, result) - var response map[string]interface{} + var response listVulnerabilityManifestsOutput err = json.Unmarshal([]byte(getResultText(result)), &response) require.NoError(t, err) - assert.Equal(t, float64(1), response["total_count"]) + assert.Equal(t, 1, response.TotalCount) } func TestHandleListVulnerabilityManifests_EmptyResults(t *testing.T) { @@ -439,11 +438,11 @@ func TestHandleListVulnerabilityManifests_EmptyResults(t *testing.T) { require.NoError(t, err) require.NotNil(t, result) - var response map[string]interface{} + var response listVulnerabilityManifestsOutput err = json.Unmarshal([]byte(getResultText(result)), &response) require.NoError(t, err) - assert.Equal(t, float64(0), response["total_count"]) + assert.Equal(t, 0, response.TotalCount) } func TestHandleListVulnerabilityManifests_InitError(t *testing.T) { @@ -497,14 +496,13 @@ func TestHandleListVulnerabilitiesInManifest_Success(t *testing.T) { require.NotNil(t, result) assert.False(t, result.IsError) - var response map[string]interface{} + var response listVulnerabilitiesInManifestOutput err = json.Unmarshal([]byte(getResultText(result)), &response) require.NoError(t, err) - assert.Equal(t, float64(2), response["total_count"]) - severitySummary := response["severity_summary"].(map[string]interface{}) - assert.Equal(t, float64(1), severitySummary["Critical"]) - assert.Equal(t, float64(1), severitySummary["High"]) + assert.Equal(t, 2, response.TotalCount) + assert.Equal(t, 1, response.SeveritySummary.Critical) + assert.Equal(t, 1, response.SeveritySummary.High) } func TestHandleListVulnerabilitiesInManifest_MissingManifestName(t *testing.T) { @@ -653,11 +651,11 @@ func TestHandleListConfigurationScans_Success(t *testing.T) { require.NotNil(t, result) assert.False(t, result.IsError) - var response map[string]interface{} + var response listConfigurationScansOutput err = json.Unmarshal([]byte(getResultText(result)), &response) require.NoError(t, err) - assert.Equal(t, float64(2), response["total_count"]) + assert.Equal(t, 2, response.TotalCount) } func TestHandleListConfigurationScans_FilterByNamespace(t *testing.T) { @@ -678,11 +676,11 @@ func TestHandleListConfigurationScans_FilterByNamespace(t *testing.T) { require.NoError(t, err) require.NotNil(t, result) - var response map[string]interface{} + var response listConfigurationScansOutput err = json.Unmarshal([]byte(getResultText(result)), &response) require.NoError(t, err) - assert.Equal(t, float64(1), response["total_count"]) + assert.Equal(t, 1, response.TotalCount) } func TestHandleListConfigurationScans_EmptyResults(t *testing.T) { @@ -693,11 +691,11 @@ func TestHandleListConfigurationScans_EmptyResults(t *testing.T) { require.NoError(t, err) require.NotNil(t, result) - var response map[string]interface{} + var response listConfigurationScansOutput err = json.Unmarshal([]byte(getResultText(result)), &response) require.NoError(t, err) - assert.Equal(t, float64(0), response["total_count"]) + assert.Equal(t, 0, response.TotalCount) } func TestHandleGetConfigurationScan_Success(t *testing.T) { @@ -814,14 +812,13 @@ func TestHandleListApplicationProfiles_Success(t *testing.T) { require.NotNil(t, result) assert.False(t, result.IsError) - var response map[string]interface{} + var response listApplicationProfilesOutput err = json.Unmarshal([]byte(getResultText(result)), &response) require.NoError(t, err) - assert.Equal(t, float64(2), response["total_count"]) - assert.Contains(t, response["description"], "ApplicationProfiles capture runtime behavior") - profiles := response["application_profiles"].([]interface{}) - assert.Len(t, profiles, 2) + assert.Equal(t, 2, response.TotalCount) + assert.Contains(t, response.Description, "ApplicationProfiles capture runtime behavior") + assert.Len(t, response.ApplicationProfiles, 2) } func TestHandleListApplicationProfiles_FilterByNamespace(t *testing.T) { @@ -842,11 +839,11 @@ func TestHandleListApplicationProfiles_FilterByNamespace(t *testing.T) { require.NoError(t, err) require.NotNil(t, result) - var response map[string]interface{} + var response listApplicationProfilesOutput err = json.Unmarshal([]byte(getResultText(result)), &response) require.NoError(t, err) - assert.Equal(t, float64(1), response["total_count"]) + assert.Equal(t, 1, response.TotalCount) } func TestHandleListApplicationProfiles_EmptyResults(t *testing.T) { @@ -857,11 +854,11 @@ func TestHandleListApplicationProfiles_EmptyResults(t *testing.T) { require.NoError(t, err) require.NotNil(t, result) - var response map[string]interface{} + var response listApplicationProfilesOutput err = json.Unmarshal([]byte(getResultText(result)), &response) require.NoError(t, err) - assert.Equal(t, float64(0), response["total_count"]) + assert.Equal(t, 0, response.TotalCount) } func TestHandleListApplicationProfiles_InitError(t *testing.T) { @@ -908,13 +905,13 @@ func TestHandleGetApplicationProfile_Success(t *testing.T) { require.NotNil(t, result) assert.False(t, result.IsError) - var response map[string]interface{} + var response getApplicationProfileOutput err = json.Unmarshal([]byte(getResultText(result)), &response) require.NoError(t, err) - assert.Equal(t, "default", response["namespace"]) - assert.Equal(t, "test-profile", response["name"]) - assert.Contains(t, response["description"], "ApplicationProfile shows what the workload containers actually execute") + assert.Equal(t, "default", response.Namespace) + assert.Equal(t, "test-profile", response.Name) + assert.Contains(t, response.Description, "ApplicationProfile shows what the workload containers actually execute") } func TestHandleGetApplicationProfile_MissingName(t *testing.T) { @@ -994,14 +991,13 @@ func TestHandleListNetworkNeighborhoods_Success(t *testing.T) { require.NotNil(t, result) assert.False(t, result.IsError) - var response map[string]interface{} + var response listNetworkNeighborhoodsOutput err = json.Unmarshal([]byte(getResultText(result)), &response) require.NoError(t, err) - assert.Equal(t, float64(2), response["total_count"]) - assert.Contains(t, response["description"], "NetworkNeighborhoods capture actual network communication patterns") - neighborhoods := response["network_neighborhoods"].([]interface{}) - assert.Len(t, neighborhoods, 2) + assert.Equal(t, 2, response.TotalCount) + assert.Contains(t, response.Description, "NetworkNeighborhoods capture actual network communication patterns") + assert.Len(t, response.NetworkNeighborhoods, 2) } func TestHandleListNetworkNeighborhoods_FilterByNamespace(t *testing.T) { @@ -1022,11 +1018,11 @@ func TestHandleListNetworkNeighborhoods_FilterByNamespace(t *testing.T) { require.NoError(t, err) require.NotNil(t, result) - var response map[string]interface{} + var response listNetworkNeighborhoodsOutput err = json.Unmarshal([]byte(getResultText(result)), &response) require.NoError(t, err) - assert.Equal(t, float64(1), response["total_count"]) + assert.Equal(t, 1, response.TotalCount) } func TestHandleListNetworkNeighborhoods_EmptyResults(t *testing.T) { @@ -1037,11 +1033,11 @@ func TestHandleListNetworkNeighborhoods_EmptyResults(t *testing.T) { require.NoError(t, err) require.NotNil(t, result) - var response map[string]interface{} + var response listNetworkNeighborhoodsOutput err = json.Unmarshal([]byte(getResultText(result)), &response) require.NoError(t, err) - assert.Equal(t, float64(0), response["total_count"]) + assert.Equal(t, 0, response.TotalCount) } func TestHandleListNetworkNeighborhoods_InitError(t *testing.T) { @@ -1086,13 +1082,13 @@ func TestHandleGetNetworkNeighborhood_Success(t *testing.T) { require.NotNil(t, result) assert.False(t, result.IsError) - var response map[string]interface{} + var response getNetworkNeighborhoodOutput err = json.Unmarshal([]byte(getResultText(result)), &response) require.NoError(t, err) - assert.Equal(t, "default", response["namespace"]) - assert.Equal(t, "test-nn", response["name"]) - assert.Contains(t, response["description"], "NetworkNeighborhood shows actual network connections") + assert.Equal(t, "default", response.Namespace) + assert.Equal(t, "test-nn", response.Name) + assert.Contains(t, response.Description, "NetworkNeighborhood shows actual network connections") } func TestHandleGetNetworkNeighborhood_MissingName(t *testing.T) { From 7fa76dfc07931776424fc4b8fd0b8825e3c10e64 Mon Sep 17 00:00:00 2001 From: Dmytro Rashko Date: Mon, 21 Sep 2026 01:42:32 +0200 Subject: [PATCH 07/21] style: gofmt errors struct and type the last test-only maps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 15 cleanup: gofmt the ToolError field alignment, and decode the logger trace_id assertion into an anonymous struct. The mcp_test payload stays a map because jsonschema.Resolved.Validate rejects structs for object schemas (google/jsonschema-go#23) — noted inline. Signed-off-by: Dmytro Rashko --- internal/errors/tool_errors.go | 14 +++++++------- internal/logger/logger_test.go | 6 ++++-- internal/mcp/mcp_test.go | 3 +++ 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/internal/errors/tool_errors.go b/internal/errors/tool_errors.go index 3f66c102..0f1280a1 100644 --- a/internal/errors/tool_errors.go +++ b/internal/errors/tool_errors.go @@ -10,13 +10,13 @@ import ( // ToolError represents a structured error with context and recovery suggestions type ToolError struct { - Operation string `json:"operation"` - Cause error `json:"cause"` - Suggestions []string `json:"suggestions"` - IsRetryable bool `json:"is_retryable"` - Timestamp time.Time `json:"timestamp"` - ErrorCode string `json:"error_code"` - Component string `json:"component"` + Operation string `json:"operation"` + Cause error `json:"cause"` + Suggestions []string `json:"suggestions"` + IsRetryable bool `json:"is_retryable"` + Timestamp time.Time `json:"timestamp"` + ErrorCode string `json:"error_code"` + Component string `json:"component"` ResourceType string `json:"resource_type,omitempty"` ResourceName string `json:"resource_name,omitempty"` Context map[string]string `json:"context,omitempty"` diff --git a/internal/logger/logger_test.go b/internal/logger/logger_test.go index fc532a54..f8196962 100644 --- a/internal/logger/logger_test.go +++ b/internal/logger/logger_test.go @@ -103,12 +103,14 @@ func TestWithContextAddsTraceID(t *testing.T) { loggerWithTrace := logger.With("trace_id", span.SpanContext().TraceID().String()) loggerWithTrace.InfoContext(ctx, "test message") - var logOutput map[string]interface{} + var logOutput struct { + TraceID string `json:"trace_id"` + } err := json.Unmarshal(buf.Bytes(), &logOutput) require.NoError(t, err) traceID := span.SpanContext().TraceID().String() - assert.Equal(t, traceID, logOutput["trace_id"]) + assert.Equal(t, traceID, logOutput.TraceID) } func TestGet(t *testing.T) { diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go index 37a4e7cf..10015d84 100644 --- a/internal/mcp/mcp_test.go +++ b/internal/mcp/mcp_test.go @@ -106,6 +106,9 @@ func TestAddToolRelaxesInputSchema(t *testing.T) { if err != nil { t.Fatalf("resolve: %v", err) } + // jsonschema.Resolved.Validate requires a JSON value for an object schema + // and explicitly rejects structs (google/jsonschema-go#23), so this payload + // must stay a map. It is a test fixture, not a tool parameter container. partial := map[string]any{"resource_type": "namespace", "output": "json"} if err := resolved.Validate(partial); err != nil { t.Errorf("partial payload should validate, got: %v", err) From a7c6740d6d88722732916254cb95f983ac50ebe4 Mon Sep 17 00:00:00 2001 From: Dmytro Rashko Date: Mon, 21 Sep 2026 07:21:25 +0200 Subject: [PATCH 08/21] refactor: return typed outputs from all MCP handlers Complete the go-sdk migration by removing the last untyped tool I/O. Every handler previously returned Out=any, so the SDK could not infer an output schema, populate StructuredContent, or validate the result. All 145 handlers now return a concrete Out type. Raw CLI text goes through the shared mcp.TextOutput wrapper ({output: "..."}) via new mcp.TextResult / mcp.TextError / mcp.TextOf helpers. Structured responses return their own DTO. Text stays in Content as before, so existing clients are unaffected; StructuredContent is now populated too. Three SDK behaviours drove the design, each confirmed against a live in-memory server: - json.RawMessage is inferred as a byte slice, not arbitrary JSON, and validation then rejects real objects/arrays. pkg/prometheus now re-indents dynamic API JSON with json.Indent instead of decoding into interface{}. - Zero values are validated on error paths too, so a map field without omitempty makes every error return fail with "validating tool output". pkg/kubescape gained omitempty on its map fields; CheckStatus.Details is typed as []PodCheckEntry instead of interface{}. Slices infer as nullable and need no change. - Types with custom JSON marshallers can fail schema inference, which panics AddTool at registration. v1beta1.WorkloadConfigurationScan is one such type, so handleGetConfigurationScan returns mcp.TextOutput. The new cmd/tools_output_schema_test.go registers every provider and fails if any Out type cannot produce a valid schema. test/e2e/helpers_test.go now returns *mcp.CallToolResult and []*mcp.Tool instead of interface{}, and the input-schema regression test exercises a real client->server call with typed partial arguments rather than validating a map[string]any fixture by hand. The only remaining "any" tokens are generic type parameters ([T any]), which bind concrete types at every call site. AGENTS.md documents the typed-output contract, the TextResult/TextError/ TextOf helpers, and the three output-schema pitfalls above. Verified: gofmt clean, go build ./... and go vet ./... pass, make lint reports 0 issues, go test ./pkg/... ./internal/... ./cmd/... passes 19/19 packages, every pkg/ package is above the 80% coverage gate, and an in-repo grep finds no interface{}, no Out=any, and no untyped maps. Signed-off-by: Dmytro Rashko --- AGENTS.md | 51 +++- cmd/tools_output_schema_test.go | 51 ++++ internal/mcp/mcp.go | 41 ++++ internal/mcp/mcp_test.go | 46 ++-- pkg/argo/argo.go | 72 +++--- pkg/cilium/cilium.go | 404 ++++++++++++++++---------------- pkg/helm/helm.go | 58 ++--- pkg/istio/istio.go | 88 +++---- pkg/k8s/k8s.go | 188 +++++++-------- pkg/kubescape/kubescape.go | 182 +++++++------- pkg/prometheus/prometheus.go | 123 ++++------ pkg/prometheus/promql.go | 12 +- pkg/utils/common.go | 14 +- test/e2e/helpers_test.go | 19 +- 14 files changed, 732 insertions(+), 617 deletions(-) create mode 100644 cmd/tools_output_schema_test.go diff --git a/AGENTS.md b/AGENTS.md index d429130b..f91b78c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,28 +125,55 @@ Before submitting changes, run `make fmt && make lint && make test`. ### Tool Registration Pattern -Each provider implements a `RegisterTools` function that adds MCP tool handlers to the server: +Each provider implements a `RegisterTools` function that adds MCP tool handlers to the server. Registration goes through the wrapper in `internal/mcp`, which records the tool's provider for metrics and relaxes the inferred input schema so optional fields stay optional: ```go -func RegisterTools(server *server.MCPServer, readOnly bool) { - server.AddTool(mcp.NewTool("tool_name", ...), handleToolName) +func RegisterTools(s *mcp.Server, readOnly bool) { + mcp.AddTool(s, "k8s", &mcp.Tool{ + Name: "k8s_get_resources", + Description: "Get Kubernetes resources", + }, handleGetResources) + if !readOnly { - server.AddTool(mcp.NewTool("write_tool", ...), handleWriteTool) + mcp.AddTool(s, "k8s", &mcp.Tool{ + Name: "k8s_delete_resource", + Description: "Delete a Kubernetes resource", + }, handleDeleteResource) } } ``` -Handler functions are prefixed with `handle` (e.g., `handleKubectlGetEnhanced`, `handleHelmList`). +Handlers are registered with a typed input and a typed output: `func handleX(ctx context.Context, req *mcp.CallToolRequest, in xInput) (*mcp.CallToolResult, xOutput, error)`. Handler functions are prefixed with `handle` (e.g., `handleKubectlGetEnhanced`, `handleHelmList`). ### Typed MCP Inputs and Outputs -All MCP tool inputs and outputs must be strongly typed: +All MCP tool inputs and outputs must be strongly typed. The Go MCP SDK derives an input and output JSON schema from the handler's `In` and `Out` type parameters, populates `CallToolResult.StructuredContent` from the typed `Out` value, and validates that value against the inferred output schema on every call — so an untyped or wrongly-shaped `Out` is not merely untidy, it breaks the tool. - Define a concrete input struct for every tool with `json` and `jsonschema` tags. -- Define a concrete output DTO for every structured response. Raw CLI text may use a shared typed wrapper such as `TextOutput` with an `Output string` field. -- When using the Go MCP SDK wrapper, do not register handlers with `Out=any`; typed outputs enable output schema inference and validation. +- Define a concrete output DTO for every structured response. +- Never register handlers with `Out=any`; typed outputs enable output schema inference and validation. - Do not use `any`, `interface{}`, `map[string]any`, `map[string]interface{}`, `[]any`, or `[]interface{}` for handler inputs, handler outputs, public response DTOs, or tests. -- If a payload is genuinely dynamic JSON, isolate it as `json.RawMessage` behind a typed envelope instead of spreading loose maps through handlers. +- Handler signature: `func handleX(ctx, req, in xInput) (*mcp.CallToolResult, xOutput, error)`. + +**Raw CLI text.** Most providers wrap CLI output in text. Use the shared `mcp.TextOutput` wrapper (`{"output": "..."}`) instead of inventing a per-tool shape, and return it through the helpers so the zero value on an error path still validates: + +```go +// success — text is preserved in Content and mirrored in StructuredContent +return mcp.TextResult(output) + +// tool-level failure — IsError=true, and the empty TextOutput keeps the +// inferred output schema satisfied +return mcp.TextError("resource_name is required") +``` + +When a helper builds the `*mcp.CallToolResult` itself (e.g. `runKubectlCommand` returning `(*mcp.CallToolResult, error)`), convert it with `mcp.TextOf(res)` and return `res, mcp.TextOf(res), err` so the typed value matches the returned result. + +**Output-schema pitfalls.** These are enforced by the SDK at call time and are easy to trip: + +- **Zero values are validated on every path, including errors.** A field whose zero value marshals to `null` but whose schema type is non-nullable (notably `map[K]V`) makes *all* error returns fail with `validating tool output`. Give such fields `omitempty`. Slices and pointers infer as nullable (`["null", ...]`) and are safe. +- **`json.RawMessage` does not mean "arbitrary JSON".** The schema inference treats it as a byte slice and validation then rejects real objects and arrays. For genuinely dynamic JSON, return the raw text through `mcp.TextOutput` rather than a `json.RawMessage` field, or re-indent it in place with `json.Indent` without decoding into `interface{}` (see `prettyJSONBody` in `pkg/prometheus`). +- **Not every type can be an `Out`.** Third-party structs with custom JSON marshallers can fail schema inference, which makes `mcp.AddTool` *panic* at registration (the server will not start). `v1beta1.WorkloadConfigurationScan` is one such type; such handlers return `mcp.TextOutput`. `cmd/tools_output_schema_test.go` registers every provider and fails if any `Out` type cannot produce a valid schema. +- **A third-party type may be used** as an `Out` field where inference succeeds (`[]v1beta1.Match`, `v1beta1.ExecCalls`, `metav1.LabelSelector` all work today); prefer a local summary DTO where it does not. ### CommandBuilder Pattern @@ -293,6 +320,8 @@ Types: `feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `perf`, `ci` - Do not duplicate logic across providers — extract to `internal/` packages. - Do not bypass the cache for read operations. - Do not use untyped maps or `any` for MCP tool input/output schemas or public response bodies. +- Do not register a handler with `Out=any` — the SDK cannot infer or validate an output schema, and the typed-output contract is what keeps the tool callable. +- Do not add a map-typed field to an output DTO without `omitempty`, and do not use `json.RawMessage` as a dynamic-JSON output field — both make the SDK reject valid results at call time (see the output-schema pitfalls above). - Do not add new tool providers without a corresponding `RegisterTools` function. - Do not commit without running `make fmt && make lint && make test`. @@ -306,8 +335,8 @@ Types: `feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `perf`, `ci` 4. Register the provider in `cmd/main.go` inside `registerMCP()`. 5. Add input validation using `internal/security/`. 6. Use `CommandBuilder` for CLI execution. -7. Define concrete typed input and output DTOs; avoid `any`, `interface{}`, and untyped maps. -8. Return errors via `ToolError.ToMCPResult()`. +7. Define concrete typed input and output DTOs; avoid `any`, `interface{}`, and untyped maps. Return `mcp.TextResult(...)` / `mcp.TextError(...)` for raw CLI text, and a concrete DTO for a structured response. Watch the output-schema pitfalls above (`omitempty` on map fields, no `json.RawMessage` fields, no `Out` types that fail schema inference). +8. Return errors via `ToolError.ToMCPResult()`; remember the `Out` value must still validate on the error path, so return the zero value of the DTO (or `mcp.TextOutput{}`). 9. Write unit tests with mock shell executor (80% coverage minimum). 10. Add E2E tests if the tool interacts with a cluster. 11. Run `make fmt && make lint && make test` before submitting. diff --git a/cmd/tools_output_schema_test.go b/cmd/tools_output_schema_test.go new file mode 100644 index 00000000..cadf9d7c --- /dev/null +++ b/cmd/tools_output_schema_test.go @@ -0,0 +1,51 @@ +package main + +import ( + "context" + "encoding/json" + "testing" + + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/require" +) + +// TestEveryToolHasValidOutputSchema is the wire-level guard for the typed-output +// migration: registering every provider tool must produce a resolvable output +// schema for each tool whose Out type is not `any`. AddTool panics when schema +// inference or resolution fails (for example a third-party k8s type with a +// custom JSON marshaller), so a panic here means the server would not start. +func TestEveryToolHasValidOutputSchema(t *testing.T) { + ctx := context.Background() + + srv := sdkmcp.NewServer(&sdkmcp.Implementation{Name: "schema", Version: "test"}, nil) + + require.NotPanics(t, func() { + registerMCP(srv, nil, "", false) + }, "registerMCP must not panic: every Out type must infer a valid output schema") + + serverT, clientT := sdkmcp.NewInMemoryTransports() + go func() { _ = srv.Run(ctx, serverT) }() + + client := sdkmcp.NewClient(&sdkmcp.Implementation{Name: "schema-client", Version: "test"}, nil) + session, err := client.Connect(ctx, clientT, nil) + require.NoError(t, err) + defer func() { _ = session.Close() }() + + checked := 0 + for tool, err := range session.Tools(ctx, nil) { + require.NoError(t, err) + require.NotEmpty(t, tool.Name) + + // Every migrated tool carries an inferred output schema: Out is a concrete + // type rather than `any`. Tools left with Out=any would have none. + require.NotNilf(t, tool.OutputSchema, + "tool %q has no output schema; its handler still returns Out=any", tool.Name) + + // The advertised schema must survive a JSON round-trip (it is sent on the + // wire as part of tools/list). + _, err := json.Marshal(tool.OutputSchema) + require.NoErrorf(t, err, "tool %q output schema is not JSON-serializable", tool.Name) + checked++ + } + require.NotEmpty(t, checked, "expected the server to advertise tools") +} diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index 525e206b..6bc0dccc 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -9,6 +9,7 @@ import ( "context" "net/http" "reflect" + "strings" "sync" "time" @@ -63,6 +64,46 @@ func Header(req *sdk.CallToolRequest) http.Header { return nil } +// TextOutput is the typed output for tools whose result is raw CLI text. It is +// the shared wrapper described by the repository's typed-I/O convention: rather +// than registering a handler with Out=any, a text tool returns a concrete +// TextOutput so the SDK can infer an output schema and populate +// CallToolResult.StructuredContent. +type TextOutput struct { + Output string `json:"output"` +} + +// TextResult is the typed equivalent of NewToolResultText for a handler whose +// Out type is TextOutput. The human-readable text stays in Content, so existing +// clients (and pre-SEP-2106 clients that only read Content) are unaffected, +// while StructuredContent carries the same value as a typed object. +func TextResult(text string) (*sdk.CallToolResult, TextOutput, error) { + return NewToolResultText(text), TextOutput{Output: text}, nil +} + +// TextError is the typed equivalent of NewToolResultError for a handler whose +// Out type is TextOutput. It returns an empty TextOutput so the zero value still +// satisfies the inferred output schema on the error path. +func TextError(message string) (*sdk.CallToolResult, TextOutput, error) { + return NewToolResultError(message), TextOutput{}, nil +} + +// TextOf extracts the concatenated text content of a result as a TextOutput, so +// handlers that build a *CallToolResult in a helper can still return a typed +// output value. A nil result yields an empty TextOutput. +func TextOf(res *sdk.CallToolResult) TextOutput { + if res == nil { + return TextOutput{} + } + var b strings.Builder + for _, content := range res.Content { + if textContent, ok := content.(*sdk.TextContent); ok { + b.WriteString(textContent.Text) + } + } + return TextOutput{Output: b.String()} +} + // providerByTool maps a registered tool name to its provider for metric labels. var providerByTool sync.Map diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go index 10015d84..8ea5ab1c 100644 --- a/internal/mcp/mcp_test.go +++ b/internal/mcp/mcp_test.go @@ -49,8 +49,8 @@ func TestAddToolRecordsProvider(t *testing.T) { type in struct { Name string `json:"name"` } - AddTool(s, "myprovider", &Tool{Name: "my_tool"}, func(_ context.Context, _ *CallToolRequest, _ in) (*CallToolResult, any, error) { - return NewToolResultText("ok"), nil, nil + AddTool(s, "myprovider", &Tool{Name: "my_tool"}, func(_ context.Context, _ *CallToolRequest, _ in) (*CallToolResult, TextOutput, error) { + return TextResult("ok") }) if got := providerOf("my_tool"); got != "myprovider" { @@ -82,8 +82,8 @@ func TestAddToolRelaxesInputSchema(t *testing.T) { Output string `json:"output"` } tool := &Tool{Name: "relax_tool"} - AddTool(s, "p", tool, func(_ context.Context, _ *CallToolRequest, _ in) (*CallToolResult, any, error) { - return NewToolResultText("ok"), nil, nil + AddTool(s, "p", tool, func(_ context.Context, _ *CallToolRequest, _ in) (*CallToolResult, TextOutput, error) { + return TextResult("ok") }) schema, ok := tool.InputSchema.(*jsonschema.Schema) @@ -101,17 +101,35 @@ func TestAddToolRelaxesInputSchema(t *testing.T) { } // The relaxed schema must accept a payload that omits optional fields, which - // is exactly what the e2e client sends and what previously failed. - resolved, err := schema.Resolve(nil) + // is exactly what the e2e client sends and what previously failed. Exercise + // the real client->server path with a typed, deliberately partial argument + // value rather than validating a fixture by hand. + serverT, clientT := sdk.NewInMemoryTransports() + ctx := context.Background() + if _, err := s.Connect(ctx, serverT, nil); err != nil { + t.Fatalf("server connect: %v", err) + } + client := sdk.NewClient(&sdk.Implementation{Name: "relax-client", Version: "v"}, nil) + session, err := client.Connect(ctx, clientT, nil) if err != nil { - t.Fatalf("resolve: %v", err) - } - // jsonschema.Resolved.Validate requires a JSON value for an object schema - // and explicitly rejects structs (google/jsonschema-go#23), so this payload - // must stay a map. It is a test fixture, not a tool parameter container. - partial := map[string]any{"resource_type": "namespace", "output": "json"} - if err := resolved.Validate(partial); err != nil { - t.Errorf("partial payload should validate, got: %v", err) + t.Fatalf("client connect: %v", err) + } + defer func() { _ = session.Close() }() + + // A partial argument value: only resource_type is set, every other field is + // omitted. Pre-migration this call was rejected for missing required fields. + type partialArgs struct { + ResourceType string `json:"resource_type"` + } + result, err := session.CallTool(ctx, &sdk.CallToolParams{ + Name: "relax_tool", + Arguments: partialArgs{ResourceType: "namespace"}, + }) + if err != nil { + t.Fatalf("partial payload should be accepted, got: %v", err) + } + if result.IsError { + t.Fatalf("partial payload returned a tool error: %v", result) } } diff --git a/pkg/argo/argo.go b/pkg/argo/argo.go index 01aeaa0e..71822f6f 100644 --- a/pkg/argo/argo.go +++ b/pkg/argo/argo.go @@ -23,7 +23,7 @@ type verifyArgoRolloutsControllerInstallInput struct { Label string `json:"label" jsonschema:"The label of the Argo Rollouts controller pods"` } -func handleVerifyArgoRolloutsControllerInstall(ctx context.Context, request *mcp.CallToolRequest, in verifyArgoRolloutsControllerInstallInput) (*mcp.CallToolResult, any, error) { +func handleVerifyArgoRolloutsControllerInstall(ctx context.Context, request *mcp.CallToolRequest, in verifyArgoRolloutsControllerInstallInput) (*mcp.CallToolResult, mcp.TextOutput, error) { ns := in.Namespace if ns == "" { ns = "argo-rollouts" @@ -36,21 +36,21 @@ func handleVerifyArgoRolloutsControllerInstall(ctx context.Context, request *mcp cmd := []string{"get", "pods", "-n", ns, "-l", label, "-o", "jsonpath={.items[*].status.phase}"} output, err := runArgoRolloutCommand(ctx, cmd) if err != nil { - return mcp.NewToolResultError("Error: " + err.Error()), nil, nil + return mcp.TextError("Error: " + err.Error()) } output = strings.TrimSpace(output) if output == "" { - return mcp.NewToolResultText("Error: No pods found"), nil, nil + return mcp.TextResult("Error: No pods found") } if strings.HasPrefix(output, "Error") { - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } podStatuses := strings.Fields(output) if len(podStatuses) == 0 { - return mcp.NewToolResultText("Error: No pod statuses returned"), nil, nil + return mcp.TextResult("Error: No pod statuses returned") } allRunning := true @@ -62,25 +62,25 @@ func handleVerifyArgoRolloutsControllerInstall(ctx context.Context, request *mcp } if allRunning { - return mcp.NewToolResultText("All pods are running"), nil, nil + return mcp.TextResult("All pods are running") } - return mcp.NewToolResultText("Error: Not all pods are running (" + strings.Join(podStatuses, " ") + ")"), nil, nil + return mcp.TextResult("Error: Not all pods are running (" + strings.Join(podStatuses, " ") + ")") } type verifyKubectlPluginInstallInput struct{} -func handleVerifyKubectlPluginInstall(ctx context.Context, request *mcp.CallToolRequest, in verifyKubectlPluginInstallInput) (*mcp.CallToolResult, any, error) { +func handleVerifyKubectlPluginInstall(ctx context.Context, request *mcp.CallToolRequest, in verifyKubectlPluginInstallInput) (*mcp.CallToolResult, mcp.TextOutput, error) { args := []string{"argo", "rollouts", "version"} output, err := runArgoRolloutCommand(ctx, args) if err != nil { - return mcp.NewToolResultText("Kubectl Argo Rollouts plugin is not installed: " + err.Error()), nil, nil + return mcp.TextResult("Kubectl Argo Rollouts plugin is not installed: " + err.Error()) } if strings.HasPrefix(output, "Error") { - return mcp.NewToolResultText("Kubectl Argo Rollouts plugin is not installed: " + output), nil, nil + return mcp.TextResult("Kubectl Argo Rollouts plugin is not installed: " + output) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } func runArgoRolloutCommand(ctx context.Context, args []string) (string, error) { @@ -97,9 +97,9 @@ type promoteRolloutInput struct { Full bool `json:"full" jsonschema:"Promote the rollout to the final step"` } -func handlePromoteRollout(ctx context.Context, request *mcp.CallToolRequest, in promoteRolloutInput) (*mcp.CallToolResult, any, error) { +func handlePromoteRollout(ctx context.Context, request *mcp.CallToolRequest, in promoteRolloutInput) (*mcp.CallToolResult, mcp.TextOutput, error) { if in.RolloutName == "" { - return mcp.NewToolResultError("rollout_name parameter is required"), nil, nil + return mcp.TextError("rollout_name parameter is required") } cmd := []string{"argo", "rollouts", "promote"} @@ -113,10 +113,10 @@ func handlePromoteRollout(ctx context.Context, request *mcp.CallToolRequest, in output, err := runArgoRolloutCommand(ctx, cmd) if err != nil { - return mcp.NewToolResultError("Error promoting rollout: " + err.Error()), nil, nil + return mcp.TextError("Error promoting rollout: " + err.Error()) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } type pauseRolloutInput struct { @@ -124,9 +124,9 @@ type pauseRolloutInput struct { Namespace string `json:"namespace" jsonschema:"The namespace of the rollout"` } -func handlePauseRollout(ctx context.Context, request *mcp.CallToolRequest, in pauseRolloutInput) (*mcp.CallToolResult, any, error) { +func handlePauseRollout(ctx context.Context, request *mcp.CallToolRequest, in pauseRolloutInput) (*mcp.CallToolResult, mcp.TextOutput, error) { if in.RolloutName == "" { - return mcp.NewToolResultError("rollout_name parameter is required"), nil, nil + return mcp.TextError("rollout_name parameter is required") } cmd := []string{"argo", "rollouts", "pause"} @@ -137,10 +137,10 @@ func handlePauseRollout(ctx context.Context, request *mcp.CallToolRequest, in pa output, err := runArgoRolloutCommand(ctx, cmd) if err != nil { - return mcp.NewToolResultError("Error pausing rollout: " + err.Error()), nil, nil + return mcp.TextError("Error pausing rollout: " + err.Error()) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } type setRolloutImageInput struct { @@ -149,12 +149,12 @@ type setRolloutImageInput struct { Namespace string `json:"namespace" jsonschema:"The namespace of the rollout"` } -func handleSetRolloutImage(ctx context.Context, request *mcp.CallToolRequest, in setRolloutImageInput) (*mcp.CallToolResult, any, error) { +func handleSetRolloutImage(ctx context.Context, request *mcp.CallToolRequest, in setRolloutImageInput) (*mcp.CallToolResult, mcp.TextOutput, error) { if in.RolloutName == "" { - return mcp.NewToolResultError("rollout_name parameter is required"), nil, nil + return mcp.TextError("rollout_name parameter is required") } if in.ContainerImage == "" { - return mcp.NewToolResultError("container_image parameter is required"), nil, nil + return mcp.TextError("container_image parameter is required") } cmd := []string{"argo", "rollouts", "set", "image", in.RolloutName, in.ContainerImage} @@ -164,10 +164,10 @@ func handleSetRolloutImage(ctx context.Context, request *mcp.CallToolRequest, in output, err := runArgoRolloutCommand(ctx, cmd) if err != nil { - return mcp.NewToolResultError("Error setting rollout image: " + err.Error()), nil, nil + return mcp.TextError("Error setting rollout image: " + err.Error()) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } // GatewayPluginStatus struct @@ -303,7 +303,7 @@ type verifyGatewayPluginInput struct { ShouldInstall *bool `json:"should_install" jsonschema:"Whether to install the plugin if not found"` } -func handleVerifyGatewayPlugin(ctx context.Context, request *mcp.CallToolRequest, in verifyGatewayPluginInput) (*mcp.CallToolResult, any, error) { +func handleVerifyGatewayPlugin(ctx context.Context, request *mcp.CallToolRequest, in verifyGatewayPluginInput) (*mcp.CallToolResult, mcp.TextOutput, error) { version := in.Version namespace := in.Namespace if namespace == "" { @@ -322,7 +322,7 @@ func handleVerifyGatewayPlugin(ctx context.Context, request *mcp.CallToolRequest Installed: true, ErrorMessage: "Gateway API plugin is already configured", } - return mcp.NewToolResultText(status.String()), nil, nil + return mcp.TextResult(status.String()) } if !shouldInstall { @@ -330,12 +330,12 @@ func handleVerifyGatewayPlugin(ctx context.Context, request *mcp.CallToolRequest Installed: false, ErrorMessage: "Gateway API plugin is not configured and installation is disabled", } - return mcp.NewToolResultText(status.String()), nil, nil + return mcp.TextResult(status.String()) } // Configure plugin status := configureGatewayPlugin(ctx, version, namespace) - return mcp.NewToolResultText(status.String()), nil, nil + return mcp.TextResult(status.String()) } type checkPluginLogsInput struct { @@ -343,7 +343,7 @@ type checkPluginLogsInput struct { Timeout int `json:"timeout" jsonschema:"Timeout for log collection in seconds"` } -func handleCheckPluginLogs(ctx context.Context, request *mcp.CallToolRequest, in checkPluginLogsInput) (*mcp.CallToolResult, any, error) { +func handleCheckPluginLogs(ctx context.Context, request *mcp.CallToolRequest, in checkPluginLogsInput) (*mcp.CallToolResult, mcp.TextOutput, error) { namespace := in.Namespace if namespace == "" { namespace = "argo-rollouts" @@ -361,7 +361,7 @@ func handleCheckPluginLogs(ctx context.Context, request *mcp.CallToolRequest, in Installed: false, ErrorMessage: err.Error(), } - return mcp.NewToolResultText(status.String()), nil, nil + return mcp.TextResult(status.String()) } // Parse download information @@ -379,14 +379,14 @@ func handleCheckPluginLogs(ctx context.Context, request *mcp.CallToolRequest, in Architecture: versionMatches[2], DownloadTime: downloadTime, } - return mcp.NewToolResultText(status.String()), nil, nil + return mcp.TextResult(status.String()) } status := GatewayPluginStatus{ Installed: false, ErrorMessage: "Plugin installation not found in logs", } - return mcp.NewToolResultText(status.String()), nil, nil + return mcp.TextResult(status.String()) } type listRolloutsInput struct { @@ -394,7 +394,7 @@ type listRolloutsInput struct { Type string `json:"type" jsonschema:"What to list: rollouts or experiments"` } -func handleListRollouts(ctx context.Context, request *mcp.CallToolRequest, in listRolloutsInput) (*mcp.CallToolResult, any, error) { +func handleListRollouts(ctx context.Context, request *mcp.CallToolRequest, in listRolloutsInput) (*mcp.CallToolResult, mcp.TextOutput, error) { ns := in.Namespace if ns == "" { ns = "argo-rollouts" @@ -411,14 +411,14 @@ func handleListRollouts(ctx context.Context, request *mcp.CallToolRequest, in li output, err := runArgoRolloutCommand(ctx, cmd) if err != nil { - return mcp.NewToolResultError("Error listing rollouts: " + err.Error()), nil, nil + return mcp.TextError("Error listing rollouts: " + err.Error()) } if strings.HasPrefix(output, "Error") { - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } func RegisterTools(s *mcp.Server, readOnly bool) { diff --git a/pkg/cilium/cilium.go b/pkg/cilium/cilium.go index af146b31..445b740e 100644 --- a/pkg/cilium/cilium.go +++ b/pkg/cilium/cilium.go @@ -219,22 +219,22 @@ func runCiliumCliWithContext(ctx context.Context, args ...string) (string, error Execute(ctx) } -func handleCiliumStatusAndVersion(ctx context.Context, request *mcp.CallToolRequest, in noInput) (*mcp.CallToolResult, any, error) { +func handleCiliumStatusAndVersion(ctx context.Context, request *mcp.CallToolRequest, in noInput) (*mcp.CallToolResult, mcp.TextOutput, error) { status, err := runCiliumCliWithContext(ctx, "status") if err != nil { - return mcp.NewToolResultError("Error getting Cilium status: " + err.Error()), nil, nil + return mcp.TextError("Error getting Cilium status: " + err.Error()) } version, err := runCiliumCliWithContext(ctx, "version") if err != nil { - return mcp.NewToolResultError("Error getting Cilium version: " + err.Error()), nil, nil + return mcp.TextError("Error getting Cilium version: " + err.Error()) } result := status + "\n" + version - return mcp.NewToolResultText(result), nil, nil + return mcp.TextResult(result) } -func handleUpgradeCilium(ctx context.Context, request *mcp.CallToolRequest, in upgradeCiliumInput) (*mcp.CallToolResult, any, error) { +func handleUpgradeCilium(ctx context.Context, request *mcp.CallToolRequest, in upgradeCiliumInput) (*mcp.CallToolResult, mcp.TextOutput, error) { clusterName := in.ClusterName datapathMode := in.DatapathMode @@ -248,13 +248,13 @@ func handleUpgradeCilium(ctx context.Context, request *mcp.CallToolRequest, in u output, err := runCiliumCliWithContext(ctx, args...) if err != nil { - return mcp.NewToolResultError("Error upgrading Cilium: " + err.Error()), nil, nil + return mcp.TextError("Error upgrading Cilium: " + err.Error()) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleInstallCilium(ctx context.Context, request *mcp.CallToolRequest, in installCiliumInput) (*mcp.CallToolResult, any, error) { +func handleInstallCilium(ctx context.Context, request *mcp.CallToolRequest, in installCiliumInput) (*mcp.CallToolResult, mcp.TextOutput, error) { clusterName := in.ClusterName clusterID := in.ClusterID datapathMode := in.DatapathMode @@ -272,27 +272,27 @@ func handleInstallCilium(ctx context.Context, request *mcp.CallToolRequest, in i output, err := runCiliumCliWithContext(ctx, args...) if err != nil { - return mcp.NewToolResultError("Error installing Cilium: " + err.Error()), nil, nil + return mcp.TextError("Error installing Cilium: " + err.Error()) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleUninstallCilium(ctx context.Context, request *mcp.CallToolRequest, in noInput) (*mcp.CallToolResult, any, error) { +func handleUninstallCilium(ctx context.Context, request *mcp.CallToolRequest, in noInput) (*mcp.CallToolResult, mcp.TextOutput, error) { output, err := runCiliumCliWithContext(ctx, "uninstall") if err != nil { - return mcp.NewToolResultError("Error uninstalling Cilium: " + err.Error()), nil, nil + return mcp.TextError("Error uninstalling Cilium: " + err.Error()) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleConnectToRemoteCluster(ctx context.Context, request *mcp.CallToolRequest, in connectToRemoteClusterInput) (*mcp.CallToolResult, any, error) { +func handleConnectToRemoteCluster(ctx context.Context, request *mcp.CallToolRequest, in connectToRemoteClusterInput) (*mcp.CallToolResult, mcp.TextOutput, error) { clusterName := in.ClusterName destContext := in.Context if clusterName == "" { - return mcp.NewToolResultError("cluster_name parameter is required"), nil, nil + return mcp.TextError("cluster_name parameter is required") } args := []string{"clustermesh", "connect", "--destination-cluster", clusterName} @@ -302,66 +302,66 @@ func handleConnectToRemoteCluster(ctx context.Context, request *mcp.CallToolRequ output, err := runCiliumCliWithContext(ctx, args...) if err != nil { - return mcp.NewToolResultError("Error connecting to remote cluster: " + err.Error()), nil, nil + return mcp.TextError("Error connecting to remote cluster: " + err.Error()) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleDisconnectRemoteCluster(ctx context.Context, request *mcp.CallToolRequest, in disconnectRemoteClusterInput) (*mcp.CallToolResult, any, error) { +func handleDisconnectRemoteCluster(ctx context.Context, request *mcp.CallToolRequest, in disconnectRemoteClusterInput) (*mcp.CallToolResult, mcp.TextOutput, error) { clusterName := in.ClusterName if clusterName == "" { - return mcp.NewToolResultError("cluster_name parameter is required"), nil, nil + return mcp.TextError("cluster_name parameter is required") } args := []string{"clustermesh", "disconnect", "--destination-cluster", clusterName} output, err := runCiliumCliWithContext(ctx, args...) if err != nil { - return mcp.NewToolResultError("Error disconnecting from remote cluster: " + err.Error()), nil, nil + return mcp.TextError("Error disconnecting from remote cluster: " + err.Error()) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleListBGPPeers(ctx context.Context, request *mcp.CallToolRequest, in noInput) (*mcp.CallToolResult, any, error) { +func handleListBGPPeers(ctx context.Context, request *mcp.CallToolRequest, in noInput) (*mcp.CallToolResult, mcp.TextOutput, error) { output, err := runCiliumCliWithContext(ctx, "bgp", "peers") if err != nil { - return mcp.NewToolResultError("Error listing BGP peers: " + err.Error()), nil, nil + return mcp.TextError("Error listing BGP peers: " + err.Error()) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleListBGPRoutes(ctx context.Context, request *mcp.CallToolRequest, in noInput) (*mcp.CallToolResult, any, error) { +func handleListBGPRoutes(ctx context.Context, request *mcp.CallToolRequest, in noInput) (*mcp.CallToolResult, mcp.TextOutput, error) { output, err := runCiliumCliWithContext(ctx, "bgp", "routes") if err != nil { - return mcp.NewToolResultError("Error listing BGP routes: " + err.Error()), nil, nil + return mcp.TextError("Error listing BGP routes: " + err.Error()) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleShowClusterMeshStatus(ctx context.Context, request *mcp.CallToolRequest, in noInput) (*mcp.CallToolResult, any, error) { +func handleShowClusterMeshStatus(ctx context.Context, request *mcp.CallToolRequest, in noInput) (*mcp.CallToolResult, mcp.TextOutput, error) { output, err := runCiliumCliWithContext(ctx, "clustermesh", "status") if err != nil { - return mcp.NewToolResultError("Error getting cluster mesh status: " + err.Error()), nil, nil + return mcp.TextError("Error getting cluster mesh status: " + err.Error()) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleShowFeaturesStatus(ctx context.Context, request *mcp.CallToolRequest, in noInput) (*mcp.CallToolResult, any, error) { +func handleShowFeaturesStatus(ctx context.Context, request *mcp.CallToolRequest, in noInput) (*mcp.CallToolResult, mcp.TextOutput, error) { output, err := runCiliumCliWithContext(ctx, "features", "status") if err != nil { - return mcp.NewToolResultError("Error getting features status: " + err.Error()), nil, nil + return mcp.TextError("Error getting features status: " + err.Error()) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleToggleHubble(ctx context.Context, request *mcp.CallToolRequest, in enableToggleInput) (*mcp.CallToolResult, any, error) { +func handleToggleHubble(ctx context.Context, request *mcp.CallToolRequest, in enableToggleInput) (*mcp.CallToolResult, mcp.TextOutput, error) { enable := true if in.Enable != nil { enable = *in.Enable @@ -375,13 +375,13 @@ func handleToggleHubble(ctx context.Context, request *mcp.CallToolRequest, in en output, err := runCiliumCliWithContext(ctx, "hubble", action) if err != nil { - return mcp.NewToolResultError("Error toggling Hubble: " + err.Error()), nil, nil + return mcp.TextError("Error toggling Hubble: " + err.Error()) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleToggleClusterMesh(ctx context.Context, request *mcp.CallToolRequest, in enableToggleInput) (*mcp.CallToolResult, any, error) { +func handleToggleClusterMesh(ctx context.Context, request *mcp.CallToolRequest, in enableToggleInput) (*mcp.CallToolResult, mcp.TextOutput, error) { enable := true if in.Enable != nil { enable = *in.Enable @@ -395,10 +395,10 @@ func handleToggleClusterMesh(ctx context.Context, request *mcp.CallToolRequest, output, err := runCiliumCliWithContext(ctx, "clustermesh", action) if err != nil { - return mcp.NewToolResultError("Error toggling cluster mesh: " + err.Error()), nil, nil + return mcp.TextError("Error toggling cluster mesh: " + err.Error()) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } func RegisterTools(s *mcp.Server, readOnly bool) { @@ -532,7 +532,7 @@ func runCiliumDbgCommandWithContext(ctx context.Context, command, nodeName strin Execute(ctx) } -func handleGetEndpointDetails(ctx context.Context, request *mcp.CallToolRequest, in getEndpointDetailsInput) (*mcp.CallToolResult, any, error) { +func handleGetEndpointDetails(ctx context.Context, request *mcp.CallToolRequest, in getEndpointDetailsInput) (*mcp.CallToolResult, mcp.TextOutput, error) { if in.OutputFormat == "" { in.OutputFormat = "json" } @@ -547,49 +547,49 @@ func handleGetEndpointDetails(ctx context.Context, request *mcp.CallToolRequest, } else if endpointID != "" { cmd = fmt.Sprintf("endpoint get %s -o %s", endpointID, outputFormat) } else { - return mcp.NewToolResultError("either endpoint_id or labels must be provided"), nil, nil + return mcp.TextError("either endpoint_id or labels must be provided") } output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to get endpoint details: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to get endpoint details: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleGetEndpointLogs(ctx context.Context, request *mcp.CallToolRequest, in getEndpointLogsInput) (*mcp.CallToolResult, any, error) { +func handleGetEndpointLogs(ctx context.Context, request *mcp.CallToolRequest, in getEndpointLogsInput) (*mcp.CallToolResult, mcp.TextOutput, error) { endpointID := in.EndpointID nodeName := in.NodeName if endpointID == "" { - return mcp.NewToolResultError("endpoint_id parameter is required"), nil, nil + return mcp.TextError("endpoint_id parameter is required") } cmd := fmt.Sprintf("endpoint logs %s", endpointID) output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to get endpoint logs: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to get endpoint logs: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleGetEndpointHealth(ctx context.Context, request *mcp.CallToolRequest, in getEndpointHealthInput) (*mcp.CallToolResult, any, error) { +func handleGetEndpointHealth(ctx context.Context, request *mcp.CallToolRequest, in getEndpointHealthInput) (*mcp.CallToolResult, mcp.TextOutput, error) { endpointID := in.EndpointID nodeName := in.NodeName if endpointID == "" { - return mcp.NewToolResultError("endpoint_id parameter is required"), nil, nil + return mcp.TextError("endpoint_id parameter is required") } cmd := fmt.Sprintf("endpoint health %s", endpointID) output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to get endpoint health: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to get endpoint health: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleManageEndpointLabels(ctx context.Context, request *mcp.CallToolRequest, in manageEndpointLabelsInput) (*mcp.CallToolResult, any, error) { +func handleManageEndpointLabels(ctx context.Context, request *mcp.CallToolRequest, in manageEndpointLabelsInput) (*mcp.CallToolResult, mcp.TextOutput, error) { if in.Action == "" { in.Action = "add" } @@ -599,91 +599,91 @@ func handleManageEndpointLabels(ctx context.Context, request *mcp.CallToolReques nodeName := in.NodeName if endpointID == "" || labels == "" { - return mcp.NewToolResultError("endpoint_id and labels parameters are required"), nil, nil + return mcp.TextError("endpoint_id and labels parameters are required") } cmd := fmt.Sprintf("endpoint labels %s --%s %s", endpointID, action, labels) output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to manage endpoint labels: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to manage endpoint labels: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleManageEndpointConfiguration(ctx context.Context, request *mcp.CallToolRequest, in manageEndpointConfigurationInput) (*mcp.CallToolResult, any, error) { +func handleManageEndpointConfiguration(ctx context.Context, request *mcp.CallToolRequest, in manageEndpointConfigurationInput) (*mcp.CallToolResult, mcp.TextOutput, error) { endpointID := in.EndpointID config := in.Config nodeName := in.NodeName if endpointID == "" { - return mcp.NewToolResultError("endpoint_id parameter is required"), nil, nil + return mcp.TextError("endpoint_id parameter is required") } if config == "" { - return mcp.NewToolResultError("config parameter is required"), nil, nil + return mcp.TextError("config parameter is required") } command := fmt.Sprintf("endpoint config %s %s", endpointID, config) output, err := runCiliumDbgCommand(ctx, command, nodeName) if err != nil { - return mcp.NewToolResultError("Error managing endpoint configuration: " + err.Error()), nil, nil + return mcp.TextError("Error managing endpoint configuration: " + err.Error()) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleDisconnectEndpoint(ctx context.Context, request *mcp.CallToolRequest, in disconnectEndpointInput) (*mcp.CallToolResult, any, error) { +func handleDisconnectEndpoint(ctx context.Context, request *mcp.CallToolRequest, in disconnectEndpointInput) (*mcp.CallToolResult, mcp.TextOutput, error) { endpointID := in.EndpointID nodeName := in.NodeName if endpointID == "" { - return mcp.NewToolResultError("endpoint_id parameter is required"), nil, nil + return mcp.TextError("endpoint_id parameter is required") } cmd := fmt.Sprintf("endpoint disconnect %s", endpointID) output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to disconnect endpoint: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to disconnect endpoint: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleGetEndpointsList(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, any, error) { +func handleGetEndpointsList(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, mcp.TextOutput, error) { nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "endpoint list", nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to get endpoints list: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to get endpoints list: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleListIdentities(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, any, error) { +func handleListIdentities(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, mcp.TextOutput, error) { nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "identity list", nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to list identities: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to list identities: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleGetIdentityDetails(ctx context.Context, request *mcp.CallToolRequest, in getIdentityDetailsInput) (*mcp.CallToolResult, any, error) { +func handleGetIdentityDetails(ctx context.Context, request *mcp.CallToolRequest, in getIdentityDetailsInput) (*mcp.CallToolResult, mcp.TextOutput, error) { identityID := in.IdentityID nodeName := in.NodeName if identityID == "" { - return mcp.NewToolResultError("identity_id parameter is required"), nil, nil + return mcp.TextError("identity_id parameter is required") } cmd := fmt.Sprintf("identity get %s", identityID) output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to get identity details: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to get identity details: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleShowConfigurationOptions(ctx context.Context, request *mcp.CallToolRequest, in showConfigurationOptionsInput) (*mcp.CallToolResult, any, error) { +func handleShowConfigurationOptions(ctx context.Context, request *mcp.CallToolRequest, in showConfigurationOptionsInput) (*mcp.CallToolResult, mcp.TextOutput, error) { listAll := in.ListAll listReadOnly := in.ListReadOnly listOptions := in.ListOptions @@ -702,12 +702,12 @@ func handleShowConfigurationOptions(ctx context.Context, request *mcp.CallToolRe output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to show configuration options: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to show configuration options: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleToggleConfigurationOption(ctx context.Context, request *mcp.CallToolRequest, in toggleConfigurationOptionInput) (*mcp.CallToolResult, any, error) { +func handleToggleConfigurationOption(ctx context.Context, request *mcp.CallToolRequest, in toggleConfigurationOptionInput) (*mcp.CallToolResult, mcp.TextOutput, error) { option := in.Option value := true if in.Value != nil { @@ -716,7 +716,7 @@ func handleToggleConfigurationOption(ctx context.Context, request *mcp.CallToolR nodeName := in.NodeName if option == "" { - return mcp.NewToolResultError("option parameter is required"), nil, nil + return mcp.TextError("option parameter is required") } valueStr := "enable" @@ -727,58 +727,58 @@ func handleToggleConfigurationOption(ctx context.Context, request *mcp.CallToolR cmd := fmt.Sprintf("config %s=%s", option, valueStr) output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to toggle configuration option: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to toggle configuration option: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleRequestDebuggingInformation(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, any, error) { +func handleRequestDebuggingInformation(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, mcp.TextOutput, error) { nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "debuginfo", nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to request debugging information: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to request debugging information: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleDisplayEncryptionState(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, any, error) { +func handleDisplayEncryptionState(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, mcp.TextOutput, error) { nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "encrypt status", nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to display encryption state: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to display encryption state: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleFlushIPsecState(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, any, error) { +func handleFlushIPsecState(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, mcp.TextOutput, error) { nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "encrypt flush -f", nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to flush IPsec state: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to flush IPsec state: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleListEnvoyConfig(ctx context.Context, request *mcp.CallToolRequest, in listEnvoyConfigInput) (*mcp.CallToolResult, any, error) { +func handleListEnvoyConfig(ctx context.Context, request *mcp.CallToolRequest, in listEnvoyConfigInput) (*mcp.CallToolResult, mcp.TextOutput, error) { resourceName := in.ResourceName nodeName := in.NodeName if resourceName == "" { - return mcp.NewToolResultError("resource_name parameter is required"), nil, nil + return mcp.TextError("resource_name parameter is required") } cmd := fmt.Sprintf("envoy admin %s", resourceName) output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to list Envoy config: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to list Envoy config: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleFQDNCache(ctx context.Context, request *mcp.CallToolRequest, in fqdnCacheInput) (*mcp.CallToolResult, any, error) { +func handleFQDNCache(ctx context.Context, request *mcp.CallToolRequest, in fqdnCacheInput) (*mcp.CallToolResult, mcp.TextOutput, error) { if in.Command == "" { in.Command = "list" } @@ -794,32 +794,32 @@ func handleFQDNCache(ctx context.Context, request *mcp.CallToolRequest, in fqdnC output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to manage FQDN cache: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to manage FQDN cache: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleShowDNSNames(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, any, error) { +func handleShowDNSNames(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, mcp.TextOutput, error) { nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "fqdn names", nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to show DNS names: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to show DNS names: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleListIPAddresses(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, any, error) { +func handleListIPAddresses(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, mcp.TextOutput, error) { nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "ip list", nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to list IP addresses: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to list IP addresses: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleShowIPCacheInformation(ctx context.Context, request *mcp.CallToolRequest, in showIPCacheInformationInput) (*mcp.CallToolResult, any, error) { +func handleShowIPCacheInformation(ctx context.Context, request *mcp.CallToolRequest, in showIPCacheInformationInput) (*mcp.CallToolResult, mcp.TextOutput, error) { cidr := in.CIDR labels := in.Labels nodeName := in.NodeName @@ -830,128 +830,128 @@ func handleShowIPCacheInformation(ctx context.Context, request *mcp.CallToolRequ } else if cidr != "" { cmd = fmt.Sprintf("ip get %s", cidr) } else { - return mcp.NewToolResultError("either cidr or labels must be provided"), nil, nil + return mcp.TextError("either cidr or labels must be provided") } output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to show IP cache information: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to show IP cache information: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleDeleteKeyFromKVStore(ctx context.Context, request *mcp.CallToolRequest, in kvStoreKeyInput) (*mcp.CallToolResult, any, error) { +func handleDeleteKeyFromKVStore(ctx context.Context, request *mcp.CallToolRequest, in kvStoreKeyInput) (*mcp.CallToolResult, mcp.TextOutput, error) { key := in.Key nodeName := in.NodeName if key == "" { - return mcp.NewToolResultError("key parameter is required"), nil, nil + return mcp.TextError("key parameter is required") } cmd := fmt.Sprintf("kvstore delete %s", key) output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to delete key from kvstore: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to delete key from kvstore: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleGetKVStoreKey(ctx context.Context, request *mcp.CallToolRequest, in kvStoreKeyInput) (*mcp.CallToolResult, any, error) { +func handleGetKVStoreKey(ctx context.Context, request *mcp.CallToolRequest, in kvStoreKeyInput) (*mcp.CallToolResult, mcp.TextOutput, error) { key := in.Key nodeName := in.NodeName if key == "" { - return mcp.NewToolResultError("key parameter is required"), nil, nil + return mcp.TextError("key parameter is required") } cmd := fmt.Sprintf("kvstore get %s", key) output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to get key from kvstore: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to get key from kvstore: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleSetKVStoreKey(ctx context.Context, request *mcp.CallToolRequest, in setKVStoreKeyInput) (*mcp.CallToolResult, any, error) { +func handleSetKVStoreKey(ctx context.Context, request *mcp.CallToolRequest, in setKVStoreKeyInput) (*mcp.CallToolResult, mcp.TextOutput, error) { key := in.Key value := in.Value nodeName := in.NodeName if key == "" || value == "" { - return mcp.NewToolResultError("key and value parameters are required"), nil, nil + return mcp.TextError("key and value parameters are required") } cmd := fmt.Sprintf("kvstore set %s=%s", key, value) output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to set key in kvstore: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to set key in kvstore: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleShowLoadInformation(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, any, error) { +func handleShowLoadInformation(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, mcp.TextOutput, error) { nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "loadinfo", nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to show load information: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to show load information: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleListLocalRedirectPolicies(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, any, error) { +func handleListLocalRedirectPolicies(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, mcp.TextOutput, error) { nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "lrp list", nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to list local redirect policies: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to list local redirect policies: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleListBPFMapEvents(ctx context.Context, request *mcp.CallToolRequest, in bpfMapInput) (*mcp.CallToolResult, any, error) { +func handleListBPFMapEvents(ctx context.Context, request *mcp.CallToolRequest, in bpfMapInput) (*mcp.CallToolResult, mcp.TextOutput, error) { mapName := in.MapName nodeName := in.NodeName if mapName == "" { - return mcp.NewToolResultError("map_name parameter is required"), nil, nil + return mcp.TextError("map_name parameter is required") } cmd := fmt.Sprintf("map events %s", mapName) output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to list BPF map events: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to list BPF map events: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleGetBPFMap(ctx context.Context, request *mcp.CallToolRequest, in bpfMapInput) (*mcp.CallToolResult, any, error) { +func handleGetBPFMap(ctx context.Context, request *mcp.CallToolRequest, in bpfMapInput) (*mcp.CallToolResult, mcp.TextOutput, error) { mapName := in.MapName nodeName := in.NodeName if mapName == "" { - return mcp.NewToolResultError("map_name parameter is required"), nil, nil + return mcp.TextError("map_name parameter is required") } cmd := fmt.Sprintf("map get %s", mapName) output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to get BPF map: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to get BPF map: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleListBPFMaps(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, any, error) { +func handleListBPFMaps(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, mcp.TextOutput, error) { nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "map list", nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to list BPF maps: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to list BPF maps: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleListMetrics(ctx context.Context, request *mcp.CallToolRequest, in listMetricsInput) (*mcp.CallToolResult, any, error) { +func handleListMetrics(ctx context.Context, request *mcp.CallToolRequest, in listMetricsInput) (*mcp.CallToolResult, mcp.TextOutput, error) { matchPattern := in.MatchPattern nodeName := in.NodeName @@ -964,32 +964,32 @@ func handleListMetrics(ctx context.Context, request *mcp.CallToolRequest, in lis output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to list metrics: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to list metrics: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleListClusterNodes(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, any, error) { +func handleListClusterNodes(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, mcp.TextOutput, error) { nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "node list", nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to list cluster nodes: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to list cluster nodes: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleListNodeIds(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, any, error) { +func handleListNodeIds(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, mcp.TextOutput, error) { nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "nodeid list", nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to list node IDs: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to list node IDs: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleDisplayPolicyNodeInformation(ctx context.Context, request *mcp.CallToolRequest, in displayPolicyNodeInformationInput) (*mcp.CallToolResult, any, error) { +func handleDisplayPolicyNodeInformation(ctx context.Context, request *mcp.CallToolRequest, in displayPolicyNodeInformationInput) (*mcp.CallToolResult, mcp.TextOutput, error) { labels := in.Labels nodeName := in.NodeName @@ -1002,12 +1002,12 @@ func handleDisplayPolicyNodeInformation(ctx context.Context, request *mcp.CallTo output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to display policy node information: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to display policy node information: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleDeletePolicyRules(ctx context.Context, request *mcp.CallToolRequest, in deletePolicyRulesInput) (*mcp.CallToolResult, any, error) { +func handleDeletePolicyRules(ctx context.Context, request *mcp.CallToolRequest, in deletePolicyRulesInput) (*mcp.CallToolResult, mcp.TextOutput, error) { labels := in.Labels all := in.All nodeName := in.NodeName @@ -1018,43 +1018,43 @@ func handleDeletePolicyRules(ctx context.Context, request *mcp.CallToolRequest, } else if labels != "" { cmd = fmt.Sprintf("policy delete %s", labels) } else { - return mcp.NewToolResultError("either labels or all=true must be provided"), nil, nil + return mcp.TextError("either labels or all=true must be provided") } output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to delete policy rules: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to delete policy rules: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleDisplaySelectors(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, any, error) { +func handleDisplaySelectors(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, mcp.TextOutput, error) { nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "policy selectors", nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to display selectors: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to display selectors: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleListXDPCIDRFilters(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, any, error) { +func handleListXDPCIDRFilters(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, mcp.TextOutput, error) { nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "prefilter list", nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to list XDP CIDR filters: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to list XDP CIDR filters: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleUpdateXDPCIDRFilters(ctx context.Context, request *mcp.CallToolRequest, in xdpCIDRFiltersInput) (*mcp.CallToolResult, any, error) { +func handleUpdateXDPCIDRFilters(ctx context.Context, request *mcp.CallToolRequest, in xdpCIDRFiltersInput) (*mcp.CallToolResult, mcp.TextOutput, error) { cidrPrefixes := in.CIDRPrefixes revision := in.Revision nodeName := in.NodeName if cidrPrefixes == "" { - return mcp.NewToolResultError("cidr_prefixes parameter is required"), nil, nil + return mcp.TextError("cidr_prefixes parameter is required") } var cmd string @@ -1066,18 +1066,18 @@ func handleUpdateXDPCIDRFilters(ctx context.Context, request *mcp.CallToolReques output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to update XDP CIDR filters: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to update XDP CIDR filters: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleDeleteXDPCIDRFilters(ctx context.Context, request *mcp.CallToolRequest, in xdpCIDRFiltersInput) (*mcp.CallToolResult, any, error) { +func handleDeleteXDPCIDRFilters(ctx context.Context, request *mcp.CallToolRequest, in xdpCIDRFiltersInput) (*mcp.CallToolResult, mcp.TextOutput, error) { cidrPrefixes := in.CIDRPrefixes revision := in.Revision nodeName := in.NodeName if cidrPrefixes == "" { - return mcp.NewToolResultError("cidr_prefixes parameter is required"), nil, nil + return mcp.TextError("cidr_prefixes parameter is required") } var cmd string @@ -1089,12 +1089,12 @@ func handleDeleteXDPCIDRFilters(ctx context.Context, request *mcp.CallToolReques output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to delete XDP CIDR filters: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to delete XDP CIDR filters: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleValidateCiliumNetworkPolicies(ctx context.Context, request *mcp.CallToolRequest, in validateCiliumNetworkPoliciesInput) (*mcp.CallToolResult, any, error) { +func handleValidateCiliumNetworkPolicies(ctx context.Context, request *mcp.CallToolRequest, in validateCiliumNetworkPoliciesInput) (*mcp.CallToolResult, mcp.TextOutput, error) { enableK8s := in.EnableK8s enableK8sAPIDiscovery := in.EnableK8sAPIDiscovery nodeName := in.NodeName @@ -1109,54 +1109,54 @@ func handleValidateCiliumNetworkPolicies(ctx context.Context, request *mcp.CallT output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to validate Cilium network policies: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to validate Cilium network policies: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleListPCAPRecorders(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, any, error) { +func handleListPCAPRecorders(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, mcp.TextOutput, error) { nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "recorder list", nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to list PCAP recorders: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to list PCAP recorders: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleGetPCAPRecorder(ctx context.Context, request *mcp.CallToolRequest, in pcapRecorderIDInput) (*mcp.CallToolResult, any, error) { +func handleGetPCAPRecorder(ctx context.Context, request *mcp.CallToolRequest, in pcapRecorderIDInput) (*mcp.CallToolResult, mcp.TextOutput, error) { recorderID := in.RecorderID nodeName := in.NodeName if recorderID == "" { - return mcp.NewToolResultError("recorder_id parameter is required"), nil, nil + return mcp.TextError("recorder_id parameter is required") } cmd := fmt.Sprintf("recorder get %s", recorderID) output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to get PCAP recorder: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to get PCAP recorder: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleDeletePCAPRecorder(ctx context.Context, request *mcp.CallToolRequest, in pcapRecorderIDInput) (*mcp.CallToolResult, any, error) { +func handleDeletePCAPRecorder(ctx context.Context, request *mcp.CallToolRequest, in pcapRecorderIDInput) (*mcp.CallToolResult, mcp.TextOutput, error) { recorderID := in.RecorderID nodeName := in.NodeName if recorderID == "" { - return mcp.NewToolResultError("recorder_id parameter is required"), nil, nil + return mcp.TextError("recorder_id parameter is required") } cmd := fmt.Sprintf("recorder delete %s", recorderID) output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to delete PCAP recorder: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to delete PCAP recorder: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleUpdatePCAPRecorder(ctx context.Context, request *mcp.CallToolRequest, in updatePCAPRecorderInput) (*mcp.CallToolResult, any, error) { +func handleUpdatePCAPRecorder(ctx context.Context, request *mcp.CallToolRequest, in updatePCAPRecorderInput) (*mcp.CallToolResult, mcp.TextOutput, error) { if in.Caplen == "" { in.Caplen = "0" } @@ -1170,18 +1170,18 @@ func handleUpdatePCAPRecorder(ctx context.Context, request *mcp.CallToolRequest, nodeName := in.NodeName if recorderID == "" || filters == "" { - return mcp.NewToolResultError("recorder_id and filters parameters are required"), nil, nil + return mcp.TextError("recorder_id and filters parameters are required") } cmd := fmt.Sprintf("recorder update %s --filters %s --caplen %s --id %s", recorderID, filters, caplen, id) output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to update PCAP recorder: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to update PCAP recorder: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleListServices(ctx context.Context, request *mcp.CallToolRequest, in listServicesInput) (*mcp.CallToolResult, any, error) { +func handleListServices(ctx context.Context, request *mcp.CallToolRequest, in listServicesInput) (*mcp.CallToolResult, mcp.TextOutput, error) { showClusterMeshAffinity := in.ShowClusterMeshAffinity nodeName := in.NodeName @@ -1194,28 +1194,28 @@ func handleListServices(ctx context.Context, request *mcp.CallToolRequest, in li output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to list services: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to list services: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleGetServiceInformation(ctx context.Context, request *mcp.CallToolRequest, in getServiceInformationInput) (*mcp.CallToolResult, any, error) { +func handleGetServiceInformation(ctx context.Context, request *mcp.CallToolRequest, in getServiceInformationInput) (*mcp.CallToolResult, mcp.TextOutput, error) { serviceID := in.ServiceID nodeName := in.NodeName if serviceID == "" { - return mcp.NewToolResultError("service_id parameter is required"), nil, nil + return mcp.TextError("service_id parameter is required") } cmd := fmt.Sprintf("service get %s", serviceID) output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to get service information: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to get service information: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleDeleteService(ctx context.Context, request *mcp.CallToolRequest, in deleteServiceInput) (*mcp.CallToolResult, any, error) { +func handleDeleteService(ctx context.Context, request *mcp.CallToolRequest, in deleteServiceInput) (*mcp.CallToolResult, mcp.TextOutput, error) { serviceID := in.ServiceID all := in.All nodeName := in.NodeName @@ -1226,17 +1226,17 @@ func handleDeleteService(ctx context.Context, request *mcp.CallToolRequest, in d } else if serviceID != "" { cmd = fmt.Sprintf("service delete %s", serviceID) } else { - return mcp.NewToolResultError("either service_id or all=true must be provided"), nil, nil + return mcp.TextError("either service_id or all=true must be provided") } output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to delete service: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to delete service: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleUpdateService(ctx context.Context, request *mcp.CallToolRequest, in updateServiceInput) (*mcp.CallToolResult, any, error) { +func handleUpdateService(ctx context.Context, request *mcp.CallToolRequest, in updateServiceInput) (*mcp.CallToolResult, mcp.TextOutput, error) { if in.K8sExtTrafficPolicy == "" { in.K8sExtTrafficPolicy = "Cluster" } @@ -1266,7 +1266,7 @@ func handleUpdateService(ctx context.Context, request *mcp.CallToolRequest, in u nodeName := in.NodeName if backends == "" || frontend == "" || id == "" { - return mcp.NewToolResultError("backends, frontend, and id parameters are required"), nil, nil + return mcp.TextError("backends, frontend, and id parameters are required") } cmd := fmt.Sprintf("service update %s --backends %s --frontend %s --protocol %s --states %s", @@ -1302,12 +1302,12 @@ func handleUpdateService(ctx context.Context, request *mcp.CallToolRequest, in u output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to update service: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to update service: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } -func handleGetDaemonStatus(ctx context.Context, request *mcp.CallToolRequest, in getDaemonStatusInput) (*mcp.CallToolResult, any, error) { +func handleGetDaemonStatus(ctx context.Context, request *mcp.CallToolRequest, in getDaemonStatusInput) (*mcp.CallToolResult, mcp.TextOutput, error) { showAllAddresses := in.ShowAllAddresses showAllClusters := in.ShowAllClusters showAllControllers := in.ShowAllControllers @@ -1342,7 +1342,7 @@ func handleGetDaemonStatus(ctx context.Context, request *mcp.CallToolRequest, in output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to get daemon status: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to get daemon status: %v", err)) } - return mcp.NewToolResultText(output), nil, nil + return mcp.TextResult(output) } diff --git a/pkg/helm/helm.go b/pkg/helm/helm.go index c9156727..07012844 100644 --- a/pkg/helm/helm.go +++ b/pkg/helm/helm.go @@ -32,7 +32,7 @@ type helmListReleasesInput struct { } // Helm list releases -func handleHelmListReleases(ctx context.Context, request *mcp.CallToolRequest, in helmListReleasesInput) (*mcp.CallToolResult, any, error) { +func handleHelmListReleases(ctx context.Context, request *mcp.CallToolRequest, in helmListReleasesInput) (*mcp.CallToolResult, mcp.TextOutput, error) { args := []string{"list"} if in.Namespace != "" { @@ -83,13 +83,13 @@ func handleHelmListReleases(ctx context.Context, request *mcp.CallToolRequest, i if in.Namespace != "" { toolErr = toolErr.WithContext("namespace", in.Namespace) } - return toolErrorResult(toolErr), nil, nil + return toolErrorResult(toolErr), mcp.TextOutput{}, nil } // Fallback for non-structured errors - return mcp.NewToolResultError(fmt.Sprintf("Helm list command failed: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Helm list command failed: %v", err)) } - return mcp.NewToolResultText(result), nil, nil + return mcp.TextResult(result) } func runHelmCommand(ctx context.Context, args []string) (string, error) { @@ -128,27 +128,27 @@ type helmGetReleaseInput struct { } // Helm get release -func handleHelmGetRelease(ctx context.Context, request *mcp.CallToolRequest, in helmGetReleaseInput) (*mcp.CallToolResult, any, error) { +func handleHelmGetRelease(ctx context.Context, request *mcp.CallToolRequest, in helmGetReleaseInput) (*mcp.CallToolResult, mcp.TextOutput, error) { if in.Resource == "" { in.Resource = "all" } if in.Name == "" { - return mcp.NewToolResultError("name parameter is required"), nil, nil + return mcp.TextError("name parameter is required") } if in.Namespace == "" { - return mcp.NewToolResultError("namespace parameter is required"), nil, nil + return mcp.TextError("namespace parameter is required") } args := []string{"get", in.Resource, in.Name, "-n", in.Namespace} result, err := runHelmCommand(ctx, args) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Helm get command failed: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Helm get command failed: %v", err)) } - return mcp.NewToolResultText(result), nil, nil + return mcp.TextResult(result) } type helmUpgradeReleaseInput struct { @@ -164,27 +164,27 @@ type helmUpgradeReleaseInput struct { } // Helm upgrade release -func handleHelmUpgradeRelease(ctx context.Context, request *mcp.CallToolRequest, in helmUpgradeReleaseInput) (*mcp.CallToolResult, any, error) { +func handleHelmUpgradeRelease(ctx context.Context, request *mcp.CallToolRequest, in helmUpgradeReleaseInput) (*mcp.CallToolResult, mcp.TextOutput, error) { if in.Name == "" || in.Chart == "" { - return mcp.NewToolResultError("name and chart parameters are required"), nil, nil + return mcp.TextError("name and chart parameters are required") } // Validate release name if err := security.ValidateHelmReleaseName(in.Name); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid release name: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Invalid release name: %v", err)) } // Validate namespace if provided if in.Namespace != "" { if err := security.ValidateNamespace(in.Namespace); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid namespace: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Invalid namespace: %v", err)) } } // Validate values file path if provided if in.Values != "" { if err := security.ValidateFilePath(in.Values); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid values file path: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Invalid values file path: %v", err)) } } @@ -224,10 +224,10 @@ func handleHelmUpgradeRelease(ctx context.Context, request *mcp.CallToolRequest, result, err := runHelmCommand(ctx, args) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Helm upgrade command failed: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Helm upgrade command failed: %v", err)) } - return mcp.NewToolResultText(result), nil, nil + return mcp.TextResult(result) } type helmUninstallInput struct { @@ -238,9 +238,9 @@ type helmUninstallInput struct { } // Helm uninstall release -func handleHelmUninstall(ctx context.Context, request *mcp.CallToolRequest, in helmUninstallInput) (*mcp.CallToolResult, any, error) { +func handleHelmUninstall(ctx context.Context, request *mcp.CallToolRequest, in helmUninstallInput) (*mcp.CallToolResult, mcp.TextOutput, error) { if in.Name == "" || in.Namespace == "" { - return mcp.NewToolResultError("name and namespace parameters are required"), nil, nil + return mcp.TextError("name and namespace parameters are required") } args := []string{"uninstall", in.Name, "-n", in.Namespace} @@ -255,10 +255,10 @@ func handleHelmUninstall(ctx context.Context, request *mcp.CallToolRequest, in h result, err := runHelmCommand(ctx, args) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Helm uninstall command failed: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Helm uninstall command failed: %v", err)) } - return mcp.NewToolResultText(result), nil, nil + return mcp.TextResult(result) } type helmRepoAddInput struct { @@ -267,43 +267,43 @@ type helmRepoAddInput struct { } // Helm repo add -func handleHelmRepoAdd(ctx context.Context, request *mcp.CallToolRequest, in helmRepoAddInput) (*mcp.CallToolResult, any, error) { +func handleHelmRepoAdd(ctx context.Context, request *mcp.CallToolRequest, in helmRepoAddInput) (*mcp.CallToolResult, mcp.TextOutput, error) { if in.Name == "" || in.URL == "" { - return mcp.NewToolResultError("name and url parameters are required"), nil, nil + return mcp.TextError("name and url parameters are required") } // Validate repository name if err := security.ValidateHelmReleaseName(in.Name); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid repository name: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Invalid repository name: %v", err)) } // Validate repository URL if err := security.ValidateURL(in.URL); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid repository URL: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Invalid repository URL: %v", err)) } args := []string{"repo", "add", in.Name, in.URL} result, err := runHelmCommand(ctx, args) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Helm repo add command failed: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Helm repo add command failed: %v", err)) } - return mcp.NewToolResultText(result), nil, nil + return mcp.TextResult(result) } type helmRepoUpdateInput struct{} // Helm repo update -func handleHelmRepoUpdate(ctx context.Context, request *mcp.CallToolRequest, in helmRepoUpdateInput) (*mcp.CallToolResult, any, error) { +func handleHelmRepoUpdate(ctx context.Context, request *mcp.CallToolRequest, in helmRepoUpdateInput) (*mcp.CallToolResult, mcp.TextOutput, error) { args := []string{"repo", "update"} result, err := runHelmCommand(ctx, args) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Helm repo update command failed: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Helm repo update command failed: %v", err)) } - return mcp.NewToolResultText(result), nil, nil + return mcp.TextResult(result) } // Register Helm tools diff --git a/pkg/istio/istio.go b/pkg/istio/istio.go index 6a0da8e0..7bdea518 100644 --- a/pkg/istio/istio.go +++ b/pkg/istio/istio.go @@ -16,7 +16,7 @@ type istioProxyStatusInput struct { } // Istio proxy status -func handleIstioProxyStatus(ctx context.Context, request *mcp.CallToolRequest, in istioProxyStatusInput) (*mcp.CallToolResult, any, error) { +func handleIstioProxyStatus(ctx context.Context, request *mcp.CallToolRequest, in istioProxyStatusInput) (*mcp.CallToolResult, mcp.TextOutput, error) { args := []string{"proxy-status"} if in.Namespace != "" { @@ -29,10 +29,10 @@ func handleIstioProxyStatus(ctx context.Context, request *mcp.CallToolRequest, i result, err := runIstioCtl(ctx, args) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("istioctl proxy-status failed: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("istioctl proxy-status failed: %v", err)) } - return mcp.NewToolResultText(result), nil, nil + return mcp.TextResult(result) } func runIstioCtl(ctx context.Context, args []string) (string, error) { @@ -50,13 +50,13 @@ type istioProxyConfigInput struct { } // Istio proxy config -func handleIstioProxyConfig(ctx context.Context, request *mcp.CallToolRequest, in istioProxyConfigInput) (*mcp.CallToolResult, any, error) { +func handleIstioProxyConfig(ctx context.Context, request *mcp.CallToolRequest, in istioProxyConfigInput) (*mcp.CallToolResult, mcp.TextOutput, error) { if in.ConfigType == "" { in.ConfigType = "all" } if in.PodName == "" { - return mcp.NewToolResultError("pod_name parameter is required"), nil, nil + return mcp.TextError("pod_name parameter is required") } args := []string{"proxy-config", in.ConfigType} @@ -69,10 +69,10 @@ func handleIstioProxyConfig(ctx context.Context, request *mcp.CallToolRequest, i result, err := runIstioCtl(ctx, args) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("istioctl proxy-config failed: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("istioctl proxy-config failed: %v", err)) } - return mcp.NewToolResultText(result), nil, nil + return mcp.TextResult(result) } type istioInstallInput struct { @@ -80,7 +80,7 @@ type istioInstallInput struct { } // Istio install -func handleIstioInstall(ctx context.Context, request *mcp.CallToolRequest, in istioInstallInput) (*mcp.CallToolResult, any, error) { +func handleIstioInstall(ctx context.Context, request *mcp.CallToolRequest, in istioInstallInput) (*mcp.CallToolResult, mcp.TextOutput, error) { if in.Profile == "" { in.Profile = "default" } @@ -89,10 +89,10 @@ func handleIstioInstall(ctx context.Context, request *mcp.CallToolRequest, in is result, err := runIstioCtl(ctx, args) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("istioctl install failed: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("istioctl install failed: %v", err)) } - return mcp.NewToolResultText(result), nil, nil + return mcp.TextResult(result) } type istioGenerateManifestInput struct { @@ -100,7 +100,7 @@ type istioGenerateManifestInput struct { } // Istio generate manifest -func handleIstioGenerateManifest(ctx context.Context, request *mcp.CallToolRequest, in istioGenerateManifestInput) (*mcp.CallToolResult, any, error) { +func handleIstioGenerateManifest(ctx context.Context, request *mcp.CallToolRequest, in istioGenerateManifestInput) (*mcp.CallToolResult, mcp.TextOutput, error) { if in.Profile == "" { in.Profile = "default" } @@ -109,10 +109,10 @@ func handleIstioGenerateManifest(ctx context.Context, request *mcp.CallToolReque result, err := runIstioCtl(ctx, args) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("istioctl manifest generate failed: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("istioctl manifest generate failed: %v", err)) } - return mcp.NewToolResultText(result), nil, nil + return mcp.TextResult(result) } type istioAnalyzeClusterConfigurationInput struct { @@ -121,7 +121,7 @@ type istioAnalyzeClusterConfigurationInput struct { } // Istio analyze -func handleIstioAnalyzeClusterConfiguration(ctx context.Context, request *mcp.CallToolRequest, in istioAnalyzeClusterConfigurationInput) (*mcp.CallToolResult, any, error) { +func handleIstioAnalyzeClusterConfiguration(ctx context.Context, request *mcp.CallToolRequest, in istioAnalyzeClusterConfigurationInput) (*mcp.CallToolResult, mcp.TextOutput, error) { args := []string{"analyze"} if in.AllNamespaces { @@ -132,10 +132,10 @@ func handleIstioAnalyzeClusterConfiguration(ctx context.Context, request *mcp.Ca result, err := runIstioCtl(ctx, args) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("istioctl analyze failed: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("istioctl analyze failed: %v", err)) } - return mcp.NewToolResultText(result), nil, nil + return mcp.TextResult(result) } type istioVersionInput struct { @@ -143,7 +143,7 @@ type istioVersionInput struct { } // Istio version -func handleIstioVersion(ctx context.Context, request *mcp.CallToolRequest, in istioVersionInput) (*mcp.CallToolResult, any, error) { +func handleIstioVersion(ctx context.Context, request *mcp.CallToolRequest, in istioVersionInput) (*mcp.CallToolResult, mcp.TextOutput, error) { args := []string{"version"} if in.Short { @@ -152,24 +152,24 @@ func handleIstioVersion(ctx context.Context, request *mcp.CallToolRequest, in is result, err := runIstioCtl(ctx, args) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("istioctl version failed: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("istioctl version failed: %v", err)) } - return mcp.NewToolResultText(result), nil, nil + return mcp.TextResult(result) } type istioRemoteClustersInput struct{} // Istio remote clusters -func handleIstioRemoteClusters(ctx context.Context, request *mcp.CallToolRequest, in istioRemoteClustersInput) (*mcp.CallToolResult, any, error) { +func handleIstioRemoteClusters(ctx context.Context, request *mcp.CallToolRequest, in istioRemoteClustersInput) (*mcp.CallToolResult, mcp.TextOutput, error) { args := []string{"remote-clusters"} result, err := runIstioCtl(ctx, args) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("istioctl remote-clusters failed: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("istioctl remote-clusters failed: %v", err)) } - return mcp.NewToolResultText(result), nil, nil + return mcp.TextResult(result) } type waypointListInput struct { @@ -178,7 +178,7 @@ type waypointListInput struct { } // Waypoint list -func handleWaypointList(ctx context.Context, request *mcp.CallToolRequest, in waypointListInput) (*mcp.CallToolResult, any, error) { +func handleWaypointList(ctx context.Context, request *mcp.CallToolRequest, in waypointListInput) (*mcp.CallToolResult, mcp.TextOutput, error) { args := []string{"waypoint", "list"} if in.AllNamespaces { @@ -189,10 +189,10 @@ func handleWaypointList(ctx context.Context, request *mcp.CallToolRequest, in wa result, err := runIstioCtl(ctx, args) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("istioctl waypoint list failed: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("istioctl waypoint list failed: %v", err)) } - return mcp.NewToolResultText(result), nil, nil + return mcp.TextResult(result) } type waypointGenerateInput struct { @@ -202,7 +202,7 @@ type waypointGenerateInput struct { } // Waypoint generate -func handleWaypointGenerate(ctx context.Context, request *mcp.CallToolRequest, in waypointGenerateInput) (*mcp.CallToolResult, any, error) { +func handleWaypointGenerate(ctx context.Context, request *mcp.CallToolRequest, in waypointGenerateInput) (*mcp.CallToolResult, mcp.TextOutput, error) { if in.Name == "" { in.Name = "waypoint" } @@ -211,7 +211,7 @@ func handleWaypointGenerate(ctx context.Context, request *mcp.CallToolRequest, i } if in.Namespace == "" { - return mcp.NewToolResultError("namespace parameter is required"), nil, nil + return mcp.TextError("namespace parameter is required") } args := []string{"waypoint", "generate"} @@ -228,10 +228,10 @@ func handleWaypointGenerate(ctx context.Context, request *mcp.CallToolRequest, i result, err := runIstioCtl(ctx, args) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("istioctl waypoint generate failed: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("istioctl waypoint generate failed: %v", err)) } - return mcp.NewToolResultText(result), nil, nil + return mcp.TextResult(result) } type waypointApplyInput struct { @@ -240,9 +240,9 @@ type waypointApplyInput struct { } // Waypoint apply -func handleWaypointApply(ctx context.Context, request *mcp.CallToolRequest, in waypointApplyInput) (*mcp.CallToolResult, any, error) { +func handleWaypointApply(ctx context.Context, request *mcp.CallToolRequest, in waypointApplyInput) (*mcp.CallToolResult, mcp.TextOutput, error) { if in.Namespace == "" { - return mcp.NewToolResultError("namespace parameter is required"), nil, nil + return mcp.TextError("namespace parameter is required") } args := []string{"waypoint", "apply", "-n", in.Namespace} @@ -253,10 +253,10 @@ func handleWaypointApply(ctx context.Context, request *mcp.CallToolRequest, in w result, err := runIstioCtl(ctx, args) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("istioctl waypoint apply failed: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("istioctl waypoint apply failed: %v", err)) } - return mcp.NewToolResultText(result), nil, nil + return mcp.TextResult(result) } type waypointDeleteInput struct { @@ -266,9 +266,9 @@ type waypointDeleteInput struct { } // Waypoint delete -func handleWaypointDelete(ctx context.Context, request *mcp.CallToolRequest, in waypointDeleteInput) (*mcp.CallToolResult, any, error) { +func handleWaypointDelete(ctx context.Context, request *mcp.CallToolRequest, in waypointDeleteInput) (*mcp.CallToolResult, mcp.TextOutput, error) { if in.Namespace == "" { - return mcp.NewToolResultError("namespace parameter is required"), nil, nil + return mcp.TextError("namespace parameter is required") } args := []string{"waypoint", "delete"} @@ -286,10 +286,10 @@ func handleWaypointDelete(ctx context.Context, request *mcp.CallToolRequest, in result, err := runIstioCtl(ctx, args) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("istioctl waypoint delete failed: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("istioctl waypoint delete failed: %v", err)) } - return mcp.NewToolResultText(result), nil, nil + return mcp.TextResult(result) } type waypointStatusInput struct { @@ -298,9 +298,9 @@ type waypointStatusInput struct { } // Waypoint status -func handleWaypointStatus(ctx context.Context, request *mcp.CallToolRequest, in waypointStatusInput) (*mcp.CallToolResult, any, error) { +func handleWaypointStatus(ctx context.Context, request *mcp.CallToolRequest, in waypointStatusInput) (*mcp.CallToolResult, mcp.TextOutput, error) { if in.Namespace == "" { - return mcp.NewToolResultError("namespace parameter is required"), nil, nil + return mcp.TextError("namespace parameter is required") } args := []string{"waypoint", "status"} @@ -313,10 +313,10 @@ func handleWaypointStatus(ctx context.Context, request *mcp.CallToolRequest, in result, err := runIstioCtl(ctx, args) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("istioctl waypoint status failed: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("istioctl waypoint status failed: %v", err)) } - return mcp.NewToolResultText(result), nil, nil + return mcp.TextResult(result) } type ztunnelConfigInput struct { @@ -325,7 +325,7 @@ type ztunnelConfigInput struct { } // Ztunnel config -func handleZtunnelConfig(ctx context.Context, request *mcp.CallToolRequest, in ztunnelConfigInput) (*mcp.CallToolResult, any, error) { +func handleZtunnelConfig(ctx context.Context, request *mcp.CallToolRequest, in ztunnelConfigInput) (*mcp.CallToolResult, mcp.TextOutput, error) { if in.ConfigType == "" { in.ConfigType = "all" } @@ -338,10 +338,10 @@ func handleZtunnelConfig(ctx context.Context, request *mcp.CallToolRequest, in z result, err := runIstioCtl(ctx, args) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("istioctl ztunnel config failed: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("istioctl ztunnel config failed: %v", err)) } - return mcp.NewToolResultText(result), nil, nil + return mcp.TextResult(result) } // Register Istio tools diff --git a/pkg/k8s/k8s.go b/pkg/k8s/k8s.go index abb40ac0..473085fa 100644 --- a/pkg/k8s/k8s.go +++ b/pkg/k8s/k8s.go @@ -62,9 +62,9 @@ type getResourcesInput struct { } // Enhanced kubectl get -func (k *K8sTool) handleKubectlGetEnhanced(ctx context.Context, request *mcp.CallToolRequest, in getResourcesInput) (*mcp.CallToolResult, any, error) { +func (k *K8sTool) handleKubectlGetEnhanced(ctx context.Context, request *mcp.CallToolRequest, in getResourcesInput) (*mcp.CallToolResult, mcp.TextOutput, error) { if in.ResourceType == "" { - return mcp.NewToolResultError("resource_type parameter is required"), nil, nil + return mcp.TextError("resource_type parameter is required") } if in.Output == "" { in.Output = "wide" @@ -85,7 +85,7 @@ func (k *K8sTool) handleKubectlGetEnhanced(ctx context.Context, request *mcp.Cal args = append(args, "-o", in.Output) res, err := k.runKubectlCommand(ctx, mcp.Header(request), args...) - return res, nil, err + return res, mcp.TextOf(res), err } // logsInput is the typed input for k8s_get_pod_logs. @@ -97,9 +97,9 @@ type logsInput struct { } // Get pod logs -func (k *K8sTool) handleKubectlLogsEnhanced(ctx context.Context, request *mcp.CallToolRequest, in logsInput) (*mcp.CallToolResult, any, error) { +func (k *K8sTool) handleKubectlLogsEnhanced(ctx context.Context, request *mcp.CallToolRequest, in logsInput) (*mcp.CallToolResult, mcp.TextOutput, error) { if in.PodName == "" { - return mcp.NewToolResultError("pod_name parameter is required"), nil, nil + return mcp.TextError("pod_name parameter is required") } if in.Namespace == "" { in.Namespace = "default" @@ -119,7 +119,7 @@ func (k *K8sTool) handleKubectlLogsEnhanced(ctx context.Context, request *mcp.Ca } res, err := k.runKubectlCommand(ctx, mcp.Header(request), args...) - return res, nil, err + return res, mcp.TextOf(res), err } // scaleInput is the typed input for k8s_scale. @@ -130,9 +130,9 @@ type scaleInput struct { } // Scale deployment -func (k *K8sTool) handleScaleDeployment(ctx context.Context, request *mcp.CallToolRequest, in scaleInput) (*mcp.CallToolResult, any, error) { +func (k *K8sTool) handleScaleDeployment(ctx context.Context, request *mcp.CallToolRequest, in scaleInput) (*mcp.CallToolResult, mcp.TextOutput, error) { if in.Name == "" { - return mcp.NewToolResultError("name parameter is required"), nil, nil + return mcp.TextError("name parameter is required") } if in.Namespace == "" { in.Namespace = "default" @@ -144,7 +144,7 @@ func (k *K8sTool) handleScaleDeployment(ctx context.Context, request *mcp.CallTo args := []string{"scale", "deployment", in.Name, "--replicas", fmt.Sprintf("%d", in.Replicas), "-n", in.Namespace} res, err := k.runKubectlCommandWithCacheInvalidation(ctx, mcp.Header(request), args...) - return res, nil, err + return res, mcp.TextOf(res), err } // patchResourceInput is the typed input for k8s_patch_resource. @@ -157,7 +157,7 @@ type patchResourceInput struct { } // Patch resource -func (k *K8sTool) handlePatchResource(ctx context.Context, request *mcp.CallToolRequest, in patchResourceInput) (*mcp.CallToolResult, any, error) { +func (k *K8sTool) handlePatchResource(ctx context.Context, request *mcp.CallToolRequest, in patchResourceInput) (*mcp.CallToolResult, mcp.TextOutput, error) { if in.Namespace == "" { in.Namespace = "default" } @@ -166,7 +166,7 @@ func (k *K8sTool) handlePatchResource(ctx context.Context, request *mcp.CallTool } if in.ResourceType == "" || in.ResourceName == "" || in.Patch == "" { - return mcp.NewToolResultError("resource_type, resource_name, and patch parameters are required"), nil, nil + return mcp.TextError("resource_type, resource_name, and patch parameters are required") } // Validate patch type. "strategic" is only implemented for built-in Kubernetes @@ -174,25 +174,25 @@ func (k *K8sTool) handlePatchResource(ctx context.Context, request *mcp.CallTool switch in.PatchType { case "strategic", "merge", "json": default: - return mcp.NewToolResultError(fmt.Sprintf("Invalid patch_type %q: must be one of strategic, merge, json", in.PatchType)), nil, nil + return mcp.TextError(fmt.Sprintf("Invalid patch_type %q: must be one of strategic, merge, json", in.PatchType)) } if err := security.ValidateK8sResourceName(in.ResourceName); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid resource name: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Invalid resource name: %v", err)) } if err := security.ValidateNamespace(in.Namespace); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid namespace: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Invalid namespace: %v", err)) } if err := security.ValidateYAMLContent(in.Patch); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid patch content: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Invalid patch content: %v", err)) } args := []string{"patch", in.ResourceType, in.ResourceName, "--type=" + in.PatchType, "-p", in.Patch, "-n", in.Namespace} res, err := k.runKubectlCommandWithCacheInvalidation(ctx, mcp.Header(request), args...) - return res, nil, err + return res, mcp.TextOf(res), err } // patchStatusInput is the typed input for k8s_patch_status. @@ -204,25 +204,25 @@ type patchStatusInput struct { } // Patch resource status -func (k *K8sTool) handlePatchStatus(ctx context.Context, request *mcp.CallToolRequest, in patchStatusInput) (*mcp.CallToolResult, any, error) { +func (k *K8sTool) handlePatchStatus(ctx context.Context, request *mcp.CallToolRequest, in patchStatusInput) (*mcp.CallToolResult, mcp.TextOutput, error) { if in.Namespace == "" { in.Namespace = "default" } if in.ResourceType == "" || in.ResourceName == "" || in.Patch == "" { - return mcp.NewToolResultError("resource_type, resource_name, and patch parameters are required"), nil, nil + return mcp.TextError("resource_type, resource_name, and patch parameters are required") } if err := security.ValidateK8sResourceName(in.ResourceName); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid resource name: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Invalid resource name: %v", err)) } if err := security.ValidateNamespace(in.Namespace); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid namespace: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Invalid namespace: %v", err)) } if err := security.ValidateYAMLContent(in.Patch); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid patch content: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Invalid patch content: %v", err)) } args := []string{ @@ -238,7 +238,7 @@ func (k *K8sTool) handlePatchStatus(ctx context.Context, request *mcp.CallToolRe } res, err := k.runKubectlCommandWithCacheInvalidation(ctx, mcp.Header(request), args...) - return res, nil, err + return res, mcp.TextOf(res), err } // applyManifestInput is the typed input for k8s_apply_manifest. @@ -247,18 +247,18 @@ type applyManifestInput struct { } // Apply manifest from content -func (k *K8sTool) handleApplyManifest(ctx context.Context, request *mcp.CallToolRequest, in applyManifestInput) (*mcp.CallToolResult, any, error) { +func (k *K8sTool) handleApplyManifest(ctx context.Context, request *mcp.CallToolRequest, in applyManifestInput) (*mcp.CallToolResult, mcp.TextOutput, error) { if in.Manifest == "" { - return mcp.NewToolResultError("manifest parameter is required"), nil, nil + return mcp.TextError("manifest parameter is required") } if err := security.ValidateYAMLContent(in.Manifest); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid manifest content: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Invalid manifest content: %v", err)) } tmpFile, err := os.CreateTemp("", "k8s-manifest-*.yaml") if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to create temp file: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to create temp file: %v", err)) } defer func() { @@ -268,20 +268,20 @@ func (k *K8sTool) handleApplyManifest(ctx context.Context, request *mcp.CallTool }() if err := os.Chmod(tmpFile.Name(), 0600); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to set file permissions: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to set file permissions: %v", err)) } if _, err := tmpFile.WriteString(in.Manifest); err != nil { tmpFile.Close() - return mcp.NewToolResultError(fmt.Sprintf("Failed to write to temp file: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to write to temp file: %v", err)) } if err := tmpFile.Close(); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to close temp file: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to close temp file: %v", err)) } res, err := k.runKubectlCommandWithCacheInvalidation(ctx, mcp.Header(request), "apply", "-f", tmpFile.Name()) - return res, nil, err + return res, mcp.TextOf(res), err } // deleteResourceInput is the typed input for k8s_delete_resource. @@ -292,19 +292,19 @@ type deleteResourceInput struct { } // Delete resource -func (k *K8sTool) handleDeleteResource(ctx context.Context, request *mcp.CallToolRequest, in deleteResourceInput) (*mcp.CallToolResult, any, error) { +func (k *K8sTool) handleDeleteResource(ctx context.Context, request *mcp.CallToolRequest, in deleteResourceInput) (*mcp.CallToolResult, mcp.TextOutput, error) { if in.Namespace == "" { in.Namespace = "default" } if in.ResourceType == "" || in.ResourceName == "" { - return mcp.NewToolResultError("resource_type and resource_name parameters are required"), nil, nil + return mcp.TextError("resource_type and resource_name parameters are required") } args := []string{"delete", in.ResourceType, in.ResourceName, "-n", in.Namespace} res, err := k.runKubectlCommandWithCacheInvalidation(ctx, mcp.Header(request), args...) - return res, nil, err + return res, mcp.TextOf(res), err } // waitInput is the typed input for k8s_wait. @@ -319,7 +319,7 @@ type waitInput struct { } // Wait for a condition on one or more resources (kubectl wait) -func (k *K8sTool) handleKubectlWait(ctx context.Context, request *mcp.CallToolRequest, in waitInput) (*mcp.CallToolResult, any, error) { +func (k *K8sTool) handleKubectlWait(ctx context.Context, request *mcp.CallToolRequest, in waitInput) (*mcp.CallToolResult, mcp.TextOutput, error) { if in.Namespace == "" { in.Namespace = "default" } @@ -328,20 +328,20 @@ func (k *K8sTool) handleKubectlWait(ctx context.Context, request *mcp.CallToolRe } if in.ResourceType == "" || in.Condition == "" { - return mcp.NewToolResultError("resource_type and condition parameters are required"), nil, nil + return mcp.TextError("resource_type and condition parameters are required") } if in.ResourceName == "" && in.Selector == "" && !in.All { - return mcp.NewToolResultError("one of resource_name, selector, or all=true must be provided"), nil, nil + return mcp.TextError("one of resource_name, selector, or all=true must be provided") } if err := security.ValidateNamespace(in.Namespace); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid namespace: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Invalid namespace: %v", err)) } target := in.ResourceType if in.ResourceName != "" { if err := security.ValidateK8sResourceName(in.ResourceName); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid resource name: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Invalid resource name: %v", err)) } target = fmt.Sprintf("%s/%s", in.ResourceType, in.ResourceName) } @@ -355,7 +355,7 @@ func (k *K8sTool) handleKubectlWait(ctx context.Context, request *mcp.CallToolRe } res, err := k.runKubectlCommand(ctx, mcp.Header(request), args...) - return res, nil, err + return res, mcp.TextOf(res), err } // serviceConnectivityInput is the typed input for k8s_check_service_connectivity. @@ -365,12 +365,12 @@ type serviceConnectivityInput struct { } // Check service connectivity -func (k *K8sTool) handleCheckServiceConnectivity(ctx context.Context, request *mcp.CallToolRequest, in serviceConnectivityInput) (*mcp.CallToolResult, any, error) { +func (k *K8sTool) handleCheckServiceConnectivity(ctx context.Context, request *mcp.CallToolRequest, in serviceConnectivityInput) (*mcp.CallToolResult, mcp.TextOutput, error) { if in.Namespace == "" { in.Namespace = "default" } if in.ServiceName == "" { - return mcp.NewToolResultError("service_name parameter is required"), nil, nil + return mcp.TextError("service_name parameter is required") } headers := mcp.Header(request) @@ -384,18 +384,18 @@ func (k *K8sTool) handleCheckServiceConnectivity(ctx context.Context, request *m // Create the curl pod _, err := k.runKubectlCommand(ctx, headers, "run", podName, "--image=curlimages/curl", "-n", in.Namespace, "--restart=Never", "--", "sleep", "3600") if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to create curl pod: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to create curl pod: %v", err)) } // Wait for pod to be ready _, err = k.runKubectlCommandWithTimeout(ctx, headers, 60*time.Second, "wait", "--for=condition=ready", "pod/"+podName, "-n", in.Namespace) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to wait for curl pod: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to wait for curl pod: %v", err)) } // Execute kubectl command res, err := k.runKubectlCommand(ctx, headers, "exec", podName, "-n", in.Namespace, "--", "curl", "-s", in.ServiceName) - return res, nil, err + return res, mcp.TextOf(res), err } // eventsInput is the typed input for k8s_get_events. @@ -404,7 +404,7 @@ type eventsInput struct { } // Get cluster events -func (k *K8sTool) handleGetEvents(ctx context.Context, request *mcp.CallToolRequest, in eventsInput) (*mcp.CallToolResult, any, error) { +func (k *K8sTool) handleGetEvents(ctx context.Context, request *mcp.CallToolRequest, in eventsInput) (*mcp.CallToolResult, mcp.TextOutput, error) { args := []string{"get", "events", "-o", "json"} if in.Namespace != "" { args = append(args, "-n", in.Namespace) @@ -413,7 +413,7 @@ func (k *K8sTool) handleGetEvents(ctx context.Context, request *mcp.CallToolRequ } res, err := k.runKubectlCommand(ctx, mcp.Header(request), args...) - return res, nil, err + return res, mcp.TextOf(res), err } // execCommandInput is the typed input for k8s_execute_command. @@ -425,39 +425,39 @@ type execCommandInput struct { } // Execute command in pod -func (k *K8sTool) handleExecCommand(ctx context.Context, request *mcp.CallToolRequest, in execCommandInput) (*mcp.CallToolResult, any, error) { +func (k *K8sTool) handleExecCommand(ctx context.Context, request *mcp.CallToolRequest, in execCommandInput) (*mcp.CallToolResult, mcp.TextOutput, error) { if in.Namespace == "" { in.Namespace = "default" } if in.PodName == "" || in.Command == "" { - return mcp.NewToolResultError("pod_name and command parameters are required"), nil, nil + return mcp.TextError("pod_name and command parameters are required") } if err := security.ValidateK8sResourceName(in.PodName); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid pod name: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Invalid pod name: %v", err)) } if err := security.ValidateNamespace(in.Namespace); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid namespace: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Invalid namespace: %v", err)) } if err := security.ValidateCommandInput(in.Command); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid command: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Invalid command: %v", err)) } args := []string{"exec", in.PodName, "-n", in.Namespace, "--", in.Command} res, err := k.runKubectlCommand(ctx, mcp.Header(request), args...) - return res, nil, err + return res, mcp.TextOf(res), err } // noInput is the typed input for tools that take no arguments. type noInput struct{} // Get available API resources -func (k *K8sTool) handleGetAvailableAPIResources(ctx context.Context, request *mcp.CallToolRequest, _ noInput) (*mcp.CallToolResult, any, error) { +func (k *K8sTool) handleGetAvailableAPIResources(ctx context.Context, request *mcp.CallToolRequest, _ noInput) (*mcp.CallToolResult, mcp.TextOutput, error) { res, err := k.runKubectlCommand(ctx, mcp.Header(request), "api-resources") - return res, nil, err + return res, mcp.TextOf(res), err } // describeInput is the typed input for k8s_describe_resource. @@ -468,9 +468,9 @@ type describeInput struct { } // Kubectl describe tool -func (k *K8sTool) handleKubectlDescribeTool(ctx context.Context, request *mcp.CallToolRequest, in describeInput) (*mcp.CallToolResult, any, error) { +func (k *K8sTool) handleKubectlDescribeTool(ctx context.Context, request *mcp.CallToolRequest, in describeInput) (*mcp.CallToolResult, mcp.TextOutput, error) { if in.ResourceType == "" || in.ResourceName == "" { - return mcp.NewToolResultError("resource_type and resource_name parameters are required"), nil, nil + return mcp.TextError("resource_type and resource_name parameters are required") } args := []string{"describe", in.ResourceType, in.ResourceName} @@ -479,7 +479,7 @@ func (k *K8sTool) handleKubectlDescribeTool(ctx context.Context, request *mcp.Ca } res, err := k.runKubectlCommand(ctx, mcp.Header(request), args...) - return res, nil, err + return res, mcp.TextOf(res), err } // rolloutInput is the typed input for k8s_rollout. @@ -491,9 +491,9 @@ type rolloutInput struct { } // Rollout operations -func (k *K8sTool) handleRollout(ctx context.Context, request *mcp.CallToolRequest, in rolloutInput) (*mcp.CallToolResult, any, error) { +func (k *K8sTool) handleRollout(ctx context.Context, request *mcp.CallToolRequest, in rolloutInput) (*mcp.CallToolResult, mcp.TextOutput, error) { if in.Action == "" || in.ResourceType == "" || in.ResourceName == "" { - return mcp.NewToolResultError("action, resource_type, and resource_name parameters are required"), nil, nil + return mcp.TextError("action, resource_type, and resource_name parameters are required") } args := []string{"rollout", in.Action, fmt.Sprintf("%s/%s", in.ResourceType, in.ResourceName)} @@ -502,13 +502,13 @@ func (k *K8sTool) handleRollout(ctx context.Context, request *mcp.CallToolReques } res, err := k.runKubectlCommand(ctx, mcp.Header(request), args...) - return res, nil, err + return res, mcp.TextOf(res), err } // Get cluster configuration -func (k *K8sTool) handleGetClusterConfiguration(ctx context.Context, request *mcp.CallToolRequest, _ noInput) (*mcp.CallToolResult, any, error) { +func (k *K8sTool) handleGetClusterConfiguration(ctx context.Context, request *mcp.CallToolRequest, _ noInput) (*mcp.CallToolResult, mcp.TextOutput, error) { res, err := k.runKubectlCommand(ctx, mcp.Header(request), "config", "view", "-o", "json") - return res, nil, err + return res, mcp.TextOf(res), err } // removeAnnotationInput is the typed input for k8s_remove_annotation. @@ -520,9 +520,9 @@ type removeAnnotationInput struct { } // Remove annotation -func (k *K8sTool) handleRemoveAnnotation(ctx context.Context, request *mcp.CallToolRequest, in removeAnnotationInput) (*mcp.CallToolResult, any, error) { +func (k *K8sTool) handleRemoveAnnotation(ctx context.Context, request *mcp.CallToolRequest, in removeAnnotationInput) (*mcp.CallToolResult, mcp.TextOutput, error) { if in.ResourceType == "" || in.ResourceName == "" || in.AnnotationKey == "" { - return mcp.NewToolResultError("resource_type, resource_name, and annotation_key parameters are required"), nil, nil + return mcp.TextError("resource_type, resource_name, and annotation_key parameters are required") } args := []string{"annotate", in.ResourceType, in.ResourceName, in.AnnotationKey + "-"} @@ -531,7 +531,7 @@ func (k *K8sTool) handleRemoveAnnotation(ctx context.Context, request *mcp.CallT } res, err := k.runKubectlCommand(ctx, mcp.Header(request), args...) - return res, nil, err + return res, mcp.TextOf(res), err } // removeLabelInput is the typed input for k8s_remove_label. @@ -543,9 +543,9 @@ type removeLabelInput struct { } // Remove label -func (k *K8sTool) handleRemoveLabel(ctx context.Context, request *mcp.CallToolRequest, in removeLabelInput) (*mcp.CallToolResult, any, error) { +func (k *K8sTool) handleRemoveLabel(ctx context.Context, request *mcp.CallToolRequest, in removeLabelInput) (*mcp.CallToolResult, mcp.TextOutput, error) { if in.ResourceType == "" || in.ResourceName == "" || in.LabelKey == "" { - return mcp.NewToolResultError("resource_type, resource_name, and label_key parameters are required"), nil, nil + return mcp.TextError("resource_type, resource_name, and label_key parameters are required") } args := []string{"label", in.ResourceType, in.ResourceName, in.LabelKey + "-"} @@ -554,7 +554,7 @@ func (k *K8sTool) handleRemoveLabel(ctx context.Context, request *mcp.CallToolRe } res, err := k.runKubectlCommand(ctx, mcp.Header(request), args...) - return res, nil, err + return res, mcp.TextOf(res), err } // annotateInput is the typed input for k8s_annotate_resource. @@ -566,9 +566,9 @@ type annotateInput struct { } // Annotate resource -func (k *K8sTool) handleAnnotateResource(ctx context.Context, request *mcp.CallToolRequest, in annotateInput) (*mcp.CallToolResult, any, error) { +func (k *K8sTool) handleAnnotateResource(ctx context.Context, request *mcp.CallToolRequest, in annotateInput) (*mcp.CallToolResult, mcp.TextOutput, error) { if in.ResourceType == "" || in.ResourceName == "" || in.Annotations == "" { - return mcp.NewToolResultError("resource_type, resource_name, and annotations parameters are required"), nil, nil + return mcp.TextError("resource_type, resource_name, and annotations parameters are required") } args := []string{"annotate", in.ResourceType, in.ResourceName} @@ -579,7 +579,7 @@ func (k *K8sTool) handleAnnotateResource(ctx context.Context, request *mcp.CallT } res, err := k.runKubectlCommand(ctx, mcp.Header(request), args...) - return res, nil, err + return res, mcp.TextOf(res), err } // labelInput is the typed input for k8s_label_resource. @@ -591,9 +591,9 @@ type labelInput struct { } // Label resource -func (k *K8sTool) handleLabelResource(ctx context.Context, request *mcp.CallToolRequest, in labelInput) (*mcp.CallToolResult, any, error) { +func (k *K8sTool) handleLabelResource(ctx context.Context, request *mcp.CallToolRequest, in labelInput) (*mcp.CallToolResult, mcp.TextOutput, error) { if in.ResourceType == "" || in.ResourceName == "" || in.Labels == "" { - return mcp.NewToolResultError("resource_type, resource_name, and labels parameters are required"), nil, nil + return mcp.TextError("resource_type, resource_name, and labels parameters are required") } args := []string{"label", in.ResourceType, in.ResourceName} @@ -604,7 +604,7 @@ func (k *K8sTool) handleLabelResource(ctx context.Context, request *mcp.CallTool } res, err := k.runKubectlCommand(ctx, mcp.Header(request), args...) - return res, nil, err + return res, mcp.TextOf(res), err } // createFromURLInput is the typed input for k8s_create_resource_from_url. @@ -614,9 +614,9 @@ type createFromURLInput struct { } // Create resource from URL -func (k *K8sTool) handleCreateResourceFromURL(ctx context.Context, request *mcp.CallToolRequest, in createFromURLInput) (*mcp.CallToolResult, any, error) { +func (k *K8sTool) handleCreateResourceFromURL(ctx context.Context, request *mcp.CallToolRequest, in createFromURLInput) (*mcp.CallToolResult, mcp.TextOutput, error) { if in.URL == "" { - return mcp.NewToolResultError("url parameter is required"), nil, nil + return mcp.TextError("url parameter is required") } args := []string{"create", "-f", in.URL} @@ -625,7 +625,7 @@ func (k *K8sTool) handleCreateResourceFromURL(ctx context.Context, request *mcp. } res, err := k.runKubectlCommand(ctx, mcp.Header(request), args...) - return res, nil, err + return res, mcp.TextOf(res), err } // getResourceYAMLInput is the typed input for k8s_get_resource_yaml. @@ -636,9 +636,9 @@ type getResourceYAMLInput struct { } // Get resource YAML -func (k *K8sTool) handleGetResourceYAML(ctx context.Context, request *mcp.CallToolRequest, in getResourceYAMLInput) (*mcp.CallToolResult, any, error) { +func (k *K8sTool) handleGetResourceYAML(ctx context.Context, request *mcp.CallToolRequest, in getResourceYAMLInput) (*mcp.CallToolResult, mcp.TextOutput, error) { if in.ResourceType == "" || in.ResourceName == "" { - return mcp.NewToolResultError("resource_type and resource_name are required"), nil, nil + return mcp.TextError("resource_type and resource_name are required") } args := []string{"get", in.ResourceType, in.ResourceName, "-o", "yaml"} @@ -648,9 +648,9 @@ func (k *K8sTool) handleGetResourceYAML(ctx context.Context, request *mcp.CallTo res, err := k.runKubectlCommand(ctx, mcp.Header(request), args...) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Get YAML command failed: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Get YAML command failed: %v", err)) } - return res, nil, nil + return res, mcp.TextOf(res), nil } // createResourceInput is the typed input for k8s_create_resource. @@ -659,27 +659,27 @@ type createResourceInput struct { } // Create resource from YAML content -func (k *K8sTool) handleCreateResource(ctx context.Context, request *mcp.CallToolRequest, in createResourceInput) (*mcp.CallToolResult, any, error) { +func (k *K8sTool) handleCreateResource(ctx context.Context, request *mcp.CallToolRequest, in createResourceInput) (*mcp.CallToolResult, mcp.TextOutput, error) { if in.YAMLContent == "" { - return mcp.NewToolResultError("yaml_content is required"), nil, nil + return mcp.TextError("yaml_content is required") } tmpFile, err := os.CreateTemp("", "k8s-resource-*.yaml") if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to create temp file: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to create temp file: %v", err)) } defer os.Remove(tmpFile.Name()) if _, err := tmpFile.WriteString(in.YAMLContent); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to write to temp file: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Failed to write to temp file: %v", err)) } tmpFile.Close() res, err := k.runKubectlCommand(ctx, mcp.Header(request), "create", "-f", tmpFile.Name()) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Create command failed: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Create command failed: %v", err)) } - return res, nil, nil + return res, mcp.TextOf(res), nil } // Resource generation embeddings @@ -733,18 +733,18 @@ type generateResourceInput struct { } // Generate resource using LLM -func (k *K8sTool) handleGenerateResource(ctx context.Context, request *mcp.CallToolRequest, in generateResourceInput) (*mcp.CallToolResult, any, error) { +func (k *K8sTool) handleGenerateResource(ctx context.Context, request *mcp.CallToolRequest, in generateResourceInput) (*mcp.CallToolResult, mcp.TextOutput, error) { if in.ResourceType == "" || in.ResourceDescription == "" { - return mcp.NewToolResultError("resource_type and resource_description parameters are required"), nil, nil + return mcp.TextError("resource_type and resource_description parameters are required") } systemPrompt, ok := resourceMap[in.ResourceType] if !ok { - return mcp.NewToolResultError(fmt.Sprintf("resource type %s not found", in.ResourceType)), nil, nil + return mcp.TextError(fmt.Sprintf("resource type %s not found", in.ResourceType)) } if k.llmModel == nil { - return mcp.NewToolResultError("No LLM client present, can't generate resource"), nil, nil + return mcp.TextError("No LLM client present, can't generate resource") } llm := k.llmModel @@ -765,16 +765,16 @@ func (k *K8sTool) handleGenerateResource(ctx context.Context, request *mcp.CallT resp, err := llm.GenerateContent(ctx, contents, llms.WithModel("gpt-4o-mini")) if err != nil { - return mcp.NewToolResultError("failed to generate content: " + err.Error()), nil, nil + return mcp.TextError("failed to generate content: " + err.Error()) } choices := resp.Choices if len(choices) < 1 { - return mcp.NewToolResultError("empty response from model"), nil, nil + return mcp.TextError("empty response from model") } responseText := choices[0].Content - return mcp.NewToolResultText(responseText), nil, nil + return mcp.TextResult(responseText) } // extractBearerToken extracts the Bearer token from the Authorization header diff --git a/pkg/kubescape/kubescape.go b/pkg/kubescape/kubescape.go index a49bd092..e3a026b9 100644 --- a/pkg/kubescape/kubescape.go +++ b/pkg/kubescape/kubescape.go @@ -105,16 +105,22 @@ func getKubeConfig(kubeconfig string) (*rest.Config, error) { // HealthCheckResult represents the result of a health check type HealthCheckResult struct { Healthy bool `json:"healthy"` - Checks map[string]CheckStatus `json:"checks"` + Checks map[string]CheckStatus `json:"checks,omitempty"` Summary string `json:"summary"` Recommendations []string `json:"recommendations,omitempty"` } // CheckStatus represents the status of a single check type CheckStatus struct { - Status string `json:"status"` - Message string `json:"message"` - Details interface{} `json:"details,omitempty"` + Status string `json:"status"` + Message string `json:"message"` + Details []PodCheckEntry `json:"details,omitempty"` +} + +// PodCheckEntry reports the name and phase of a pod inspected by a health check. +type PodCheckEntry struct { + Name string `json:"name"` + Status string `json:"status"` } type checkHealthInput struct { @@ -164,9 +170,9 @@ type getNetworkNeighborhoodInput struct { Name string `json:"name" jsonschema:"Name of the network neighborhood"` } -// Typed response shapes for the read-only scan/report tools. These replace the -// untyped map[string]interface{} builders so the JSON returned to the client is -// produced from concrete Go types. +// Typed response shapes for the read-only scan/report tools. Each is returned +// directly as the handler's typed Out value, so the SDK derives an output schema +// and populates CallToolResult.StructuredContent from a concrete Go type. type vulnerabilityManifestSummary struct { Namespace string `json:"namespace"` @@ -256,8 +262,8 @@ type getApplicationProfileOutput struct { Name string `json:"name"` Containers []containerBehavior `json:"containers"` InitContainers []containerBehavior `json:"init_containers"` - Annotations map[string]string `json:"annotations"` - Labels map[string]string `json:"labels"` + Annotations map[string]string `json:"annotations,omitempty"` + Labels map[string]string `json:"labels,omitempty"` Description string `json:"description"` } @@ -296,16 +302,16 @@ type getNetworkNeighborhoodOutput struct { Namespace string `json:"namespace"` Name string `json:"name"` Containers []networkContainer `json:"containers"` - Annotations map[string]string `json:"annotations"` - Labels map[string]string `json:"labels"` + Annotations map[string]string `json:"annotations,omitempty"` + Labels map[string]string `json:"labels,omitempty"` Description string `json:"description"` } // handleCheckHealth verifies Kubescape operator installation and readiness -func (k *KubescapeTool) handleCheckHealth(ctx context.Context, request *mcp.CallToolRequest, in checkHealthInput) (*mcp.CallToolResult, any, error) { +func (k *KubescapeTool) handleCheckHealth(ctx context.Context, request *mcp.CallToolRequest, in checkHealthInput) (*mcp.CallToolResult, HealthCheckResult, error) { if k.initError != nil { toolErr := errors.NewKubescapeError("check_health", k.initError) - return kubescapeErrResult(toolErr), nil, nil + return kubescapeErrResult(toolErr), HealthCheckResult{}, nil } namespace := in.Namespace @@ -362,15 +368,15 @@ func (k *KubescapeTool) handleCheckHealth(ctx context.Context, request *mcp.Call recommendations = append(recommendations, "Install Kubescape operator: helm upgrade --install kubescape kubescape/kubescape-operator -n kubescape --create-namespace") } else { runningCount := 0 - podDetails := []map[string]string{} + podDetails := []PodCheckEntry{} for _, pod := range operatorPods.Items { status := string(pod.Status.Phase) if pod.Status.Phase == corev1.PodRunning { runningCount++ } - podDetails = append(podDetails, map[string]string{ - "name": pod.Name, - "status": status, + podDetails = append(podDetails, PodCheckEntry{ + Name: pod.Name, + Status: status, }) } if runningCount == len(operatorPods.Items) { @@ -646,17 +652,17 @@ func (k *KubescapeTool) handleCheckHealth(ctx context.Context, request *mcp.Call content, err := json.MarshalIndent(result, "", " ") if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil, nil + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), HealthCheckResult{}, nil } - return mcp.NewToolResultText(string(content)), nil, nil + return mcp.NewToolResultText(string(content)), result, nil } // handleListVulnerabilityManifests lists vulnerability manifests at image and workload levels -func (k *KubescapeTool) handleListVulnerabilityManifests(ctx context.Context, request *mcp.CallToolRequest, in listVulnerabilityManifestsInput) (*mcp.CallToolResult, any, error) { +func (k *KubescapeTool) handleListVulnerabilityManifests(ctx context.Context, request *mcp.CallToolRequest, in listVulnerabilityManifestsInput) (*mcp.CallToolResult, listVulnerabilityManifestsOutput, error) { if k.initError != nil { toolErr := errors.NewKubescapeError("list_vulnerability_manifests", k.initError) - return kubescapeErrResult(toolErr), nil, nil + return kubescapeErrResult(toolErr), listVulnerabilityManifestsOutput{}, nil } namespace := in.Namespace @@ -691,7 +697,7 @@ func (k *KubescapeTool) handleListVulnerabilityManifests(ctx context.Context, re toolErr := errors.NewKubescapeError("list_vulnerability_manifests", err). WithContext("namespace", namespace). WithContext("level", level) - return kubescapeErrResult(toolErr), nil, nil + return kubescapeErrResult(toolErr), listVulnerabilityManifestsOutput{}, nil } // Build response @@ -718,17 +724,17 @@ func (k *KubescapeTool) handleListVulnerabilityManifests(ctx context.Context, re content, err := json.MarshalIndent(result, "", " ") if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil, nil + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), listVulnerabilityManifestsOutput{}, nil } - return mcp.NewToolResultText(string(content)), nil, nil + return mcp.NewToolResultText(string(content)), result, nil } // handleListVulnerabilitiesInManifest lists all CVEs in a specific manifest -func (k *KubescapeTool) handleListVulnerabilitiesInManifest(ctx context.Context, request *mcp.CallToolRequest, in listVulnerabilitiesInManifestInput) (*mcp.CallToolResult, any, error) { +func (k *KubescapeTool) handleListVulnerabilitiesInManifest(ctx context.Context, request *mcp.CallToolRequest, in listVulnerabilitiesInManifestInput) (*mcp.CallToolResult, listVulnerabilitiesInManifestOutput, error) { if k.initError != nil { toolErr := errors.NewKubescapeError("list_vulnerabilities", k.initError) - return kubescapeErrResult(toolErr), nil, nil + return kubescapeErrResult(toolErr), listVulnerabilitiesInManifestOutput{}, nil } namespace := in.Namespace @@ -738,7 +744,7 @@ func (k *KubescapeTool) handleListVulnerabilitiesInManifest(ctx context.Context, manifestName := in.ManifestName if manifestName == "" { - return mcp.NewToolResultError("manifest_name parameter is required"), nil, nil + return mcp.NewToolResultError("manifest_name parameter is required"), listVulnerabilitiesInManifestOutput{}, nil } manifest, err := k.spdxClient.VulnerabilityManifests(namespace).Get(ctx, manifestName, metav1.GetOptions{}) @@ -746,7 +752,7 @@ func (k *KubescapeTool) handleListVulnerabilitiesInManifest(ctx context.Context, toolErr := errors.NewKubescapeError("get_vulnerability_manifest", err). WithContext("namespace", namespace). WithContext("manifest_name", manifestName) - return kubescapeErrResult(toolErr), nil, nil + return kubescapeErrResult(toolErr), listVulnerabilitiesInManifestOutput{}, nil } // Extract vulnerabilities with summary info @@ -794,14 +800,14 @@ func (k *KubescapeTool) handleListVulnerabilitiesInManifest(ctx context.Context, content, err := json.MarshalIndent(result, "", " ") if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil, nil + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), listVulnerabilitiesInManifestOutput{}, nil } - return mcp.NewToolResultText(string(content)), nil, nil + return mcp.NewToolResultText(string(content)), result, nil } // handleGetVulnerabilityDetails gets detailed info about a specific CVE in a manifest -func (k *KubescapeTool) handleGetVulnerabilityDetails(ctx context.Context, request *mcp.CallToolRequest, in getVulnerabilityDetailsInput) (*mcp.CallToolResult, any, error) { +func (k *KubescapeTool) handleGetVulnerabilityDetails(ctx context.Context, request *mcp.CallToolRequest, in getVulnerabilityDetailsInput) (*mcp.CallToolResult, []v1beta1.Match, error) { if k.initError != nil { toolErr := errors.NewKubescapeError("get_vulnerability_details", k.initError) return kubescapeErrResult(toolErr), nil, nil @@ -846,14 +852,14 @@ func (k *KubescapeTool) handleGetVulnerabilityDetails(ctx context.Context, reque return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil, nil } - return mcp.NewToolResultText(string(content)), nil, nil + return mcp.NewToolResultText(string(content)), matches, nil } // handleListConfigurationScans lists configuration security scan results -func (k *KubescapeTool) handleListConfigurationScans(ctx context.Context, request *mcp.CallToolRequest, in listConfigurationScansInput) (*mcp.CallToolResult, any, error) { +func (k *KubescapeTool) handleListConfigurationScans(ctx context.Context, request *mcp.CallToolRequest, in listConfigurationScansInput) (*mcp.CallToolResult, listConfigurationScansOutput, error) { if k.initError != nil { toolErr := errors.NewKubescapeError("list_configuration_scans", k.initError) - return kubescapeErrResult(toolErr), nil, nil + return kubescapeErrResult(toolErr), listConfigurationScansOutput{}, nil } namespace := in.Namespace @@ -867,7 +873,7 @@ func (k *KubescapeTool) handleListConfigurationScans(ctx context.Context, reques if err != nil { toolErr := errors.NewKubescapeError("list_configuration_scans", err). WithContext("namespace", namespace) - return kubescapeErrResult(toolErr), nil, nil + return kubescapeErrResult(toolErr), listConfigurationScansOutput{}, nil } configManifests := []configurationScanSummary{} @@ -886,17 +892,18 @@ func (k *KubescapeTool) handleListConfigurationScans(ctx context.Context, reques content, err := json.MarshalIndent(result, "", " ") if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil, nil + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), listConfigurationScansOutput{}, nil } - return mcp.NewToolResultText(string(content)), nil, nil + return mcp.NewToolResultText(string(content)), result, nil } // handleGetConfigurationScan gets details of a specific configuration scan -func (k *KubescapeTool) handleGetConfigurationScan(ctx context.Context, request *mcp.CallToolRequest, in getConfigurationScanInput) (*mcp.CallToolResult, any, error) { +func (k *KubescapeTool) handleGetConfigurationScan(ctx context.Context, request *mcp.CallToolRequest, in getConfigurationScanInput) (*mcp.CallToolResult, mcp.TextOutput, error) { if k.initError != nil { toolErr := errors.NewKubescapeError("get_configuration_scan", k.initError) - return kubescapeErrResult(toolErr), nil, nil + res := kubescapeErrResult(toolErr) + return res, mcp.TextOf(res), nil } namespace := in.Namespace @@ -906,7 +913,7 @@ func (k *KubescapeTool) handleGetConfigurationScan(ctx context.Context, request manifestName := in.ManifestName if manifestName == "" { - return mcp.NewToolResultError("manifest_name parameter is required"), nil, nil + return mcp.TextError("manifest_name parameter is required") } manifest, err := k.spdxClient.WorkloadConfigurationScans(namespace).Get(ctx, manifestName, metav1.GetOptions{}) @@ -914,22 +921,23 @@ func (k *KubescapeTool) handleGetConfigurationScan(ctx context.Context, request toolErr := errors.NewKubescapeError("get_configuration_scan", err). WithContext("namespace", namespace). WithContext("manifest_name", manifestName) - return kubescapeErrResult(toolErr), nil, nil + res := kubescapeErrResult(toolErr) + return res, mcp.TextOf(res), nil } content, err := json.MarshalIndent(manifest, "", " ") if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("failed to marshal result: %v", err)) } - return mcp.NewToolResultText(string(content)), nil, nil + return mcp.TextResult(string(content)) } // handleListApplicationProfiles lists application profiles showing runtime behavior data -func (k *KubescapeTool) handleListApplicationProfiles(ctx context.Context, request *mcp.CallToolRequest, in listApplicationProfilesInput) (*mcp.CallToolResult, any, error) { +func (k *KubescapeTool) handleListApplicationProfiles(ctx context.Context, request *mcp.CallToolRequest, in listApplicationProfilesInput) (*mcp.CallToolResult, listApplicationProfilesOutput, error) { if k.initError != nil { toolErr := errors.NewKubescapeError("list_application_profiles", k.initError) - return kubescapeErrResult(toolErr), nil, nil + return kubescapeErrResult(toolErr), listApplicationProfilesOutput{}, nil } namespace := in.Namespace @@ -943,7 +951,7 @@ func (k *KubescapeTool) handleListApplicationProfiles(ctx context.Context, reque if err != nil { toolErr := errors.NewKubescapeError("list_application_profiles", err). WithContext("namespace", namespace) - return kubescapeErrResult(toolErr), nil, nil + return kubescapeErrResult(toolErr), listApplicationProfilesOutput{}, nil } profileList := []applicationProfileSummary{} @@ -993,27 +1001,27 @@ func (k *KubescapeTool) handleListApplicationProfiles(ctx context.Context, reque content, err := json.MarshalIndent(result, "", " ") if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil, nil + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), listApplicationProfilesOutput{}, nil } - return mcp.NewToolResultText(string(content)), nil, nil + return mcp.NewToolResultText(string(content)), result, nil } // handleGetApplicationProfile gets detailed runtime behavior for a specific workload -func (k *KubescapeTool) handleGetApplicationProfile(ctx context.Context, request *mcp.CallToolRequest, in getApplicationProfileInput) (*mcp.CallToolResult, any, error) { +func (k *KubescapeTool) handleGetApplicationProfile(ctx context.Context, request *mcp.CallToolRequest, in getApplicationProfileInput) (*mcp.CallToolResult, getApplicationProfileOutput, error) { if k.initError != nil { toolErr := errors.NewKubescapeError("get_application_profile", k.initError) - return kubescapeErrResult(toolErr), nil, nil + return kubescapeErrResult(toolErr), getApplicationProfileOutput{}, nil } namespace := in.Namespace name := in.Name if name == "" { - return mcp.NewToolResultError("name parameter is required"), nil, nil + return mcp.NewToolResultError("name parameter is required"), getApplicationProfileOutput{}, nil } if namespace == "" { - return mcp.NewToolResultError("namespace parameter is required"), nil, nil + return mcp.NewToolResultError("namespace parameter is required"), getApplicationProfileOutput{}, nil } profile, err := k.spdxClient.ApplicationProfiles(namespace).Get(ctx, name, metav1.GetOptions{}) @@ -1021,7 +1029,7 @@ func (k *KubescapeTool) handleGetApplicationProfile(ctx context.Context, request toolErr := errors.NewKubescapeError("get_application_profile", err). WithContext("namespace", namespace). WithContext("name", name) - return kubescapeErrResult(toolErr), nil, nil + return kubescapeErrResult(toolErr), getApplicationProfileOutput{}, nil } // Build detailed response with container behaviors @@ -1069,17 +1077,17 @@ func (k *KubescapeTool) handleGetApplicationProfile(ctx context.Context, request content, err := json.MarshalIndent(result, "", " ") if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil, nil + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), getApplicationProfileOutput{}, nil } - return mcp.NewToolResultText(string(content)), nil, nil + return mcp.NewToolResultText(string(content)), result, nil } // handleListNetworkNeighborhoods lists network communication patterns for workloads -func (k *KubescapeTool) handleListNetworkNeighborhoods(ctx context.Context, request *mcp.CallToolRequest, in listNetworkNeighborhoodsInput) (*mcp.CallToolResult, any, error) { +func (k *KubescapeTool) handleListNetworkNeighborhoods(ctx context.Context, request *mcp.CallToolRequest, in listNetworkNeighborhoodsInput) (*mcp.CallToolResult, listNetworkNeighborhoodsOutput, error) { if k.initError != nil { toolErr := errors.NewKubescapeError("list_network_neighborhoods", k.initError) - return kubescapeErrResult(toolErr), nil, nil + return kubescapeErrResult(toolErr), listNetworkNeighborhoodsOutput{}, nil } namespace := in.Namespace @@ -1093,7 +1101,7 @@ func (k *KubescapeTool) handleListNetworkNeighborhoods(ctx context.Context, requ if err != nil { toolErr := errors.NewKubescapeError("list_network_neighborhoods", err). WithContext("namespace", namespace) - return kubescapeErrResult(toolErr), nil, nil + return kubescapeErrResult(toolErr), listNetworkNeighborhoodsOutput{}, nil } neighborhoodList := []networkNeighborhoodSummary{} @@ -1126,27 +1134,27 @@ func (k *KubescapeTool) handleListNetworkNeighborhoods(ctx context.Context, requ content, err := json.MarshalIndent(result, "", " ") if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil, nil + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), listNetworkNeighborhoodsOutput{}, nil } - return mcp.NewToolResultText(string(content)), nil, nil + return mcp.NewToolResultText(string(content)), result, nil } // handleGetNetworkNeighborhood gets detailed network connections for a specific workload -func (k *KubescapeTool) handleGetNetworkNeighborhood(ctx context.Context, request *mcp.CallToolRequest, in getNetworkNeighborhoodInput) (*mcp.CallToolResult, any, error) { +func (k *KubescapeTool) handleGetNetworkNeighborhood(ctx context.Context, request *mcp.CallToolRequest, in getNetworkNeighborhoodInput) (*mcp.CallToolResult, getNetworkNeighborhoodOutput, error) { if k.initError != nil { toolErr := errors.NewKubescapeError("get_network_neighborhood", k.initError) - return kubescapeErrResult(toolErr), nil, nil + return kubescapeErrResult(toolErr), getNetworkNeighborhoodOutput{}, nil } namespace := in.Namespace name := in.Name if name == "" { - return mcp.NewToolResultError("name parameter is required"), nil, nil + return mcp.NewToolResultError("name parameter is required"), getNetworkNeighborhoodOutput{}, nil } if namespace == "" { - return mcp.NewToolResultError("namespace parameter is required"), nil, nil + return mcp.NewToolResultError("namespace parameter is required"), getNetworkNeighborhoodOutput{}, nil } nn, err := k.spdxClient.NetworkNeighborhoods(namespace).Get(ctx, name, metav1.GetOptions{}) @@ -1154,7 +1162,7 @@ func (k *KubescapeTool) handleGetNetworkNeighborhood(ctx context.Context, reques toolErr := errors.NewKubescapeError("get_network_neighborhood", err). WithContext("namespace", namespace). WithContext("name", name) - return kubescapeErrResult(toolErr), nil, nil + return kubescapeErrResult(toolErr), getNetworkNeighborhoodOutput{}, nil } // Build detailed response with container network data @@ -1230,10 +1238,10 @@ func (k *KubescapeTool) handleGetNetworkNeighborhood(ctx context.Context, reques content, err := json.MarshalIndent(result, "", " ") if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil, nil + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), getNetworkNeighborhoodOutput{}, nil } - return mcp.NewToolResultText(string(content)), nil, nil + return mcp.NewToolResultText(string(content)), result, nil } // Helper function to truncate strings @@ -1314,58 +1322,58 @@ func RegisterTools(s *mcp.Server, kubeconfig string, readOnly bool) { // Interfaces for testing - allows mocking the Kubernetes clients type KubescapeToolInterface interface { - HandleCheckHealth(ctx context.Context, in checkHealthInput) (*mcp.CallToolResult, any, error) - HandleListVulnerabilityManifests(ctx context.Context, in listVulnerabilityManifestsInput) (*mcp.CallToolResult, any, error) - HandleListVulnerabilitiesInManifest(ctx context.Context, in listVulnerabilitiesInManifestInput) (*mcp.CallToolResult, any, error) - HandleGetVulnerabilityDetails(ctx context.Context, in getVulnerabilityDetailsInput) (*mcp.CallToolResult, any, error) - HandleListConfigurationScans(ctx context.Context, in listConfigurationScansInput) (*mcp.CallToolResult, any, error) - HandleGetConfigurationScan(ctx context.Context, in getConfigurationScanInput) (*mcp.CallToolResult, any, error) - HandleListApplicationProfiles(ctx context.Context, in listApplicationProfilesInput) (*mcp.CallToolResult, any, error) - HandleGetApplicationProfile(ctx context.Context, in getApplicationProfileInput) (*mcp.CallToolResult, any, error) - HandleListNetworkNeighborhoods(ctx context.Context, in listNetworkNeighborhoodsInput) (*mcp.CallToolResult, any, error) - HandleGetNetworkNeighborhood(ctx context.Context, in getNetworkNeighborhoodInput) (*mcp.CallToolResult, any, error) + HandleCheckHealth(ctx context.Context, in checkHealthInput) (*mcp.CallToolResult, HealthCheckResult, error) + HandleListVulnerabilityManifests(ctx context.Context, in listVulnerabilityManifestsInput) (*mcp.CallToolResult, listVulnerabilityManifestsOutput, error) + HandleListVulnerabilitiesInManifest(ctx context.Context, in listVulnerabilitiesInManifestInput) (*mcp.CallToolResult, listVulnerabilitiesInManifestOutput, error) + HandleGetVulnerabilityDetails(ctx context.Context, in getVulnerabilityDetailsInput) (*mcp.CallToolResult, []v1beta1.Match, error) + HandleListConfigurationScans(ctx context.Context, in listConfigurationScansInput) (*mcp.CallToolResult, listConfigurationScansOutput, error) + HandleGetConfigurationScan(ctx context.Context, in getConfigurationScanInput) (*mcp.CallToolResult, mcp.TextOutput, error) + HandleListApplicationProfiles(ctx context.Context, in listApplicationProfilesInput) (*mcp.CallToolResult, listApplicationProfilesOutput, error) + HandleGetApplicationProfile(ctx context.Context, in getApplicationProfileInput) (*mcp.CallToolResult, getApplicationProfileOutput, error) + HandleListNetworkNeighborhoods(ctx context.Context, in listNetworkNeighborhoodsInput) (*mcp.CallToolResult, listNetworkNeighborhoodsOutput, error) + HandleGetNetworkNeighborhood(ctx context.Context, in getNetworkNeighborhoodInput) (*mcp.CallToolResult, getNetworkNeighborhoodOutput, error) } // Ensure KubescapeTool implements the interface var _ KubescapeToolInterface = (*KubescapeTool)(nil) // Export handler methods for testing -func (k *KubescapeTool) HandleCheckHealth(ctx context.Context, in checkHealthInput) (*mcp.CallToolResult, any, error) { +func (k *KubescapeTool) HandleCheckHealth(ctx context.Context, in checkHealthInput) (*mcp.CallToolResult, HealthCheckResult, error) { return k.handleCheckHealth(ctx, &mcp.CallToolRequest{}, in) } -func (k *KubescapeTool) HandleListVulnerabilityManifests(ctx context.Context, in listVulnerabilityManifestsInput) (*mcp.CallToolResult, any, error) { +func (k *KubescapeTool) HandleListVulnerabilityManifests(ctx context.Context, in listVulnerabilityManifestsInput) (*mcp.CallToolResult, listVulnerabilityManifestsOutput, error) { return k.handleListVulnerabilityManifests(ctx, &mcp.CallToolRequest{}, in) } -func (k *KubescapeTool) HandleListVulnerabilitiesInManifest(ctx context.Context, in listVulnerabilitiesInManifestInput) (*mcp.CallToolResult, any, error) { +func (k *KubescapeTool) HandleListVulnerabilitiesInManifest(ctx context.Context, in listVulnerabilitiesInManifestInput) (*mcp.CallToolResult, listVulnerabilitiesInManifestOutput, error) { return k.handleListVulnerabilitiesInManifest(ctx, &mcp.CallToolRequest{}, in) } -func (k *KubescapeTool) HandleGetVulnerabilityDetails(ctx context.Context, in getVulnerabilityDetailsInput) (*mcp.CallToolResult, any, error) { +func (k *KubescapeTool) HandleGetVulnerabilityDetails(ctx context.Context, in getVulnerabilityDetailsInput) (*mcp.CallToolResult, []v1beta1.Match, error) { return k.handleGetVulnerabilityDetails(ctx, &mcp.CallToolRequest{}, in) } -func (k *KubescapeTool) HandleListConfigurationScans(ctx context.Context, in listConfigurationScansInput) (*mcp.CallToolResult, any, error) { +func (k *KubescapeTool) HandleListConfigurationScans(ctx context.Context, in listConfigurationScansInput) (*mcp.CallToolResult, listConfigurationScansOutput, error) { return k.handleListConfigurationScans(ctx, &mcp.CallToolRequest{}, in) } -func (k *KubescapeTool) HandleGetConfigurationScan(ctx context.Context, in getConfigurationScanInput) (*mcp.CallToolResult, any, error) { +func (k *KubescapeTool) HandleGetConfigurationScan(ctx context.Context, in getConfigurationScanInput) (*mcp.CallToolResult, mcp.TextOutput, error) { return k.handleGetConfigurationScan(ctx, &mcp.CallToolRequest{}, in) } -func (k *KubescapeTool) HandleListApplicationProfiles(ctx context.Context, in listApplicationProfilesInput) (*mcp.CallToolResult, any, error) { +func (k *KubescapeTool) HandleListApplicationProfiles(ctx context.Context, in listApplicationProfilesInput) (*mcp.CallToolResult, listApplicationProfilesOutput, error) { return k.handleListApplicationProfiles(ctx, &mcp.CallToolRequest{}, in) } -func (k *KubescapeTool) HandleGetApplicationProfile(ctx context.Context, in getApplicationProfileInput) (*mcp.CallToolResult, any, error) { +func (k *KubescapeTool) HandleGetApplicationProfile(ctx context.Context, in getApplicationProfileInput) (*mcp.CallToolResult, getApplicationProfileOutput, error) { return k.handleGetApplicationProfile(ctx, &mcp.CallToolRequest{}, in) } -func (k *KubescapeTool) HandleListNetworkNeighborhoods(ctx context.Context, in listNetworkNeighborhoodsInput) (*mcp.CallToolResult, any, error) { +func (k *KubescapeTool) HandleListNetworkNeighborhoods(ctx context.Context, in listNetworkNeighborhoodsInput) (*mcp.CallToolResult, listNetworkNeighborhoodsOutput, error) { return k.handleListNetworkNeighborhoods(ctx, &mcp.CallToolRequest{}, in) } -func (k *KubescapeTool) HandleGetNetworkNeighborhood(ctx context.Context, in getNetworkNeighborhoodInput) (*mcp.CallToolResult, any, error) { +func (k *KubescapeTool) HandleGetNetworkNeighborhood(ctx context.Context, in getNetworkNeighborhoodInput) (*mcp.CallToolResult, getNetworkNeighborhoodOutput, error) { return k.handleGetNetworkNeighborhood(ctx, &mcp.CallToolRequest{}, in) } diff --git a/pkg/prometheus/prometheus.go b/pkg/prometheus/prometheus.go index 73ff43bd..a6a7ccae 100644 --- a/pkg/prometheus/prometheus.go +++ b/pkg/prometheus/prometheus.go @@ -1,6 +1,7 @@ package prometheus import ( + "bytes" "context" "encoding/json" "fmt" @@ -29,12 +30,24 @@ func prometheusErrResult(toolErr *errors.ToolError) *mcp.CallToolResult { return toolErr.ToMCPResult() } +// prettyJSONBody indents a JSON response body for readability without decoding it +// into an untyped value. Prometheus returns a dynamic payload, so the raw JSON is +// preserved and only re-indented; if it is not valid JSON the original body is +// returned unchanged, matching the previous fallback behaviour. +func prettyJSONBody(body []byte) string { + var indented bytes.Buffer + if err := json.Indent(&indented, body, "", " "); err != nil { + return string(body) + } + return indented.String() +} + type prometheusQueryInput struct { Query string `json:"query" jsonschema:"PromQL query to execute"` PrometheusURL string `json:"prometheus_url" jsonschema:"Prometheus server URL (default: http://localhost:9090)"` } -func handlePrometheusQueryTool(ctx context.Context, request *mcp.CallToolRequest, in prometheusQueryInput) (*mcp.CallToolResult, any, error) { +func handlePrometheusQueryTool(ctx context.Context, request *mcp.CallToolRequest, in prometheusQueryInput) (*mcp.CallToolResult, mcp.TextOutput, error) { prometheusURL := in.PrometheusURL if prometheusURL == "" { prometheusURL = "http://localhost:9090" @@ -42,17 +55,17 @@ func handlePrometheusQueryTool(ctx context.Context, request *mcp.CallToolRequest query := in.Query if query == "" { - return mcp.NewToolResultError("query parameter is required"), nil, nil + return mcp.TextError("query parameter is required") } // Validate prometheus URL if err := security.ValidateURL(prometheusURL); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid Prometheus URL: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Invalid Prometheus URL: %v", err)) } // Validate PromQL query if err := security.ValidatePromQLQuery(query); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid PromQL query: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Invalid PromQL query: %v", err)) } // Make request to Prometheus API @@ -69,7 +82,7 @@ func handlePrometheusQueryTool(ctx context.Context, request *mcp.CallToolRequest toolErr := errors.NewPrometheusError("create_request", err). WithContext("prometheus_url", prometheusURL). WithContext("query", query) - return prometheusErrResult(toolErr), nil, nil + return prometheusErrResult(toolErr), mcp.TextOutput{}, nil } resp, err := client.Do(req) @@ -78,7 +91,7 @@ func handlePrometheusQueryTool(ctx context.Context, request *mcp.CallToolRequest WithContext("prometheus_url", prometheusURL). WithContext("query", query). WithContext("api_url", apiURL) - return prometheusErrResult(toolErr), nil, nil + return prometheusErrResult(toolErr), mcp.TextOutput{}, nil } defer resp.Body.Close() @@ -88,7 +101,7 @@ func handlePrometheusQueryTool(ctx context.Context, request *mcp.CallToolRequest WithContext("prometheus_url", prometheusURL). WithContext("query", query). WithContext("status_code", fmt.Sprintf("%d", resp.StatusCode)) - return prometheusErrResult(toolErr), nil, nil + return prometheusErrResult(toolErr), mcp.TextOutput{}, nil } if resp.StatusCode != http.StatusOK { @@ -97,21 +110,11 @@ func handlePrometheusQueryTool(ctx context.Context, request *mcp.CallToolRequest WithContext("query", query). WithContext("status_code", fmt.Sprintf("%d", resp.StatusCode)). WithContext("response_body", string(body)) - return prometheusErrResult(toolErr), nil, nil + return prometheusErrResult(toolErr), mcp.TextOutput{}, nil } // Parse the JSON response to pretty-print it - var result interface{} - if err := json.Unmarshal(body, &result); err != nil { - return mcp.NewToolResultText(string(body)), nil, nil - } - - prettyJSON, err := json.MarshalIndent(result, "", " ") - if err != nil { - return mcp.NewToolResultText(string(body)), nil, nil - } - - return mcp.NewToolResultText(string(prettyJSON)), nil, nil + return mcp.TextResult(prettyJSONBody(body)) } type prometheusRangeQueryInput struct { @@ -122,7 +125,7 @@ type prometheusRangeQueryInput struct { PrometheusURL string `json:"prometheus_url" jsonschema:"Prometheus server URL (default: http://localhost:9090)"` } -func handlePrometheusRangeQueryTool(ctx context.Context, request *mcp.CallToolRequest, in prometheusRangeQueryInput) (*mcp.CallToolResult, any, error) { +func handlePrometheusRangeQueryTool(ctx context.Context, request *mcp.CallToolRequest, in prometheusRangeQueryInput) (*mcp.CallToolResult, mcp.TextOutput, error) { prometheusURL := in.PrometheusURL if prometheusURL == "" { prometheusURL = "http://localhost:9090" @@ -136,33 +139,33 @@ func handlePrometheusRangeQueryTool(ctx context.Context, request *mcp.CallToolRe } if query == "" { - return mcp.NewToolResultError("query parameter is required"), nil, nil + return mcp.TextError("query parameter is required") } // Validate prometheus URL if err := security.ValidateURL(prometheusURL); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid Prometheus URL: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Invalid Prometheus URL: %v", err)) } // Validate PromQL query if err := security.ValidatePromQLQuery(query); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid PromQL query: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Invalid PromQL query: %v", err)) } // Validate time parameters if provided if start != "" { if err := security.ValidateCommandInput(start); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid start time: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Invalid start time: %v", err)) } } if end != "" { if err := security.ValidateCommandInput(end); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid end time: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Invalid end time: %v", err)) } } if step != "" { if err := security.ValidateCommandInput(step); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid step parameter: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Invalid step parameter: %v", err)) } } @@ -187,43 +190,33 @@ func handlePrometheusRangeQueryTool(ctx context.Context, request *mcp.CallToolRe client := getHTTPClient(ctx) req, err := http.NewRequestWithContext(ctx, "GET", fullURL, nil) if err != nil { - return mcp.NewToolResultError("failed to create request: " + err.Error()), nil, nil + return mcp.TextError("failed to create request: " + err.Error()) } resp, err := client.Do(req) if err != nil { - return mcp.NewToolResultError("failed to query Prometheus: " + err.Error()), nil, nil + return mcp.TextError("failed to query Prometheus: " + err.Error()) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { - return mcp.NewToolResultError("failed to read response: " + err.Error()), nil, nil + return mcp.TextError("failed to read response: " + err.Error()) } if resp.StatusCode != http.StatusOK { - return mcp.NewToolResultError(fmt.Sprintf("Prometheus API error (%d): %s", resp.StatusCode, string(body))), nil, nil + return mcp.TextError(fmt.Sprintf("Prometheus API error (%d): %s", resp.StatusCode, string(body))) } // Parse the JSON response to pretty-print it - var result interface{} - if err := json.Unmarshal(body, &result); err != nil { - return mcp.NewToolResultText(string(body)), nil, nil - } - - prettyJSON, err := json.MarshalIndent(result, "", " ") - if err != nil { - return mcp.NewToolResultText(string(body)), nil, nil - } - - return mcp.NewToolResultText(string(prettyJSON)), nil, nil + return mcp.TextResult(prettyJSONBody(body)) } type prometheusLabelsInput struct { PrometheusURL string `json:"prometheus_url" jsonschema:"Prometheus server URL (default: http://localhost:9090)"` } -func handlePrometheusLabelsQueryTool(ctx context.Context, request *mcp.CallToolRequest, in prometheusLabelsInput) (*mcp.CallToolResult, any, error) { +func handlePrometheusLabelsQueryTool(ctx context.Context, request *mcp.CallToolRequest, in prometheusLabelsInput) (*mcp.CallToolResult, mcp.TextOutput, error) { prometheusURL := in.PrometheusURL if prometheusURL == "" { prometheusURL = "http://localhost:9090" @@ -231,7 +224,7 @@ func handlePrometheusLabelsQueryTool(ctx context.Context, request *mcp.CallToolR // Validate prometheus URL if err := security.ValidateURL(prometheusURL); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid Prometheus URL: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Invalid Prometheus URL: %v", err)) } // Make request to Prometheus API for labels @@ -243,7 +236,7 @@ func handlePrometheusLabelsQueryTool(ctx context.Context, request *mcp.CallToolR toolErr := errors.NewPrometheusError("create_request", err). WithContext("prometheus_url", prometheusURL). WithContext("api_url", apiURL) - return prometheusErrResult(toolErr), nil, nil + return prometheusErrResult(toolErr), mcp.TextOutput{}, nil } resp, err := client.Do(req) @@ -251,7 +244,7 @@ func handlePrometheusLabelsQueryTool(ctx context.Context, request *mcp.CallToolR toolErr := errors.NewPrometheusError("query_execution", err). WithContext("prometheus_url", prometheusURL). WithContext("api_url", apiURL) - return prometheusErrResult(toolErr), nil, nil + return prometheusErrResult(toolErr), mcp.TextOutput{}, nil } defer resp.Body.Close() @@ -261,7 +254,7 @@ func handlePrometheusLabelsQueryTool(ctx context.Context, request *mcp.CallToolR WithContext("prometheus_url", prometheusURL). WithContext("api_url", apiURL). WithContext("status_code", fmt.Sprintf("%d", resp.StatusCode)) - return prometheusErrResult(toolErr), nil, nil + return prometheusErrResult(toolErr), mcp.TextOutput{}, nil } if resp.StatusCode != http.StatusOK { @@ -270,28 +263,18 @@ func handlePrometheusLabelsQueryTool(ctx context.Context, request *mcp.CallToolR WithContext("api_url", apiURL). WithContext("status_code", fmt.Sprintf("%d", resp.StatusCode)). WithContext("response_body", string(body)) - return prometheusErrResult(toolErr), nil, nil + return prometheusErrResult(toolErr), mcp.TextOutput{}, nil } // Parse the JSON response to pretty-print it - var result interface{} - if err := json.Unmarshal(body, &result); err != nil { - return mcp.NewToolResultText(string(body)), nil, nil - } - - prettyJSON, err := json.MarshalIndent(result, "", " ") - if err != nil { - return mcp.NewToolResultText(string(body)), nil, nil - } - - return mcp.NewToolResultText(string(prettyJSON)), nil, nil + return mcp.TextResult(prettyJSONBody(body)) } type prometheusTargetsInput struct { PrometheusURL string `json:"prometheus_url" jsonschema:"Prometheus server URL (default: http://localhost:9090)"` } -func handlePrometheusTargetsQueryTool(ctx context.Context, request *mcp.CallToolRequest, in prometheusTargetsInput) (*mcp.CallToolResult, any, error) { +func handlePrometheusTargetsQueryTool(ctx context.Context, request *mcp.CallToolRequest, in prometheusTargetsInput) (*mcp.CallToolResult, mcp.TextOutput, error) { prometheusURL := in.PrometheusURL if prometheusURL == "" { prometheusURL = "http://localhost:9090" @@ -299,7 +282,7 @@ func handlePrometheusTargetsQueryTool(ctx context.Context, request *mcp.CallTool // Validate prometheus URL if err := security.ValidateURL(prometheusURL); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid Prometheus URL: %v", err)), nil, nil + return mcp.TextError(fmt.Sprintf("Invalid Prometheus URL: %v", err)) } // Make request to Prometheus API for targets @@ -308,36 +291,26 @@ func handlePrometheusTargetsQueryTool(ctx context.Context, request *mcp.CallTool client := getHTTPClient(ctx) req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) if err != nil { - return mcp.NewToolResultError("failed to create request: " + err.Error()), nil, nil + return mcp.TextError("failed to create request: " + err.Error()) } resp, err := client.Do(req) if err != nil { - return mcp.NewToolResultError("failed to query Prometheus: " + err.Error()), nil, nil + return mcp.TextError("failed to query Prometheus: " + err.Error()) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { - return mcp.NewToolResultError("failed to read response: " + err.Error()), nil, nil + return mcp.TextError("failed to read response: " + err.Error()) } if resp.StatusCode != http.StatusOK { - return mcp.NewToolResultError(fmt.Sprintf("Prometheus API error (%d): %s", resp.StatusCode, string(body))), nil, nil + return mcp.TextError(fmt.Sprintf("Prometheus API error (%d): %s", resp.StatusCode, string(body))) } // Parse the JSON response to pretty-print it - var result interface{} - if err := json.Unmarshal(body, &result); err != nil { - return mcp.NewToolResultText(string(body)), nil, nil - } - - prettyJSON, err := json.MarshalIndent(result, "", " ") - if err != nil { - return mcp.NewToolResultText(string(body)), nil, nil - } - - return mcp.NewToolResultText(string(prettyJSON)), nil, nil + return mcp.TextResult(prettyJSONBody(body)) } func RegisterTools(s *mcp.Server, readOnly bool) { diff --git a/pkg/prometheus/promql.go b/pkg/prometheus/promql.go index d95b3c1b..94cb1f68 100644 --- a/pkg/prometheus/promql.go +++ b/pkg/prometheus/promql.go @@ -16,15 +16,15 @@ type promqlInput struct { QueryDescription string `json:"query_description" jsonschema:"A string describing the query to generate"` } -func handlePromql(ctx context.Context, request *mcp.CallToolRequest, in promqlInput) (*mcp.CallToolResult, any, error) { +func handlePromql(ctx context.Context, request *mcp.CallToolRequest, in promqlInput) (*mcp.CallToolResult, mcp.TextOutput, error) { queryDescription := in.QueryDescription if queryDescription == "" { - return mcp.NewToolResultError("query_description is required"), nil, nil + return mcp.TextError("query_description is required") } llm, err := openai.New() if err != nil { - return mcp.NewToolResultError("failed to create LLM client: " + err.Error()), nil, nil + return mcp.TextError("failed to create LLM client: " + err.Error()) } contents := []llms.MessageContent{ @@ -45,13 +45,13 @@ func handlePromql(ctx context.Context, request *mcp.CallToolRequest, in promqlIn resp, err := llm.GenerateContent(ctx, contents, llms.WithModel("gpt-4o-mini")) if err != nil { - return mcp.NewToolResultError("failed to generate content: " + err.Error()), nil, nil + return mcp.TextError("failed to generate content: " + err.Error()) } choices := resp.Choices if len(choices) < 1 { - return mcp.NewToolResultError("empty response from model"), nil, nil + return mcp.TextError("empty response from model") } c1 := choices[0] - return mcp.NewToolResultText(c1.Content), nil, nil + return mcp.TextResult(c1.Content) } diff --git a/pkg/utils/common.go b/pkg/utils/common.go index f45e19f4..fa3f4e70 100644 --- a/pkg/utils/common.go +++ b/pkg/utils/common.go @@ -82,17 +82,17 @@ func shellTool(ctx context.Context, params shellParams) (string, error) { return commands.NewCommandBuilder(cmd).WithArgs(args...).Execute(ctx) } -func handleShellTool(ctx context.Context, request *mcp.CallToolRequest, in shellParams) (*mcp.CallToolResult, any, error) { +func handleShellTool(ctx context.Context, request *mcp.CallToolRequest, in shellParams) (*mcp.CallToolResult, mcp.TextOutput, error) { if in.Command == "" { - return mcp.NewToolResultError("command parameter is required"), nil, nil + return mcp.TextError("command parameter is required") } result, err := shellTool(ctx, in) if err != nil { - return mcp.NewToolResultError(err.Error()), nil, nil + return mcp.TextError(err.Error()) } - return mcp.NewToolResultText(result), nil, nil + return mcp.TextResult(result) } func handleMCPInspectTool(_ context.Context, request *mcp.CallToolRequest, in inspectInput) (*mcp.CallToolResult, *inspectOutput, error) { @@ -103,7 +103,7 @@ func handleMCPInspectTool(_ context.Context, request *mcp.CallToolRequest, in in payload, err := json.MarshalIndent(output, "", " ") if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to render inspect output: %v", err)), nil, nil + return mcp.NewToolResultError(fmt.Sprintf("failed to render inspect output: %v", err)), output, nil } return mcp.NewToolResultText(string(payload)), output, nil @@ -138,11 +138,11 @@ func inspectHeaders(headers http.Header) []inspectHeader { type datetimeInput struct{} // handleGetCurrentDateTimeTool provides datetime functionality for both MCP and testing -func handleGetCurrentDateTimeTool(ctx context.Context, request *mcp.CallToolRequest, in datetimeInput) (*mcp.CallToolResult, any, error) { +func handleGetCurrentDateTimeTool(ctx context.Context, request *mcp.CallToolRequest, in datetimeInput) (*mcp.CallToolResult, mcp.TextOutput, error) { // Returns the current date and time in ISO 8601 format (RFC3339) // This matches the Python implementation: datetime.datetime.now().isoformat() now := time.Now() - return mcp.NewToolResultText(now.Format(time.RFC3339)), nil, nil + return mcp.TextResult(now.Format(time.RFC3339)) } func RegisterTools(s *mcp.Server, readOnly bool) { diff --git a/test/e2e/helpers_test.go b/test/e2e/helpers_test.go index 901bac3f..8918a2fe 100644 --- a/test/e2e/helpers_test.go +++ b/test/e2e/helpers_test.go @@ -296,7 +296,7 @@ func GetMCPClient() (*MCPClient, error) { } // listTools calls the tools/list method to get available tools -func (c *MCPClient) listTools() ([]interface{}, error) { +func (c *MCPClient) listTools() ([]*mcp.Tool, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -305,16 +305,11 @@ func (c *MCPClient) listTools() ([]interface{}, error) { return nil, err } - tools := make([]interface{}, len(result.Tools)) - for i, tool := range result.Tools { - tools[i] = tool - } - - return tools, nil + return result.Tools, nil } // k8sListResources calls the k8s_get_resources tool -func (c *MCPClient) k8sListResources(resourceType string) (interface{}, error) { +func (c *MCPClient) k8sListResources(resourceType string) (*mcp.CallToolResult, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -342,7 +337,7 @@ func (c *MCPClient) k8sListResources(resourceType string) (interface{}, error) { } // helmListReleases calls the helm_list_releases tool -func (c *MCPClient) helmListReleases() (interface{}, error) { +func (c *MCPClient) helmListReleases() (*mcp.CallToolResult, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -370,7 +365,7 @@ func (c *MCPClient) helmListReleases() (interface{}, error) { } // istioInstall calls the istio_install_istio tool -func (c *MCPClient) istioInstall(profile string) (interface{}, error) { +func (c *MCPClient) istioInstall(profile string) (*mcp.CallToolResult, error) { ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) // Istio install can take time defer cancel() @@ -396,7 +391,7 @@ func (c *MCPClient) istioInstall(profile string) (interface{}, error) { } // argoRolloutsList calls the argo_rollouts_get tool to list rollouts -func (c *MCPClient) argoRolloutsList(namespace string) (interface{}, error) { +func (c *MCPClient) argoRolloutsList(namespace string) (*mcp.CallToolResult, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -424,7 +419,7 @@ func (c *MCPClient) argoRolloutsList(namespace string) (interface{}, error) { } // ciliumStatus calls the cilium_status_and_version tool -func (c *MCPClient) ciliumStatus() (interface{}, error) { +func (c *MCPClient) ciliumStatus() (*mcp.CallToolResult, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() From 379728975d279c78271a69202a82bd12270f66f5 Mon Sep 17 00:00:00 2001 From: Dmytro Rashko Date: Mon, 21 Sep 2026 07:44:11 +0200 Subject: [PATCH 09/21] ci: pin Go toolchain from go.mod instead of a stale version spec Both Go jobs pinned `go-version: '^1.26.1'`, which resolves to 1.26.8, but go.mod requires go >= 1.27.0. actions/setup-go@v6 also sets GOTOOLCHAIN=local (an intentional change in setup-go 1d76b95), so the toolchain is never auto-downloaded and the job dies during `make test` -> `make build`: go: go.mod requires go >= 1.27.0 (running go 1.26.8; GOTOOLCHAIN=local) This failed before a single test ran, in both go-unit-tests and go-e2e-tests. The `build` job was unaffected because the Dockerfile uses chainguard/go:latest, which already ships 1.27. Use `go-version-file: 'go.mod'` so CI tracks the module's own Go directive and cannot drift when the directive is bumped again. The Docker build and the lint config (pinned for the go 1.27 directive) were already consistent with 1.27; only the two setup-go pins were stale. Also correct the Go version listed in the AGENTS.md repository tree (1.25.6 -> 1.27.0), which is the same drift that caused this. Verified under the exact CI condition (GOTOOLCHAIN=local, go.mod at 1.27.0): go build ./..., go vet ./... and go test ./pkg/... ./internal/... ./cmd/... all pass, and make lint reports 0 issues. Reproduced the original failure as a control by running go 1.26.8 with GOTOOLCHAIN=local against the 1.27.0 directive. Signed-off-by: Dmytro Rashko --- .github/workflows/ci.yaml | 4 ++-- AGENTS.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 819a56eb..b9cbbe17 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -47,7 +47,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: '^1.26.1' + go-version-file: 'go.mod' cache: false - name: Run cmd/main.go tests @@ -64,7 +64,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: '^1.26.1' + go-version-file: 'go.mod' cache: false - name: Create k8s Kind Cluster diff --git a/AGENTS.md b/AGENTS.md index f91b78c7..cdcd1abd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,7 +73,7 @@ tools/ │ └── tag.yaml # Release tagging ├── Makefile # Build orchestration ├── Dockerfile # Multi-stage build (multi-arch) -├── go.mod # Go 1.25.6 +├── go.mod # Go 1.27.0 ├── DEVELOPMENT.md # Development setup and standards └── CONTRIBUTION.md # Contribution process ``` From da4f1b8c221e5040e00b19da4e8c03405e6045af Mon Sep 17 00:00:00 2001 From: Dmytro Rashko Date: Mon, 21 Sep 2026 10:54:48 +0200 Subject: [PATCH 10/21] test(e2e): type e2e assertions and fix setup races The e2e suite reported success without exercising the migrated behaviour: three specs skipped silently, a helper swallowed tool errors, and setup raced kube-proxy. Fix the harness and assert the typed-output contract end to end. Assertions now decode structuredContent into the shared mcp.TextOutput DTO via decodeTextOutput, so a handler regressing to Out=any fails the suite. Applied to the k8s, helm, istio, cilium and argo specs; a new istio_version spec runs against the installed control plane. Bugs fixed: - ciliumStatus was the only MCP helper that did not check result.IsError, so its spec passed even when the tool returned an error. It now returns an error. - The helm spec never ran: helmListReleases sent all_namespaces as the string "true" while the tool declares a boolean, so every call failed input validation and the spec skipped itself. It now sends a bool. - InstallKAgentTools waited on pod status.phase only. phase=Running precedes the readiness probe (initialDelaySeconds=15), so GetMCPClient could connect to a server that had not begun serving. It now waits for the Ready condition and probes the NodePort before the suite starts. - CreateNamespace/DeleteNamespace ignored in-flight namespace deletion, so consecutive runs failed with "unable to create new content ... because it is being terminated". Both now wait for the namespace to settle. Note --ignore-not-found exits 0 for a missing namespace, so the waits key on empty output rather than on an error. - GetMCPClient retries the initialize handshake, because the NodePort resets connections until kube-proxy programs the new endpoint. - The helm install context (120s) was shorter than helm's own timeout, killing helm with "signal: killed"; the context now outlives it, and --timeout is 3m to clear the readiness delay. Adds an opt-in Cilium lifecycle spec (E2E_CILIUM_LIFECYCLE=true) that installs Cilium through cilium_install_cilium, verifies status, then uninstalls through cilium_uninstall_cilium. It is opt-in because it replaces the cluster CNI, which Kind does not use by default and CI cannot tolerate. Installing a CNI resets pod networking on the node and drops the pod's own long-lived MCP session, so the spec reconnects afterwards. The uninstall runs inside the It rather than DeferCleanup: the ordered container's AfterAll deletes the namespace first, leaving no server to call. A host-side DeferCleanup removes a leaked DaemonSet so a failure cannot poison later runs. Also set tools.metrics.port=8085 in test-values-e2e.yaml. Without it the deployment template emits containerPort 8084 twice and Helm 4 rejects the manifest with "duplicate entries for key [containerPort=8084]", which broke every deploy. Verified locally on a Kind cluster with the repo's NodePort mappings: - default suite: 24 passed, 0 failed, 2 skipped (the two Cilium specs) - with E2E_CILIUM_LIFECYCLE=true: 25 passed, 0 failed, 1 skipped, Cilium installed and then uninstalled, nothing leaked - 3 consecutive default runs passed, where the same sequence previously failed roughly half the time - gofmt and go vet clean; unit tests unaffected (19/19 packages pass) Signed-off-by: Dmytro Rashko --- scripts/kind/test-values-e2e.yaml | 5 + test/e2e/helpers_test.go | 242 +++++++++++++++++++++++++----- test/e2e/k8s_test.go | 133 +++++++++++++++- 3 files changed, 338 insertions(+), 42 deletions(-) diff --git a/scripts/kind/test-values-e2e.yaml b/scripts/kind/test-values-e2e.yaml index 9460dde2..28d52be3 100644 --- a/scripts/kind/test-values-e2e.yaml +++ b/scripts/kind/test-values-e2e.yaml @@ -7,6 +7,11 @@ service: tools: image: registry: cr.kagent.dev + metrics: + # Must differ from service.ports.tools.targetPort (8084): the deployment + # template declares both the tools port and the metrics port, and Helm 4 + # rejects the resulting duplicate containerPort entries. + port: "8085" otel: tracing: diff --git a/test/e2e/helpers_test.go b/test/e2e/helpers_test.go index 8918a2fe..3f5077b5 100644 --- a/test/e2e/helpers_test.go +++ b/test/e2e/helpers_test.go @@ -3,6 +3,7 @@ package e2e import ( "bufio" "context" + "encoding/json" "fmt" "io" "log/slog" @@ -15,6 +16,7 @@ import ( "time" "github.com/kagent-dev/tools/internal/commands" + toolsmcp "github.com/kagent-dev/tools/internal/mcp" mcp "github.com/modelcontextprotocol/go-sdk/mcp" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -187,8 +189,10 @@ type MCPClient struct { // InstallKAgentTools installs KAgent Tools using helm in the specified namespace func InstallKAgentTools(namespace string, releaseName string) { - // Use longer timeout for helm installation as it can take time to pull images - ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + // The context must outlive helm's own --timeout below, otherwise the context + // cancels first and helm is killed with "signal: killed" rather than being + // allowed to report its real status. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() log := slog.Default() @@ -215,6 +219,11 @@ func InstallKAgentTools(namespace string, releaseName string) { // Install KAgent Tools using helm with unique release name // Use absolute path from project root + // + // --timeout must comfortably exceed the readiness probe's initialDelaySeconds + // (15s) plus image pull and scheduling. The previous 1m expired with + // "resource Deployment ... not ready: Available: 0/1" whenever the node was + // busy, failing BeforeAll before any spec ran. output, err := commands.NewCommandBuilder("helm"). WithArgs("install", releaseName, "../../helm/kagent-tools"). WithArgs("--namespace", namespace). @@ -223,15 +232,20 @@ func InstallKAgentTools(namespace string, releaseName string) { WithArgs("--create-namespace"). WithArgs("--debug"). WithArgs("--wait"). - WithArgs("--timeout=1m"). + WithArgs("--timeout=3m"). WithCache(false). // Don't cache helm installation Execute(ctx) Expect(err).ToNot(HaveOccurred(), "Failed to install KAgent Tools: %v %v", err, output) log.Info("KAgent Tools installation completed", "namespace", namespace, "output", output) - // Verify the installation by checking if pods are running - By("Verifying KAgent Tools pods are running") + // Verify the installation by checking that pods are Running AND Ready. + // Waiting on status.phase alone is racy: the container reports Running + // immediately, but the server only starts serving /health and /mcp once the + // readiness probe passes (initialDelaySeconds=15), so an MCP client that + // connects in between gets "connection reset by peer". Gate on the + // Ready condition instead. + By("Verifying KAgent Tools pods are ready") log.Info("Verifying KAgent Tools pods", "namespace", namespace) Eventually(func() bool { @@ -239,20 +253,32 @@ func InstallKAgentTools(namespace string, releaseName string) { defer cancel() output, err := commands.NewCommandBuilder("kubectl"). - WithArgs("get", "pods", "-n", namespace, "-l", "app.kubernetes.io/instance="+releaseName, "-o", "jsonpath={.items[*].status.phase}"). + WithArgs("get", "pods", "-n", namespace, "-l", "app.kubernetes.io/instance="+releaseName, + "-o", "jsonpath={.items[*].status.conditions[?(@.type=='Ready')].status}"). + WithCache(false). Execute(ctx) if err != nil { - log.Error("Failed to get pod status", "error", err) + log.Error("Failed to get pod readiness", "error", err) return false } - log.Info("Pod status check", "namespace", namespace, "output", output) - // Check if all pods are in Running state - return output == "Running" || (len(output) > 0 && !contains(output, "Pending") && !contains(output, "Failed")) - }, 60*time.Second, 5*time.Second).Should(BeTrue(), "KAgent Tools pods should be running") + log.Info("Pod readiness check", "namespace", namespace, "output", output) - log.Info("KAgent Tools pods are running", "namespace", namespace) + // Every pod must report Ready=True; an empty list means no pods yet. + statuses := strings.Fields(strings.TrimSpace(output)) + if len(statuses) == 0 { + return false + } + for _, status := range statuses { + if status != "True" { + return false + } + } + return true + }, 2*time.Minute, 5*time.Second).Should(BeTrue(), "KAgent Tools pods should become ready") + + log.Info("KAgent Tools pods are ready", "namespace", namespace) //validate service nodePort == 30885 By("Validating KAgent Tools service is accessible") nodePort, err := commands.NewCommandBuilder("kubectl"). @@ -260,10 +286,59 @@ func InstallKAgentTools(namespace string, releaseName string) { Execute(ctx) Expect(err).ToNot(HaveOccurred(), "Failed to get service nodePort: %v", err) Expect(nodePort).To(Equal("30885")) + + // A Ready pod does not guarantee the NodePort is routable yet: kube-proxy + // still has to program the new endpoint into the node's rules, and until it + // does the NodePort answers with a connection reset. Probe it cheaply before + // the suite starts, and keep the retry in GetMCPClient as well, so neither + // side races kube-proxy. + By("Waiting for the MCP endpoint to answer over the NodePort") + Eventually(func() bool { + probeCtx, probeCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer probeCancel() + + req, err := http.NewRequestWithContext(probeCtx, http.MethodPost, + "http://127.0.0.1:30885/mcp", strings.NewReader("{}")) + if err != nil { + return false + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + + resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req) + if err != nil { + log.Info("MCP endpoint not routable yet", "error", err) + return false + } + defer func() { _ = resp.Body.Close() }() + // Any HTTP response (even a 400 for the malformed body) proves the + // NodePort is programmed and reaching the server. + return resp.StatusCode > 0 + }, 2*time.Minute, 3*time.Second).Should(BeTrue(), + "MCP endpoint did not become reachable on NodePort 30885") } -// GetMCPClient creates a new MCP client configured for the e2e test environment using the official go-sdk client +// GetMCPClient creates a new MCP client configured for the e2e test environment +// using the official go-sdk client. The initialize handshake is retried because +// the NodePort can briefly reset connections while kube-proxy programs the +// Service endpoint after a rollout. func GetMCPClient() (*MCPClient, error) { + deadline := time.Now().Add(90 * time.Second) + var lastErr error + + for time.Now().Before(deadline) { + client, err := connectMCPClient() + if err == nil { + return client, nil + } + lastErr = err + time.Sleep(2 * time.Second) + } + return nil, fmt.Errorf("failed to connect MCP client after retries: %w", lastErr) +} + +// connectMCPClient performs a single MCP connect + initialize handshake. +func connectMCPClient() (*MCPClient, error) { // HTTP timeout long enough for operations like Istio installation. httpTransport := &mcp.StreamableClientTransport{ Endpoint: "http://127.0.0.1:30885/mcp", @@ -295,6 +370,26 @@ func GetMCPClient() (*MCPClient, error) { return mcpHelper, err } +// callTool invokes any MCP tool by name with typed arguments and returns the +// raw result. It does not treat a tool-level error (IsError) as a Go error, so +// callers can assert on either outcome; use it with ExpectMCPToolSuccess when a +// spec requires a successful call. +func (c *MCPClient) callTool(name string, args any) (*mcp.CallToolResult, error) { + return c.callToolWithTimeout(name, args, 60*time.Second) +} + +// callToolWithTimeout is callTool with an explicit timeout, for slow tools such +// as istio_install_istio or cilium_install_cilium. +func (c *MCPClient) callToolWithTimeout(name string, args any, timeout time.Duration) (*mcp.CallToolResult, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + return c.session.CallTool(ctx, &mcp.CallToolParams{ + Name: name, + Arguments: args, + }) +} + // listTools calls the tools/list method to get available tools func (c *MCPClient) listTools() ([]*mcp.Tool, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) @@ -341,13 +436,16 @@ func (c *MCPClient) helmListReleases() (*mcp.CallToolResult, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() + // all_namespaces is declared as a boolean in the tool's input schema, so it + // must be sent as a JSON boolean. Sending the string "true" fails input + // validation and the tool never executes. type HelmArgs struct { - AllNamespaces string `json:"all_namespaces"` + AllNamespaces bool `json:"all_namespaces"` Output string `json:"output"` } arguments := HelmArgs{ - AllNamespaces: "true", + AllNamespaces: true, Output: "json", } @@ -430,9 +528,66 @@ func (c *MCPClient) ciliumStatus() (*mcp.CallToolResult, error) { if err != nil { return nil, err } + if result.IsError { + return nil, fmt.Errorf("tool call failed: %s", toolResultText(result)) + } return result, nil } +// TextOutputArgs mirrors internal/mcp.TextOutput so e2e assertions decode the +// same DTO the server produces for raw CLI text tools. +type TextOutputArgs = toolsmcp.TextOutput + +// decodeTextOutput decodes a tool result's typed structuredContent into the +// shared TextOutput DTO. Every migrated handler returns a concrete Out type, so +// the SDK must populate StructuredContent; a nil value means a handler regressed +// to Out=any and the typed-output contract is broken. +func decodeTextOutput(result *mcp.CallToolResult) (TextOutputArgs, error) { + var out TextOutputArgs + if result == nil { + return out, fmt.Errorf("nil tool result") + } + if result.StructuredContent == nil { + return out, fmt.Errorf("result has no structuredContent: handler did not return a typed Out value") + } + raw, err := json.Marshal(result.StructuredContent) + if err != nil { + return out, fmt.Errorf("marshaling structuredContent: %w", err) + } + if err := json.Unmarshal(raw, &out); err != nil { + return out, fmt.Errorf("decoding structuredContent into TextOutput: %w", err) + } + return out, nil +} + +// clusterHasCilium reports whether Cilium is installed as a DaemonSet. The Kind +// cluster uses kindnet by default, so Cilium-backed tools are unavailable unless +// a test installed it; specs that need it must skip rather than fail. +func clusterHasCilium() bool { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + output, err := commands.NewCommandBuilder("kubectl"). + WithArgs("get", "daemonset", "cilium", "-n", "kube-system", + "--ignore-not-found", "-o", "jsonpath={.metadata.name}"). + WithCache(false). + Execute(ctx) + return err == nil && strings.TrimSpace(output) == "cilium" +} + +// clusterHasIstio reports whether Istio's control plane is installed. +func clusterHasIstio() bool { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + output, err := commands.NewCommandBuilder("kubectl"). + WithArgs("get", "deployment", "istiod", "-n", "istio-system", + "--ignore-not-found", "-o", "jsonpath={.metadata.name}"). + WithCache(false). + Execute(ctx) + return err == nil && strings.TrimSpace(output) == "istiod" +} + // Constants for default test values const ( DefaultReleaseName = "kagent-tools-e2e" @@ -449,16 +604,25 @@ func CreateNamespace(namespace string) { By("Creating namespace " + namespace) log.Info("Creating namespace", "namespace", namespace) - // First, check if the namespace already exists - _, err := commands.NewCommandBuilder("kubectl"). - WithArgs("get", "namespace", namespace). - WithCache(false). - Execute(ctx) + // A namespace left over from a previous run may still be terminating + // (DeleteNamespace issues the delete without waiting). Creating resources in + // a terminating namespace fails with "unable to create new content ... + // because it is being terminated", so wait for the old one to disappear + // before deciding whether creation is needed. + // + // Note: --ignore-not-found makes kubectl exit 0 even when the namespace is + // absent, so the wait must key on empty output rather than on an error. + Eventually(func() bool { + checkCtx, checkCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer checkCancel() - if err == nil { - log.Info("Namespace already exists, skipping creation", "namespace", namespace) - return - } + output, err := commands.NewCommandBuilder("kubectl"). + WithArgs("get", "namespace", namespace, "--ignore-not-found", "-o", "jsonpath={.metadata.name}"). + WithCache(false). + Execute(checkCtx) + return err == nil && strings.TrimSpace(output) == "" + }, 2*time.Minute, 2*time.Second).Should(BeTrue(), + "namespace %s did not finish terminating before test setup", namespace) // Create the namespace using kubectl output, err := commands.NewCommandBuilder("kubectl"). @@ -476,7 +640,8 @@ func CreateNamespace(namespace string) { log.Info("Namespace creation completed", "namespace", namespace, "output", output) } -// DeleteNamespace deletes a Kubernetes namespace +// DeleteNamespace deletes a Kubernetes namespace and waits for it to be fully +// removed, so a subsequent run can recreate it immediately. func DeleteNamespace(namespace string) { // Use longer timeout for namespace deletion as it can take more time ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) @@ -494,20 +659,23 @@ func DeleteNamespace(namespace string) { Expect(err).ToNot(HaveOccurred(), "Failed to delete namespace: %v", err) log.Info("Namespace deletion completed", "namespace", namespace, "output", output) -} -// contains checks if a string contains a substring -func contains(s, substr string) bool { - return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr || containsHelper(s, substr))) -} + // Wait until the namespace is actually gone. Without this the next test run + // can attempt to create resources in a still-terminating namespace and fail + // with "unable to create new content ... because it is being terminated". + // As above, --ignore-not-found returns exit 0 for a missing namespace, so + // the wait keys on empty output. + Eventually(func() bool { + checkCtx, checkCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer checkCancel() -func containsHelper(s, substr string) bool { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return true - } - } - return false + output, err := commands.NewCommandBuilder("kubectl"). + WithArgs("get", "namespace", namespace, "--ignore-not-found", "-o", "jsonpath={.metadata.name}"). + WithCache(false). + Execute(checkCtx) + return err == nil && strings.TrimSpace(output) == "" + }, 2*time.Minute, 2*time.Second).Should(BeTrue(), + "namespace %s was not removed", namespace) } // waitForHTTPServer waits for the HTTP server to become available diff --git a/test/e2e/k8s_test.go b/test/e2e/k8s_test.go index e90b6ddb..2e2babf4 100644 --- a/test/e2e/k8s_test.go +++ b/test/e2e/k8s_test.go @@ -3,6 +3,9 @@ package e2e import ( "context" "fmt" + "os" + "time" + "github.com/kagent-dev/tools/internal/commands" "github.com/kagent-dev/tools/internal/logger" . "github.com/onsi/ginkgo/v2" @@ -88,6 +91,13 @@ var _ = Describe("KAgent Tools Kubernetes E2E Tests", Ordered, func() { response, err := client.k8sListResources("namespace") Expect(err).ToNot(HaveOccurred(), "Failed to list k8s resources via MCP: %v", err) Expect(response).ToNot(BeNil()) + Expect(response.IsError).To(BeFalse(), "k8s_get_resources returned a tool error: %s", toolResultText(response)) + + // The migrated handler returns a typed Out value, so the SDK must + // populate StructuredContent. A missing value means Out=any regressed. + output, err := decodeTextOutput(response) + Expect(err).ToNot(HaveOccurred(), "k8s_get_resources did not return typed output: %v", err) + Expect(output.Output).ToNot(BeEmpty(), "k8s_get_resources returned empty output") log.Info("Successfully tested k8s operations via MCP", "namespace", namespace) }) @@ -106,6 +116,11 @@ var _ = Describe("KAgent Tools Kubernetes E2E Tests", Ordered, func() { return } Expect(response).ToNot(BeNil()) + + output, err := decodeTextOutput(response) + Expect(err).ToNot(HaveOccurred(), "helm_list_releases did not return typed output: %v", err) + Expect(output.Output).ToNot(BeEmpty(), "helm_list_releases returned empty output") + log.Info("Successfully tested helm operations via MCP", "namespace", namespace) }) }) @@ -119,19 +134,50 @@ var _ = Describe("KAgent Tools Kubernetes E2E Tests", Ordered, func() { Expect(err).ToNot(HaveOccurred(), "Failed to install istio via MCP: %v", err) Expect(response).ToNot(BeNil()) - log.Info("Successfully tested istio operations via MCP", "namespace", namespace, "response", response) + // istioctl install exits 0 when Istio is already installed, so this + // asserts the tool reached the CLI rather than that it changed state. + Expect(response.IsError).To(BeFalse(), "istio_install_istio returned a tool error: %s", toolResultText(response)) + + output, err := decodeTextOutput(response) + Expect(err).ToNot(HaveOccurred(), "istio_install_istio did not return typed output: %v", err) + + log.Info("Successfully tested istio operations via MCP", "namespace", namespace, "response", output.Output) + }) + + It("should report the installed istio version through the MCP tool", func() { + if !clusterHasIstio() { + Skip("Istio control plane (istiod) not installed; no MCP tool exists to install it in CI") + } + + response, err := client.callTool("istio_version", struct{}{}) + Expect(err).ToNot(HaveOccurred(), "istio_version call failed: %v", err) + Expect(response.IsError).To(BeFalse(), "istio_version returned a tool error: %s", toolResultText(response)) + + output, err := decodeTextOutput(response) + Expect(err).ToNot(HaveOccurred(), "istio_version did not return typed output: %v", err) + Expect(output.Output).To(ContainSubstring("version"), "istio_version output should name a version") }) }) Describe("KAgent Tools Cilium Operations", func() { - It("should be able to install cilium in the cluster", func() { - log.Info("Testing cilium operations via MCP", "namespace", namespace) + It("should report cilium status through the MCP tool", func() { + // The Kind cluster uses kindnet by default, so Cilium is only present + // if the lifecycle spec below (or an operator) installed it. Without + // Cilium the tool correctly returns a tool error, which is not a + // failure of this suite -- so skip instead of asserting success. + if !clusterHasCilium() { + Skip("Cilium is not installed in this cluster (Kind uses kindnet); run the Cilium lifecycle spec to cover it") + } - // If we get here, MCP is accessible, test cilium operations + log.Info("Testing cilium operations via MCP", "namespace", namespace) response, err := client.ciliumStatus() Expect(err).ToNot(HaveOccurred(), "Failed to get cilium status via MCP: %v", err) Expect(response).ToNot(BeNil()) + output, err := decodeTextOutput(response) + Expect(err).ToNot(HaveOccurred(), "cilium_status_and_version did not return typed output: %v", err) + Expect(output.Output).ToNot(BeEmpty(), "cilium_status_and_version returned empty output") + log.Info("Successfully tested cilium operations via MCP", "namespace", namespace) }) }) @@ -140,12 +186,89 @@ var _ = Describe("KAgent Tools Kubernetes E2E Tests", Ordered, func() { It("should be able to list Argo rollouts in the cluster", func() { log.Info("Testing Argo operations via MCP", "namespace", namespace) - // If we get here, MCP is accessible, test cilium operations + // If we get here, MCP is accessible, test argo operations response, err := client.argoRolloutsList(namespace) Expect(err).ToNot(HaveOccurred(), "Failed to list argo rollouts via MCP: %v", err) Expect(response).ToNot(BeNil()) + // argo_rollouts_list legitimately reports "No resources found." as a + // successful result when nothing is installed, so assert the typed + // contract rather than specific content. + output, err := decodeTextOutput(response) + Expect(err).ToNot(HaveOccurred(), "argo_rollouts_list did not return typed output: %v", err) + Expect(output.Output).ToNot(BeEmpty(), "argo_rollouts_list returned empty output") + log.Info("Successfully tested argo rollouts via MCP", "namespace", namespace) }) }) + + // The Cilium lifecycle mutates cluster-wide networking: it installs a CNI. + // It is opt-in via E2E_CILIUM_LIFECYCLE=true so the default suite (and CI, + // whose Kind cluster uses kindnet) never touches pod networking. + Describe("KAgent Tools Cilium Lifecycle", Label("cilium-lifecycle"), func() { + It("should install, report status, list endpoints, and uninstall cilium via MCP", func() { + if os.Getenv("E2E_CILIUM_LIFECYCLE") != "true" { + Skip("set E2E_CILIUM_LIFECYCLE=true to run the Cilium install/uninstall lifecycle") + } + if clusterHasCilium() { + Skip("Cilium is already installed; refusing to modify existing cluster networking") + } + + // Install through the MCP tool. Cilium is long to converge, so allow + // a generous timeout for the CLI call itself. + By("installing Cilium via the cilium_install_cilium tool") + installResult, err := client.callToolWithTimeout("cilium_install_cilium", + struct { + DatapathMode string `json:"datapath_mode"` + }{DatapathMode: "native"}, 5*time.Minute) + Expect(err).ToNot(HaveOccurred(), "cilium_install_cilium call failed: %v", err) + Expect(installResult.IsError).To(BeFalse(), + "cilium_install_cilium returned a tool error: %s", toolResultText(installResult)) + + // Installing via the tool must actually create the DaemonSet. + Eventually(clusterHasCilium, 3*time.Minute, 5*time.Second). + Should(BeTrue(), "cilium DaemonSet was not created by cilium_install_cilium") + + // Uninstall even if an assertion below fails, so the CNI is not left + // half-installed on the cluster. Failures here must be loud: silently + // skipping verification would leak a CNI into the cluster and then + // make every later run skip this spec ("already installed"). + DeferCleanup(func() { + By("uninstalling Cilium via the cilium_uninstall_cilium tool") + // Connect fresh: installing Cilium replaces the CNI, which + // resets pod networking on this node and drops the original + // MCP session. + uninstallClient, err := GetMCPClient() + Expect(err).ToNot(HaveOccurred(), "failed to reconnect for Cilium uninstall: %v", err) + + uninstallResult, err := uninstallClient.callToolWithTimeout("cilium_uninstall_cilium", struct{}{}, 3*time.Minute) + Expect(err).ToNot(HaveOccurred(), "cilium_uninstall_cilium call failed: %v", err) + Expect(uninstallResult).ToNot(BeNil()) + Expect(uninstallResult.IsError).To(BeFalse(), + "cilium_uninstall_cilium returned a tool error: %s", toolResultText(uninstallResult)) + + Eventually(clusterHasCilium, 3*time.Minute, 5*time.Second). + Should(BeFalse(), "cilium DaemonSet still present after cilium_uninstall_cilium") + }) + + // Installing Cilium replaces the cluster CNI and briefly resets pod + // networking, which drops the long-lived MCP session established in + // BeforeAll. Reconnect before driving further tools. + By("reconnecting the MCP session after the CNI switch") + client, err = GetMCPClient() + Expect(err).ToNot(HaveOccurred(), "failed to reconnect after Cilium install: %v", err) + + By("reporting Cilium status via the cilium_status_and_version tool") + statusResult, err := client.callToolWithTimeout("cilium_status_and_version", struct{}{}, 2*time.Minute) + Expect(err).ToNot(HaveOccurred(), "cilium_status_and_version call failed: %v", err) + Expect(statusResult.IsError).To(BeFalse(), + "cilium_status_and_version returned a tool error: %s", toolResultText(statusResult)) + + statusOutput, err := decodeTextOutput(statusResult) + Expect(err).ToNot(HaveOccurred(), "cilium_status_and_version did not return typed output: %v", err) + Expect(statusOutput.Output).ToNot(BeEmpty(), "cilium status output should not be empty") + + log.Info("Successfully exercised the Cilium install/status/uninstall lifecycle via MCP") + }) + }) }) From 5ae1ea4e7597ff39bf45f620350813bdd678c67c Mon Sep 17 00:00:00 2001 From: Dmytro Rashko Date: Mon, 21 Sep 2026 11:39:06 +0200 Subject: [PATCH 11/21] test(e2e): sweep every read-only tool for typed-output conformance The suite previously touched about 8 of the 126 advertised tools. Add a data-driven sweep that drives all 80 tools the server registers in read-only mode and asserts two invariants for each: - the call completes without a protocol/transport error, and - the result honours the typed-output contract: a success carries structuredContent (so a handler regressing to Out=any fails the suite), and a failure reports IsError with a readable message rather than breaking transport. The read-only set is taken from the providers themselves: the sweep registers argo, cilium, helm, istio, k8s, kubescape, prometheus and utils with readOnly=true and uses the resulting tool list as the safety rail. No name pattern guessing, and a write-capable tool cannot be reached from the sweep's invocation list. This matters because an earlier attempt at a regex classification wrongly flagged helm_repo_update, which the server registers read-only (it only refreshes the local chart cache). Tools needing identifiers that exist only alongside a live dependency (Cilium endpoint/service/recorder IDs, an existing Helm release, a vulnerability manifest name) are enumerated with an explicit skip reason rather than called with invented arguments, which would only re-test input validation. Tools whose backing dependency is absent on a Kind cluster (Cilium on kindnet, no Prometheus server, no Kubescape operator) legitimately answer with a tool error; the sweep records those instead of failing, so the run reports ok=24 toolerr=32 skipped=24 for 80 covered tools. The sweep runs inside the ordered k8s container, before AfterAll deletes the namespace, so the deployed server is still reachable. Also fixes a bug introduced earlier with the namespace race fix: CreateNamespace waited for the namespace to be absent, but a healthy namespace left over from a previous run must be reused, not waited out. It now waits for the namespace to be usable (gone, or present and Active). DeleteNamespace no longer blocks, since deletion may linger on CRD finalizers and would turn a slow finalizer into an AfterAll failure; CreateNamespace is the only place the wait matters. Verified on a local Kind cluster with the repo's NodePort mappings: the suite passes 26 specs (0 failed) and twice consecutively; gofmt and go vet are clean and unit tests still pass 19/19 packages. Signed-off-by: Dmytro Rashko --- test/e2e/coverage_test.go | 401 ++++++++++++++++++++++++++++++++++++++ test/e2e/helpers_test.go | 43 ++-- test/e2e/k8s_test.go | 62 ++++-- 3 files changed, 462 insertions(+), 44 deletions(-) create mode 100644 test/e2e/coverage_test.go diff --git a/test/e2e/coverage_test.go b/test/e2e/coverage_test.go new file mode 100644 index 00000000..05375888 --- /dev/null +++ b/test/e2e/coverage_test.go @@ -0,0 +1,401 @@ +package e2e + +import ( + "context" + "encoding/json" + "sort" + "strings" + "time" + + "github.com/kagent-dev/tools/internal/commands" + "github.com/kagent-dev/tools/pkg/argo" + "github.com/kagent-dev/tools/pkg/cilium" + "github.com/kagent-dev/tools/pkg/helm" + "github.com/kagent-dev/tools/pkg/istio" + "github.com/kagent-dev/tools/pkg/k8s" + "github.com/kagent-dev/tools/pkg/kubescape" + "github.com/kagent-dev/tools/pkg/prometheus" + "github.com/kagent-dev/tools/pkg/utils" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + mcp "github.com/modelcontextprotocol/go-sdk/mcp" + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" +) + +/* +Tool Coverage Sweep + +Drives every read-only tool the deployed server advertises, so the suite covers +the whole tool surface rather than a handful of hand-picked tools. + +Asserted for every invoked tool: + 1. the call completes without a protocol/transport error, and + 2. the result honours the typed-output contract: a success carries + structuredContent that decodes into the shared TextOutput DTO (a handler + regressing to Out=any would produce none), while a failure reports IsError + with a readable message. + +Write-guarded tools are deliberately NOT invoked: they mutate the cluster, and +calling one by accident is exactly the class of bug this sweep must catch. Their +registration is covered by TestEveryToolHasValidOutputSchema and +TestNoToolNameRegressions. + +Tools whose backing dependency is absent (Cilium on a kindnet cluster, a +Prometheus server, the Kubescape operator) legitimately answer with a tool +error or are skipped with a reason; both outcomes are recorded, not failed. +*/ + +// ReadOnlyTools returns the tool names the server registers in read-only mode, +// taken from the providers themselves rather than guessed from name patterns. +// Anything outside this set is write-capable and must never be invoked by the +// sweep, since the server only exposes it when write access is enabled. +func ReadOnlyTools() map[string]bool { + ctx := context.Background() + srv := sdkmcp.NewServer(&sdkmcp.Implementation{Name: "readonly-probe", Version: "test"}, nil) + + // Register every provider with readOnly=true: the same gating the server + // applies under --read-only. + argo.RegisterTools(srv, true) + cilium.RegisterTools(srv, true) + helm.RegisterTools(srv, true) + istio.RegisterTools(srv, true) + k8s.RegisterTools(srv, nil, "", true) + kubescape.RegisterTools(srv, "", true) + prometheus.RegisterTools(srv, true) + utils.RegisterTools(srv, true) + + st, ct := sdkmcp.NewInMemoryTransports() + go func() { _ = srv.Run(ctx, st) }() + + client := sdkmcp.NewClient(&sdkmcp.Implementation{Name: "readonly-client", Version: "test"}, nil) + session, err := client.Connect(ctx, ct, nil) + Expect(err).ToNot(HaveOccurred(), "read-only probe failed to connect: %v", err) + defer func() { _ = session.Close() }() + + names := map[string]bool{} + for tool, err := range session.Tools(ctx, nil) { + Expect(err).ToNot(HaveOccurred()) + names[tool.Name] = true + } + Expect(names).ToNot(BeEmpty(), "read-only probe advertised no tools") + return names +} + +type toolCase struct { + name string + // args are sent verbatim; nil means "no arguments". + args map[string]any + // skipReason, when set, documents why the tool cannot be exercised here. + skipReason string +} + +// SweepReadOnlyTools exercises every read-only tool and asserts the typed-output +// contract. Call it from inside the ordered k8s container so the deployed server +// is still up (that container's AfterAll deletes the namespace). +func SweepReadOnlyTools(client *MCPClient) { + tools, err := client.listTools() + Expect(err).ToNot(HaveOccurred(), "listing tools failed: %v", err) + + advertised := make(map[string]*mcp.Tool, len(tools)) + for _, t := range tools { + advertised[t.Name] = t + } + Expect(advertised).ToNot(BeEmpty(), "server advertised no tools") + + // Every advertised tool must expose an output schema; none means Out=any. + var noSchema []string + for name, tool := range advertised { + if tool.OutputSchema == nil { + noSchema = append(noSchema, name) + } + } + sort.Strings(noSchema) + Expect(noSchema).To(BeEmpty(), + "%d tool(s) advertise no output schema (handler still returns Out=any): %v", + len(noSchema), noSchema) + + cases := readOnlyToolCases() + Expect(cases).ToNot(BeEmpty()) + readOnly := ReadOnlyTools() + + var okCount, toolErrCount, skipCount int + var missingTools []string + invoked := make(map[string]bool, len(cases)) + + for _, tc := range cases { + if _, present := advertised[tc.name]; !present { + missingTools = append(missingTools, tc.name) + continue + } + if tc.skipReason != "" { + skipCount++ + GinkgoWriter.Printf("SKIP %-42s %s\n", tc.name, tc.skipReason) + continue + } + + // Safety rail: only invoke tools the providers register in read-only + // mode. This is the authoritative set, so a write-capable tool can never + // be reached from this list. + Expect(readOnly[tc.name]).To(BeTrue(), + "refusing to invoke %q: the server does not register it in read-only mode", tc.name) + Expect(invoked[tc.name]).To(BeFalse(), "%s is enumerated twice", tc.name) + invoked[tc.name] = true + + args := tc.args + if args == nil { + args = map[string]any{} + } + + result, err := client.callToolWithTimeout(tc.name, args, 90*time.Second) + Expect(err).ToNot(HaveOccurred(), + "%s: transport error (a tool must answer, even with an error result): %v", tc.name, err) + Expect(result).ToNot(BeNil(), "%s returned a nil result", tc.name) + + if result.IsError { + // An absent dependency is a legitimate outcome; the contract is a + // readable error, not a transport failure. + toolErrCount++ + Expect(toolResultText(result)).ToNot(BeEmpty(), + "%s reported IsError with an empty message", tc.name) + GinkgoWriter.Printf("TERR %-42s %s\n", tc.name, truncate(toolResultText(result), 90)) + continue + } + + // Success must carry the typed output the SDK derives from Out. Some + // tools use a dedicated DTO (mcp_inspect returns echo+headers) rather + // than the shared TextOutput wrapper, so assert that structuredContent + // is a non-empty JSON object rather than that it decodes as TextOutput. + Expect(result.StructuredContent).ToNot(BeNil(), + "%s succeeded without structuredContent; its handler likely returns Out=any", tc.name) + raw, marshalErr := json.Marshal(result.StructuredContent) + Expect(marshalErr).ToNot(HaveOccurred(), "%s: structuredContent is not JSON: %v", tc.name, marshalErr) + Expect(len(raw)).To(BeNumerically(">", 2), "%s succeeded with empty structuredContent", tc.name) + + okCount++ + GinkgoWriter.Printf("OK %-42s\n", tc.name) + } + + GinkgoWriter.Printf("\ncoverage: ok=%d toolerr=%d skipped=%d enumerated=%d\n", + okCount, toolErrCount, skipCount, len(cases)) + + Expect(missingTools).To(BeEmpty(), "tool(s) not advertised by the server: %v", missingTools) + Expect(okCount+toolErrCount+skipCount).To(Equal(len(cases)), + "every enumerated tool must be invoked or explicitly skipped") +} + +// SweepGuardedTools asserts the read/write split is meaningful and that no +// write-capable tool slipped into the sweep's invocation list. +func SweepGuardedTools(client *MCPClient) { + tools, err := client.listTools() + Expect(err).ToNot(HaveOccurred()) + + readOnly := ReadOnlyTools() + var guarded int + for _, t := range tools { + if !readOnly[t.Name] { + guarded++ + } + } + Expect(guarded).To(BeNumerically(">", 0), + "no write-capable tools were found, so the read-only safety rail proves nothing") + + // Every case the sweep may invoke must be a read-only tool. + for _, tc := range readOnlyToolCases() { + if tc.skipReason != "" { + continue + } + Expect(readOnly[tc.name]).To(BeTrue(), + "sweep would invoke %q, which is not registered in read-only mode", tc.name) + } +} + +// readOnlyToolCases enumerates the read-only tools and the arguments needed to +// exercise them. Tools needing cluster-specific setup are skipped with a reason +// rather than guessed at. +func readOnlyToolCases() []toolCase { + // Resolve a schedulable target pod once, so the k8s read tools exercise + // their real code paths on a namespace the arg validator accepts. + podName, podNamespace := discoverPodTarget() + + cases := []toolCase{ + // utils + {name: "datetime_get_current_time"}, + {name: "mcp_inspect", args: map[string]any{"echo": "coverage"}}, + + // k8s (read-only) + {name: "k8s_get_available_api_resources"}, + {name: "k8s_get_cluster_configuration"}, + {name: "k8s_get_events"}, + // Discover a concrete pod to target: k8s arg validation rejects the + // reserved kube-* namespaces, and no pods are guaranteed in "default", + // so resolve a real one at runtime. + {name: "k8s_get_resources", args: map[string]any{"resource_type": "pods", "namespace": podNamespace, "output": "json"}}, + {name: "k8s_describe_resource", args: map[string]any{"resource_type": "pod", "resource_name": podName, "namespace": podNamespace}}, + {name: "k8s_get_resource_yaml", args: map[string]any{"resource_type": "pod", "resource_name": podName, "namespace": podNamespace}}, + {name: "k8s_get_pod_logs", args: map[string]any{"pod_name": podName, "namespace": podNamespace, "tail_lines": 5}}, + {name: "k8s_wait", args: map[string]any{"resource_type": "pod", "condition": "condition=Ready", "resource_name": podName, "namespace": podNamespace}}, + {name: "k8s_generate_resource", skipReason: "needs an LLM-backed resource_description; covered by unit tests"}, + + // helm (read-only) + {name: "helm_list_releases", args: map[string]any{"all_namespaces": true, "output": "json"}}, + {name: "helm_get_release", skipReason: "needs an existing release name"}, + {name: "helm_repo_update"}, + + // istio (read-only) + {name: "istio_version"}, + {name: "istio_proxy_status"}, + {name: "istio_analyze_cluster_configuration"}, + {name: "istio_generate_manifest", args: map[string]any{"profile": "default"}}, + {name: "istio_remote_clusters"}, + {name: "istio_list_waypoints", skipReason: "istioctl waypoint list needs ambient mode; none in this cluster"}, + {name: "istio_proxy_config", skipReason: "needs a pod enrolled in the mesh"}, + {name: "istio_generate_waypoint", skipReason: "needs an ambient-enrolled namespace"}, + {name: "istio_waypoint_status", skipReason: "needs an ambient waypoint"}, + {name: "istio_ztunnel_config", skipReason: "needs ambient ztunnel"}, + + // argo (read-only) + {name: "argo_rollouts_list", args: map[string]any{"namespace": "default"}}, + {name: "argo_verify_kubectl_plugin_install"}, + {name: "argo_verify_argo_rollouts_controller_install"}, + {name: "argo_check_plugin_logs", args: map[string]any{"namespace": "argo-rollouts"}}, + + // kubescape (read-only) - the operator may be absent; a tool error is a + // legitimate outcome and is recorded, not failed. + {name: "kubescape_check_health"}, + {name: "kubescape_list_vulnerability_manifests"}, + {name: "kubescape_list_configuration_scans"}, + {name: "kubescape_list_application_profiles"}, + {name: "kubescape_list_network_neighborhoods"}, + {name: "kubescape_list_vulnerabilities", skipReason: "needs an existing vulnerability manifest name"}, + {name: "kubescape_get_vulnerability_details", skipReason: "needs an existing manifest and CVE id"}, + {name: "kubescape_get_configuration_scan", skipReason: "needs an existing configuration scan name"}, + {name: "kubescape_get_application_profile", skipReason: "needs an existing application profile"}, + {name: "kubescape_get_network_neighborhood", skipReason: "needs an existing network neighborhood"}, + + // prometheus - no server deployed; tools must report a readable error. + {name: "prometheus_query_tool", args: map[string]any{"query": "up"}}, + {name: "prometheus_query_range_tool", args: map[string]any{"query": "up", "start": "now-5m", "end": "now", "step": "60s"}}, + {name: "prometheus_label_names_tool"}, + {name: "prometheus_targets_tool"}, + {name: "prometheus_promql_tool", skipReason: "needs an OpenAI API key for LLM query generation"}, + } + + // Cilium tools are only meaningful when Cilium is the cluster CNI. On the + // Kind default (kindnet) they must report an error, which the sweep records. + ciliumNode := ciliumNodeName() + ciliumTools := []string{ + "cilium_status_and_version", + "cilium_get_daemon_status", + "cilium_get_endpoints_list", + "cilium_list_identities", + "cilium_list_cluster_nodes", + "cilium_list_node_ids", + "cilium_list_bpf_maps", + "cilium_list_ip_addresses", + "cilium_list_services", + "cilium_list_metrics", + "cilium_display_encryption_state", + "cilium_display_selectors", + "cilium_display_policy_node_information", + "cilium_show_configuration_options", + "cilium_show_dns_names", + "cilium_show_load_information", + "cilium_show_features_status", + "cilium_show_cluster_mesh_status", + "cilium_list_bgp_peers", + "cilium_list_bgp_routes", + "cilium_list_pcap_recorders", + "cilium_list_local_redirect_policies", + "cilium_list_xdp_cidr_filters", + "cilium_request_debugging_information", + "cilium_validate_cilium_network_policies", + "cilium_fqdn_cache", + } + for _, name := range ciliumTools { + cases = append(cases, toolCase{name: name, args: map[string]any{"node_name": ciliumNode}}) + } + + // These read-only cilium tools need identifiers (endpoint/service/recorder + // ID, map name, identity ID, CIDR) that only exist when a Cilium agent is + // running. On a kindnet cluster there is nothing to query, so they are + // enumerated (proving they are advertised) but skipped rather than called + // with invented arguments, which would only re-test input validation. + for _, name := range []string{ + "cilium_get_endpoint_details", + "cilium_get_endpoint_health", + "cilium_get_endpoint_logs", + "cilium_get_kv_store_key", + "cilium_get_pcap_recorder", + "cilium_get_service_information", + "cilium_list_envoy_config", + "cilium_list_bpf_map_events", + "cilium_get_bpf_map", + "cilium_get_identity_details", + "cilium_show_ip_cache_information", + } { + cases = append(cases, toolCase{ + name: name, + skipReason: "needs a live Cilium agent identifier; Cilium is absent on kindnet", + }) + } + + return cases +} + +// discoverPodTarget returns a running pod and its namespace, preferring a +// non-reserved namespace since the argument validator rejects kube-* +// namespaces. Both values fall back to a harmless default so the sweep still +// runs (and records a tool error) when nothing is schedulable. +func discoverPodTarget() (name, namespace string) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Use a Go raw string so the jsonpath quotes need no escaping. + const jsonpath = `jsonpath={range .items[*]}{.metadata.namespace}{" "}{.metadata.name}{"\n"}{end}` + output, err := commands.NewCommandBuilder("kubectl"). + WithArgs("get", "pods", "-A", "--field-selector=status.phase=Running", "-o", jsonpath). + WithCache(false). + Execute(ctx) + if err != nil { + return "no-such-pod", "default" + } + + for _, line := range strings.Split(strings.TrimSpace(output), "\n") { + fields := strings.Fields(line) + if len(fields) != 2 { + continue + } + ns, pod := fields[0], fields[1] + // Skip namespaces the validator refuses. + if strings.HasPrefix(ns, "kube-") { + continue + } + return pod, ns + } + return "no-such-pod", "default" +} + +// ciliumNodeName returns the first node name, so the cilium-dbg backed tools +// target a real (or cleanly absent) agent instead of an empty selector. +func ciliumNodeName() string { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + output, err := commands.NewCommandBuilder("kubectl"). + WithArgs("get", "nodes", "-o", "jsonpath={.items[0].metadata.name}"). + WithCache(false). + Execute(ctx) + if err != nil { + return "" + } + return strings.TrimSpace(output) +} + +func truncate(s string, n int) string { + s = strings.ReplaceAll(strings.TrimSpace(s), "\n", " ") + if len(s) > n { + return s[:n] + "..." + } + return s +} diff --git a/test/e2e/helpers_test.go b/test/e2e/helpers_test.go index 3f5077b5..46a224fe 100644 --- a/test/e2e/helpers_test.go +++ b/test/e2e/helpers_test.go @@ -604,25 +604,30 @@ func CreateNamespace(namespace string) { By("Creating namespace " + namespace) log.Info("Creating namespace", "namespace", namespace) - // A namespace left over from a previous run may still be terminating - // (DeleteNamespace issues the delete without waiting). Creating resources in - // a terminating namespace fails with "unable to create new content ... - // because it is being terminated", so wait for the old one to disappear - // before deciding whether creation is needed. + // A namespace left over from a previous run may still be Terminating + // (DeleteNamespace issues the delete without waiting), and creating + // resources in a Terminating namespace fails with "unable to create new + // content ... because it is being terminated". Wait for the namespace to be + // usable: either gone, or present and Active. // // Note: --ignore-not-found makes kubectl exit 0 even when the namespace is - // absent, so the wait must key on empty output rather than on an error. + // absent, so the wait keys on the reported phase rather than on an error. Eventually(func() bool { checkCtx, checkCancel := context.WithTimeout(context.Background(), 30*time.Second) defer checkCancel() output, err := commands.NewCommandBuilder("kubectl"). - WithArgs("get", "namespace", namespace, "--ignore-not-found", "-o", "jsonpath={.metadata.name}"). + WithArgs("get", "namespace", namespace, "--ignore-not-found", "-o", "jsonpath={.status.phase}"). WithCache(false). Execute(checkCtx) - return err == nil && strings.TrimSpace(output) == "" + if err != nil { + return false + } + phase := strings.TrimSpace(output) + // Empty means the namespace is gone; Active means it is already usable. + return phase == "" || phase == "Active" }, 2*time.Minute, 2*time.Second).Should(BeTrue(), - "namespace %s did not finish terminating before test setup", namespace) + "namespace %s never became usable (still terminating)", namespace) // Create the namespace using kubectl output, err := commands.NewCommandBuilder("kubectl"). @@ -660,22 +665,10 @@ func DeleteNamespace(namespace string) { Expect(err).ToNot(HaveOccurred(), "Failed to delete namespace: %v", err) log.Info("Namespace deletion completed", "namespace", namespace, "output", output) - // Wait until the namespace is actually gone. Without this the next test run - // can attempt to create resources in a still-terminating namespace and fail - // with "unable to create new content ... because it is being terminated". - // As above, --ignore-not-found returns exit 0 for a missing namespace, so - // the wait keys on empty output. - Eventually(func() bool { - checkCtx, checkCancel := context.WithTimeout(context.Background(), 30*time.Second) - defer checkCancel() - - output, err := commands.NewCommandBuilder("kubectl"). - WithArgs("get", "namespace", namespace, "--ignore-not-found", "-o", "jsonpath={.metadata.name}"). - WithCache(false). - Execute(checkCtx) - return err == nil && strings.TrimSpace(output) == "" - }, 2*time.Minute, 2*time.Second).Should(BeTrue(), - "namespace %s was not removed", namespace) + // Deletion is asynchronous (--wait=false) and may linger on CRD finalizers, + // so it is not awaited here: CreateNamespace waits for the namespace to be + // usable before the next run starts, which is the only place it matters. + // Blocking here would turn a slow finalizer into an AfterAll failure. } // waitForHTTPServer waits for the HTTP server to become available diff --git a/test/e2e/k8s_test.go b/test/e2e/k8s_test.go index 2e2babf4..efd04299 100644 --- a/test/e2e/k8s_test.go +++ b/test/e2e/k8s_test.go @@ -182,6 +182,20 @@ var _ = Describe("KAgent Tools Kubernetes E2E Tests", Ordered, func() { }) }) + // The sweep must run before AfterAll deletes the namespace, so it lives in + // this ordered container rather than a separate one. + Describe("KAgent Tools Coverage Sweep", Label("coverage"), func() { + It("invokes every read-only tool with a typed result", func() { + By("sweeping every advertised read-only tool") + SweepReadOnlyTools(client) + }) + + It("classifies write-guarded tools without invoking them", func() { + By("verifying the write-tool safety rail") + SweepGuardedTools(client) + }) + }) + Describe("KAgent Tools Argo Operations", func() { It("should be able to list Argo rollouts in the cluster", func() { log.Info("Testing Argo operations via MCP", "namespace", namespace) @@ -229,26 +243,23 @@ var _ = Describe("KAgent Tools Kubernetes E2E Tests", Ordered, func() { Eventually(clusterHasCilium, 3*time.Minute, 5*time.Second). Should(BeTrue(), "cilium DaemonSet was not created by cilium_install_cilium") - // Uninstall even if an assertion below fails, so the CNI is not left - // half-installed on the cluster. Failures here must be loud: silently - // skipping verification would leak a CNI into the cluster and then - // make every later run skip this spec ("already installed"). + // Safety net: if an assertion below fails before the uninstall step + // runs, remove the DaemonSet through the cluster API so a leaked CNI + // cannot poison later runs (a leftover Cilium would make this spec + // skip itself as "already installed"). The MCP tool cannot be used + // here: the ordered container's AfterAll deletes the kagent-tools + // namespace before DeferCleanup runs, leaving no server to call. DeferCleanup(func() { - By("uninstalling Cilium via the cilium_uninstall_cilium tool") - // Connect fresh: installing Cilium replaces the CNI, which - // resets pod networking on this node and drops the original - // MCP session. - uninstallClient, err := GetMCPClient() - Expect(err).ToNot(HaveOccurred(), "failed to reconnect for Cilium uninstall: %v", err) - - uninstallResult, err := uninstallClient.callToolWithTimeout("cilium_uninstall_cilium", struct{}{}, 3*time.Minute) - Expect(err).ToNot(HaveOccurred(), "cilium_uninstall_cilium call failed: %v", err) - Expect(uninstallResult).ToNot(BeNil()) - Expect(uninstallResult.IsError).To(BeFalse(), - "cilium_uninstall_cilium returned a tool error: %s", toolResultText(uninstallResult)) - - Eventually(clusterHasCilium, 3*time.Minute, 5*time.Second). - Should(BeFalse(), "cilium DaemonSet still present after cilium_uninstall_cilium") + if !clusterHasCilium() { + return + } + By("removing leftover Cilium via the cluster API") + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + _, _ = commands.NewCommandBuilder("kubectl"). + WithArgs("delete", "daemonset", "cilium", "-n", "kube-system", "--ignore-not-found"). + WithCache(false). + Execute(ctx) }) // Installing Cilium replaces the cluster CNI and briefly resets pod @@ -268,6 +279,19 @@ var _ = Describe("KAgent Tools Kubernetes E2E Tests", Ordered, func() { Expect(err).ToNot(HaveOccurred(), "cilium_status_and_version did not return typed output: %v", err) Expect(statusOutput.Output).ToNot(BeEmpty(), "cilium status output should not be empty") + // Uninstall through the MCP tool while the server is still reachable. + // This must run inside the It, not DeferCleanup: the ordered container's + // AfterAll deletes the namespace first, leaving no server to call. + By("uninstalling Cilium via the cilium_uninstall_cilium tool") + uninstallResult, err := client.callToolWithTimeout("cilium_uninstall_cilium", struct{}{}, 3*time.Minute) + Expect(err).ToNot(HaveOccurred(), "cilium_uninstall_cilium call failed: %v", err) + Expect(uninstallResult).ToNot(BeNil()) + Expect(uninstallResult.IsError).To(BeFalse(), + "cilium_uninstall_cilium returned a tool error: %s", toolResultText(uninstallResult)) + + Eventually(clusterHasCilium, 3*time.Minute, 5*time.Second). + Should(BeFalse(), "cilium DaemonSet still present after cilium_uninstall_cilium") + log.Info("Successfully exercised the Cilium install/status/uninstall lifecycle via MCP") }) }) From cd0258b6823f9cbc4036eaa5b511457f6932f8e5 Mon Sep 17 00:00:00 2001 From: Dmytro Rashko Date: Mon, 21 Sep 2026 14:19:21 +0200 Subject: [PATCH 12/21] chore(deps): bump Go dependencies and bundled CLI versions Go dependencies, direct: - github.com/modelcontextprotocol/go-sdk v1.7.0 -> v1.8.0 (the release that prompted this). It adds no new protocol revision; the work is transport hardening plus ServerOptions.SupportedProtocolVersions and SetCacheable. The output-schema machinery the typed-output migration depends on (toolForErr, setSchema, ToolHandlerFor, AddTool) is byte-for-byte unchanged, so no handler changes were needed. - k8s.io/{api,apimachinery,client-go,apiextensions-apiserver} v0.35.3 -> v0.37.0 - go.opentelemetry.io/otel* v1.43.0 -> v1.46.0 - github.com/prometheus/client_golang v1.23.2 -> v1.24.1, client_model v0.6.2 -> v0.6.3 - github.com/kubescape/k8s-interface v0.0.203 -> v0.0.221 - github.com/onsi/ginkgo/v2 v2.27.2 -> v2.33.0, gomega v1.38.2 -> v1.43.1 - github.com/stretchr/testify v1.11.1 -> v1.12.1 - go directive 1.27.0 -> 1.27.1 github.com/kubescape/storage v0.0.239 -> v0.0.300, not the latest v0.2.0. That module is imported directly for its v1beta1 API types and generated clientset, and upstream removed both the types and the client methods this code uses (v1beta1.ExecCalls, OpenCalls, HTTPEndpoint, NetworkConnections, CommunicationType, NetworkPort, SingleSeccompProfile, and the ApplicationProfiles/WorkloadConfigurationScans/NetworkNeighborhoods client methods). v0.0.300 is the newest version that still exposes them; going further requires rewriting the kubescape DTOs, which is out of scope here. Bundled CLI versions, checked against each project's latest release: - istioctl 1.30.1 -> 1.31.0 - argo 1.9.0 -> 1.10.0 - kubectl 1.36.2 -> 1.37.0 - helm 4.2.2 -> 4.3.0 - cilium 0.19.4 -> 0.20.0 make check-releases now reports all six checks green. Verified: go build ./... and go vet ./... pass; go test ./pkg/... ./internal/... ./cmd/... passes 19/19 packages; make lint reports 0 issues; gofmt clean. The image was rebuilt with make docker-build and each binary was executed inside it to confirm the versions actually installed, rather than trusting the pin: kubectl v1.37.0, helm v4.3.0, istioctl 1.31.0, cilium-cli v0.20.0, kubectl-argo-rollouts v1.10.0, and the server reporting go1.27.1. Signed-off-by: Dmytro Rashko --- Makefile | 10 +- go.mod | 162 +++++++++++++------------- go.sum | 337 ++++++++++++++++++++++++++++--------------------------- 3 files changed, 260 insertions(+), 249 deletions(-) diff --git a/Makefile b/Makefile index 0ba2c5c5..6a604e04 100644 --- a/Makefile +++ b/Makefile @@ -136,11 +136,11 @@ DOCKER_BUILDER ?= docker buildx DOCKER_BUILD_ARGS ?= --pull --load --platform linux/$(LOCALARCH) --builder $(BUILDX_BUILDER_NAME) # tools image build args -TOOLS_ISTIO_VERSION ?= 1.30.1 -TOOLS_ARGO_ROLLOUTS_VERSION ?= 1.9.0 -TOOLS_KUBECTL_VERSION ?= 1.36.2 -TOOLS_HELM_VERSION ?= 4.2.2 -TOOLS_CILIUM_VERSION ?= 0.19.4 +TOOLS_ISTIO_VERSION ?= 1.31.0 +TOOLS_ARGO_ROLLOUTS_VERSION ?= 1.10.0 +TOOLS_KUBECTL_VERSION ?= 1.37.0 +TOOLS_HELM_VERSION ?= 4.3.0 +TOOLS_CILIUM_VERSION ?= 0.20.0 # build args TOOLS_IMAGE_BUILD_ARGS = --build-arg VERSION=$(VERSION) diff --git a/go.mod b/go.mod index ca7caada..37a83da5 100644 --- a/go.mod +++ b/go.mod @@ -1,41 +1,43 @@ module github.com/kagent-dev/tools -go 1.27.0 +go 1.27.1 require ( github.com/google/jsonschema-go v0.4.3 github.com/joho/godotenv v1.5.1 - github.com/kubescape/k8s-interface v0.0.203 - github.com/kubescape/storage v0.0.239 - github.com/modelcontextprotocol/go-sdk v1.7.0 - github.com/onsi/ginkgo/v2 v2.27.2 - github.com/onsi/gomega v1.38.2 - github.com/prometheus/client_golang v1.23.2 - github.com/prometheus/client_model v0.6.2 + github.com/kubescape/k8s-interface v0.0.221 + github.com/kubescape/storage v0.0.300 + github.com/modelcontextprotocol/go-sdk v1.8.0 + github.com/onsi/ginkgo/v2 v2.33.0 + github.com/onsi/gomega v1.43.1 + github.com/prometheus/client_golang v1.24.1 + github.com/prometheus/client_model v0.6.3 github.com/spf13/cobra v1.10.2 - github.com/stretchr/testify v1.11.1 + github.com/stretchr/testify v1.12.1 github.com/tmc/langchaingo v0.1.14 - go.opentelemetry.io/otel v1.43.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 - go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 - go.opentelemetry.io/otel/metric v1.43.0 - go.opentelemetry.io/otel/sdk v1.43.0 - go.opentelemetry.io/otel/trace v1.43.0 - k8s.io/api v0.35.3 - k8s.io/apiextensions-apiserver v0.35.3 - k8s.io/apimachinery v0.35.3 - k8s.io/client-go v0.35.3 + go.opentelemetry.io/otel v1.46.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0 + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0 + go.opentelemetry.io/otel/metric v1.46.0 + go.opentelemetry.io/otel/sdk v1.46.0 + go.opentelemetry.io/otel/trace v1.46.0 + k8s.io/api v0.37.0 + k8s.io/apiextensions-apiserver v0.37.0 + k8s.io/apimachinery v0.37.0 + k8s.io/client-go v0.37.0 ) require ( + cel.dev/expr v0.25.2 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/acobaugh/osrelease v0.1.0 // indirect github.com/anchore/go-logger v0.0.0-20250318195838-07ae343dd722 // indirect github.com/anchore/packageurl-go v0.1.1-0.20250220190351-d62adb6e1115 // indirect github.com/anchore/stereoscope v0.1.22 // indirect github.com/anchore/syft v1.42.3 // indirect - github.com/armosec/armoapi-go v0.0.674 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/armosec/armoapi-go v0.0.696 // indirect github.com/armosec/gojay v1.2.17 // indirect github.com/armosec/utils-go v0.0.58 // indirect github.com/armosec/utils-k8s-go v0.0.35 // indirect @@ -61,62 +63,63 @@ require ( github.com/docker/go-connections v0.6.0 // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/facebookincubator/nvdtools v0.1.5 // indirect - github.com/fatih/color v1.18.0 // indirect + github.com/fatih/color v1.19.0 // indirect github.com/francoispqt/gojay v1.2.13 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.1 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/github/go-spdx/v2 v2.4.0 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect - github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-openapi/analysis v0.24.2 // indirect - github.com/go-openapi/errors v0.22.6 // indirect - github.com/go-openapi/jsonpointer v0.22.4 // indirect - github.com/go-openapi/jsonreference v0.21.4 // indirect - github.com/go-openapi/loads v0.23.2 // indirect - github.com/go-openapi/spec v0.22.3 // indirect - github.com/go-openapi/strfmt v0.25.0 // indirect - github.com/go-openapi/swag v0.25.4 // indirect - github.com/go-openapi/swag/cmdutils v0.25.4 // indirect - github.com/go-openapi/swag/conv v0.25.4 // indirect - github.com/go-openapi/swag/fileutils v0.25.4 // indirect - github.com/go-openapi/swag/jsonname v0.25.4 // indirect - github.com/go-openapi/swag/jsonutils v0.25.4 // indirect - github.com/go-openapi/swag/loading v0.25.4 // indirect - github.com/go-openapi/swag/mangling v0.25.4 // indirect - github.com/go-openapi/swag/netutils v0.25.4 // indirect - github.com/go-openapi/swag/stringutils v0.25.4 // indirect - github.com/go-openapi/swag/typeutils v0.25.4 // indirect - github.com/go-openapi/swag/yamlutils v0.25.4 // indirect - github.com/go-openapi/validate v0.25.1 // indirect + github.com/go-openapi/analysis v0.25.5 // indirect + github.com/go-openapi/errors v0.22.8 // indirect + github.com/go-openapi/jsonpointer v1.0.0 // indirect + github.com/go-openapi/jsonreference v1.0.0 // indirect + github.com/go-openapi/loads v0.25.0 // indirect + github.com/go-openapi/spec v0.22.9 // indirect + github.com/go-openapi/strfmt v0.27.0 // indirect + github.com/go-openapi/swag v0.28.0 // indirect + github.com/go-openapi/swag/cmdutils v0.28.0 // indirect + github.com/go-openapi/swag/conv v0.28.0 // indirect + github.com/go-openapi/swag/fileutils v0.28.0 // indirect + github.com/go-openapi/swag/jsonutils v0.28.0 // indirect + github.com/go-openapi/swag/loading v0.28.0 // indirect + github.com/go-openapi/swag/mangling v0.28.0 // indirect + github.com/go-openapi/swag/netutils v0.28.0 // indirect + github.com/go-openapi/swag/pools v0.28.0 // indirect + github.com/go-openapi/swag/stringutils v0.28.0 // indirect + github.com/go-openapi/swag/typeutils v0.28.0 // indirect + github.com/go-openapi/swag/yamlutils v0.28.0 // indirect + github.com/go-openapi/validate v0.26.1 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/gohugoio/hashstructure v0.6.0 // indirect + github.com/google/cel-go v0.29.2 // indirect github.com/google/gnostic-models v0.7.1 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-containerregistry v0.21.2 // indirect github.com/google/licensecheck v0.3.1 // indirect - github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8 // indirect + github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jinzhu/copier v0.4.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.18.4 // indirect - github.com/kubescape/go-logger v0.0.26 // indirect + github.com/klauspost/compress v1.19.1 // indirect + github.com/kubescape/go-logger v0.0.28 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/mackerelio/go-osstat v0.2.6 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.21 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/oklog/ulid v1.3.1 // indirect + github.com/oklog/ulid/v2 v2.1.1 // indirect github.com/olvrng/ujson v1.1.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect @@ -125,9 +128,8 @@ require ( github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pkoukk/tiktoken-go v0.1.8 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/common v0.67.5 // indirect - github.com/prometheus/procfs v0.19.2 // indirect + github.com/prometheus/common v0.70.1 // indirect + github.com/prometheus/procfs v0.21.1 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/sasha-s/go-deadlock v0.3.6 // indirect github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e // indirect @@ -146,7 +148,7 @@ require ( github.com/ulikunitz/xz v0.5.15 // indirect github.com/uptrace/opentelemetry-go-extra/otelutil v0.3.2 // indirect github.com/uptrace/opentelemetry-go-extra/otelzap v0.3.2 // indirect - github.com/uptrace/uptrace-go v1.39.0 // indirect + github.com/uptrace/uptrace-go v1.43.0 // indirect github.com/vishvananda/netlink v1.3.2-0.20260109214200-c6faf428e8f8 // indirect github.com/vishvananda/netns v0.0.5 // indirect github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651 // indirect @@ -154,47 +156,47 @@ require ( github.com/x448/float16 v0.8.4 // indirect github.com/yl2chen/cidranger v1.0.2 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect - go.mongodb.org/mongo-driver v1.17.9 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/bridges/otelslog v0.15.0 // indirect - go.opentelemetry.io/contrib/instrumentation/runtime v0.65.0 // indirect + go.opentelemetry.io/contrib/bridges/otelslog v0.18.0 // indirect + go.opentelemetry.io/contrib/instrumentation/runtime v0.68.0 // indirect + go.opentelemetry.io/contrib/processors/minsev v0.16.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect go.opentelemetry.io/otel/log v0.19.0 // indirect go.opentelemetry.io/otel/sdk/log v0.19.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect - go.opentelemetry.io/proto/otlp v1.10.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.46.0 // indirect + go.opentelemetry.io/proto/otlp v1.11.0 // indirect go.uber.org/dig v1.19.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect - go.yaml.in/yaml/v2 v2.4.3 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect - golang.org/x/mod v0.35.0 // indirect - golang.org/x/net v0.55.0 // indirect + golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/net v0.58.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/term v0.43.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/term v0.45.0 // indirect + golang.org/x/text v0.41.0 // indirect golang.org/x/time v0.15.0 // indirect - golang.org/x/tools v0.44.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/grpc v1.80.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect + golang.org/x/tools v0.48.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect + google.golang.org/grpc v1.83.1 // indirect + google.golang.org/protobuf v1.36.12 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/apiserver v0.35.3 // indirect - k8s.io/component-base v0.35.3 // indirect - k8s.io/klog/v2 v2.130.1 // indirect - k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 // indirect - k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 // indirect + k8s.io/apiserver v0.37.0 // indirect + k8s.io/component-base v0.37.0 // indirect + k8s.io/klog/v2 v2.140.0 // indirect + k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect + k8s.io/utils v0.0.0-20260626114624-be93311217bd // indirect sigs.k8s.io/controller-runtime v0.23.3 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index ee518007..9fa440f4 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= +cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.31.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= @@ -87,13 +89,15 @@ github.com/anchore/syft v1.42.3/go.mod h1:i2PZ+276IdPcnd/n32aeIv849iO/QqdjRknbIc github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-metrics v0.3.10/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= -github.com/armosec/armoapi-go v0.0.674 h1:jsp4rZqs+iKeL/Y4GgXW6JkVf0DhrilhaqRq3bar8HY= -github.com/armosec/armoapi-go v0.0.674/go.mod h1:9jAH0g8ZsryhiBDd/aNMX4+n10bGwTx/doWCyyjSxts= +github.com/armosec/armoapi-go v0.0.696 h1:+0Ll7y4oWNaKEO47qbGDFIQLxkSJeKYzylS0FwI84XE= +github.com/armosec/armoapi-go v0.0.696/go.mod h1:9jAH0g8ZsryhiBDd/aNMX4+n10bGwTx/doWCyyjSxts= github.com/armosec/gojay v1.2.17 h1:VSkLBQzD1c2V+FMtlGFKqWXNsdNvIKygTKJI9ysY8eM= github.com/armosec/gojay v1.2.17/go.mod h1:vuvX3DlY0nbVrJ0qCklSS733AWMoQboq3cFyuQW9ybc= github.com/armosec/utils-go v0.0.58 h1:g9RnRkxZAmzTfPe2ruMo2OXSYLwVSegQSkSavOfmaIE= @@ -194,8 +198,8 @@ github.com/facebookincubator/nvdtools v0.1.5/go.mod h1:Kh55SAWnjckS96TBSrXI99KrE github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= -github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= -github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/fatih/set v0.2.1 h1:nn2CaJyknWE/6txyUDGwysr3G5QC6xWB/PtVjPBbeaA= github.com/fatih/set v0.2.1/go.mod h1:+RKtMCH+favT2+3YecHGxcc0b4KyVWA1QWWJUs4E0CI= github.com/felixge/fgprof v0.9.5 h1:8+vR6yu2vvSKn08urWyEuxx75NWPEvybbkBirEpsbVY= @@ -209,8 +213,8 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= -github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ= +github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= @@ -232,58 +236,58 @@ github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2 github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= -github.com/go-openapi/analysis v0.24.2 h1:6p7WXEuKy1llDgOH8FooVeO+Uq2za9qoAOq4ZN08B50= -github.com/go-openapi/analysis v0.24.2/go.mod h1:x27OOHKANE0lutg2ml4kzYLoHGMKgRm1Cj2ijVOjJuE= -github.com/go-openapi/errors v0.22.6 h1:eDxcf89O8odEnohIXwEjY1IB4ph5vmbUsBMsFNwXWPo= -github.com/go-openapi/errors v0.22.6/go.mod h1:z9S8ASTUqx7+CP1Q8dD8ewGH/1JWFFLX/2PmAYNQLgk= -github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= -github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= -github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8= -github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4= -github.com/go-openapi/loads v0.23.2 h1:rJXAcP7g1+lWyBHC7iTY+WAF0rprtM+pm8Jxv1uQJp4= -github.com/go-openapi/loads v0.23.2/go.mod h1:IEVw1GfRt/P2Pplkelxzj9BYFajiWOtY2nHZNj4UnWY= -github.com/go-openapi/spec v0.22.3 h1:qRSmj6Smz2rEBxMnLRBMeBWxbbOvuOoElvSvObIgwQc= -github.com/go-openapi/spec v0.22.3/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs= -github.com/go-openapi/strfmt v0.25.0 h1:7R0RX7mbKLa9EYCTHRcCuIPcaqlyQiWNPTXwClK0saQ= -github.com/go-openapi/strfmt v0.25.0/go.mod h1:nNXct7OzbwrMY9+5tLX4I21pzcmE6ccMGXl3jFdPfn8= -github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU= -github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ= -github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4= -github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= -github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4= -github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU= -github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y= -github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk= -github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= -github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= -github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA= -github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM= -github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s= -github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE= -github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48= -github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg= -github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0= -github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg= -github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8= -github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0= -github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw= -github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE= -github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw= -github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc= -github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4= -github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg= -github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= -github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= -github.com/go-openapi/validate v0.25.1 h1:sSACUI6Jcnbo5IWqbYHgjibrhhmt3vR6lCzKZnmAgBw= -github.com/go-openapi/validate v0.25.1/go.mod h1:RMVyVFYte0gbSTaZ0N4KmTn6u/kClvAFp+mAVfS/DQc= +github.com/go-openapi/analysis v0.25.5 h1:xPYEvTb90o1y0epuiOPAoG4QqahjP3cdp5xNlHeKJRI= +github.com/go-openapi/analysis v0.25.5/go.mod h1:d3UGtQC5uq5Kqqqis2VH09Km/v3vwsWrYkbp4gdm+Rc= +github.com/go-openapi/errors v0.22.8 h1:oP7sW7TWc3wFFjrzzj0nI83H2qMBkNjNfSd+XRejk/I= +github.com/go-openapi/errors v0.22.8/go.mod h1:BuUoHcYrU6E7V9gfj1I5wLQqgtIHnup/alXZ8KdgQ0w= +github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s= +github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y= +github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY= +github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0= +github.com/go-openapi/loads v0.25.0 h1:74Bc2snfaVlsHzwdQj/3gsA9XJz3daXTJVs+4ZaK7jI= +github.com/go-openapi/loads v0.25.0/go.mod h1:JFBw4SIB9+PTIFHDfcXuSSy5h6aWzjtUCrPYyx3qWU8= +github.com/go-openapi/spec v0.22.9 h1:/vKIFDcGKp0ktZWGbym/tJEWbk6/XOEmAVU0kqKMH+w= +github.com/go-openapi/spec v0.22.9/go.mod h1:b/mNUYIOQOyIiUzUzXEE8xzyZqf93KvM9hQGP91yfl0= +github.com/go-openapi/strfmt v0.27.0 h1:kbcTeaD9TXuXD0hhMXzuYa1sdTo6+dWGvwjW93E80IM= +github.com/go-openapi/strfmt v0.27.0/go.mod h1:s/qhDqfY72irigXUGJmtgid2Rm+3tnz3k8hZaRmvWYc= +github.com/go-openapi/swag v0.28.0 h1:xkgbOSKj6DZziNpyqRRAOt3GJGtgjgsd2RoyT30VWuw= +github.com/go-openapi/swag v0.28.0/go.mod h1:4qYnT3Cqr1p1VknOdPo70evN4rgQnAg6jwApHyxSGIg= +github.com/go-openapi/swag/cmdutils v0.28.0 h1:7TOeNtkYru1SG8Y34tDh9WBbLsMqGnptuxWiHREPZ4Q= +github.com/go-openapi/swag/cmdutils v0.28.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= +github.com/go-openapi/swag/conv v0.28.0 h1:GtqqbyFe7vR5Y7ehxG9W6/OvrSFdf1OLeTGp40TqxH8= +github.com/go-openapi/swag/conv v0.28.0/go.mod h1:mbUE+mzctnhxi864m0Q07SpN8OowD9JhxmxuYvZZD/k= +github.com/go-openapi/swag/fileutils v0.28.0 h1:Z04XWQD7R8Eq+7GnOrjovBxPPmZzsS4gt2H2GPGIViU= +github.com/go-openapi/swag/fileutils v0.28.0/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8= +github.com/go-openapi/swag/jsonutils v0.28.0 h1:YIch6FwO7RXzeAnbO8Tu7dWBZeUEH+4nA0HXltVTnv4= +github.com/go-openapi/swag/jsonutils v0.28.0/go.mod h1:CYM3WlTUcagR2ZoHdz54di/cbBqt82tuxuXgAjxw+mg= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0 h1:qV+VVUAx5Oro8WjVWpZeql7YReTKhT4smR4zhcOQZr0= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY= +github.com/go-openapi/swag/loading v0.28.0 h1:td8QZdZC9MIYGGSnSPKShKiK22I2tU5UQvuUhIBPRLU= +github.com/go-openapi/swag/loading v0.28.0/go.mod h1:rXB0QiQX5mMveXEA7ouM4KiiM9jVJe4K6BVbwhD1M4k= +github.com/go-openapi/swag/mangling v0.28.0 h1:pH8eyeNO9SLYsTMWJrurnNfKmDa28XrlA+HePVD53VM= +github.com/go-openapi/swag/mangling v0.28.0/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w= +github.com/go-openapi/swag/netutils v0.28.0 h1:YXN6TALEi2pzts8/8GNm6T61HTAZsieukGZidap989k= +github.com/go-openapi/swag/netutils v0.28.0/go.mod h1:J+WYyFMLtvtCGqa6jLv+YNUmIKI3ZRQRrvfNDMoQoEQ= +github.com/go-openapi/swag/pools v0.28.0 h1:HPMZWSAfce3rdVTFcjFiCIBtDg9h4x2QlRrHipwhxeU= +github.com/go-openapi/swag/pools v0.28.0/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE= +github.com/go-openapi/swag/stringutils v0.28.0 h1:ixsc9iYgDPubHL/8nSkbnryEHpD2VRlBMLKpQyPXcDU= +github.com/go-openapi/swag/stringutils v0.28.0/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM= +github.com/go-openapi/swag/typeutils v0.28.0 h1:nRBKSBXjDgf01VDPB3fWeD9nQuhCOVeIYAkUx2tbkyY= +github.com/go-openapi/swag/typeutils v0.28.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= +github.com/go-openapi/swag/yamlutils v0.28.0 h1:TV3JXH6DS46KUroDtMLAYHGkdWf5VDq3wVWFirmzROY= +github.com/go-openapi/swag/yamlutils v0.28.0/go.mod h1:x0q/yndZHEgk9Rx3DyDqzFUmHy55KTvIZldvF2dTJXs= +github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0= +github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo= +github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= +github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-openapi/validate v0.26.1 h1:pZSbvtRO8G2R2FpWTYRn3w8LrsNwbtaVhP2dWiBa0Us= +github.com/go-openapi/validate v0.26.1/go.mod h1:B8UMgXiQiwwQWIbmuROlwJZDPGlikPuh7iHV1vPX9Oo= github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6 h1:teYtXy9B7y5lHTp8V9KPxpYRAVA7dozigQcMiBust1s= github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= @@ -340,6 +344,8 @@ github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6 github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/cel-go v0.29.2 h1:ZtDxkeiMmz0mxbKDYiNkE5Lk7V5edMRcaaDf2jX002k= +github.com/google/cel-go v0.29.2/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= @@ -383,8 +389,8 @@ github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8 h1:3DsUAV+VNEQa2CUVLxCY3f87278uWfIDhJnbdvDjvmE= -github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -402,8 +408,8 @@ github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORR github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= github.com/grpc-ecosystem/grpc-gateway v1.5.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY= github.com/hashicorp/consul/api v1.11.0/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M= github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -463,8 +469,8 @@ github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/X github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= -github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= @@ -477,12 +483,12 @@ github.com/kr/pty v1.1.3/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kubescape/go-logger v0.0.26 h1:pOADnCLaooXwa7XUzmelCasq8h/vWhjHScJITuKoTUk= -github.com/kubescape/go-logger v0.0.26/go.mod h1:qfUg4BGH2Rbxy+Tn3g4ks2Lt7zOENKA8AMXnAfkZpl0= -github.com/kubescape/k8s-interface v0.0.203 h1:dMlF+X8PQPUhcn9QeMipnHd/6/gkOvyC0smLz1uBO5I= -github.com/kubescape/k8s-interface v0.0.203/go.mod h1:d4NVhL81bVXe8yEXlkT4ZHrt3iEppEIN39b8N1oXm5s= -github.com/kubescape/storage v0.0.239 h1:hfuq1+CuEAKE7zCg9bB8gfU9vZoGMrJBgNh5tAD1rak= -github.com/kubescape/storage v0.0.239/go.mod h1:f6u/Lt3SjUTBrmzOStb33IkKTtaqKM4pyfV5d1lUMiY= +github.com/kubescape/go-logger v0.0.28 h1:xulKTp9kOg3rD98sopFELQ6yZCHQoQXMDzteoSHDFKI= +github.com/kubescape/go-logger v0.0.28/go.mod h1:YZHFjwGCDar1hP9OyBLE46oR7a0Y/Z/0FperDo8+9D0= +github.com/kubescape/k8s-interface v0.0.221 h1:la69jLhCkEcOokffXGR5WUQU9N7Cv2zmk360V8okFJo= +github.com/kubescape/k8s-interface v0.0.221/go.mod h1:sOZm48DJn8QO1cWRbuNsPPsk0TxLzm13Ws85EkiH0X0= +github.com/kubescape/storage v0.0.300 h1:4p1hgXJEIVlWO6wT3VHN+AM1r4iqKeMdW5fFwKoDcfY= +github.com/kubescape/storage v0.0.300/go.mod h1:d/1hqWPda2clsjx2wmQgysnB5dThIo3rDKP7RWx+v+M= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= @@ -506,8 +512,8 @@ github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcME github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= +github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= @@ -528,8 +534,8 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg= github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= -github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44= -github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= +github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4= +github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -543,14 +549,14 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8m github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo= github.com/neelance/sourcemap v0.0.0-20151028013722-8c68805598ab/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= -github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= -github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= +github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/olvrng/ujson v1.1.0 h1:8xVUzVlqwdMVWh5d1UHBtLQ1D50nxoPuPEq9Wozs8oA= github.com/olvrng/ujson v1.1.0/go.mod h1:Mz4G3RODTUfbkKyvi0lgmPx/7vd3Saksk+1jgk8s9xo= -github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= -github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= -github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= -github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= +github.com/onsi/ginkgo/v2 v2.33.0 h1:C8gBA6Uc2ZEubiV+SXiu5tZnMTwEmXHgkJwGozKtZf8= +github.com/onsi/ginkgo/v2 v2.33.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= +github.com/onsi/gomega v1.43.1 h1:vGIPFuYrIO6/0Z09s0I0QQQgFchiX4+tb1re3MScJYo= +github.com/onsi/gomega v1.43.1/go.mod h1:e/C2HwaZ1DhvjzXXuFhcR7hY7Sh9pl7MmoWKEjzwcdA= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -562,6 +568,7 @@ github.com/opencontainers/runtime-tools v0.9.1-0.20250303011046-260e151b8552/go. github.com/openzipkin/zipkin-go v0.1.1/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pborman/indent v1.2.1 h1:lFiviAbISHv3Rf0jcuh489bi06hj98JsVMtIDZQb9yM= github.com/pborman/indent v1.2.1/go.mod h1:FitS+t35kIYtB5xWTZAPhnmrxcciEEOdbyrrpz5K6Vw= github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= @@ -589,25 +596,25 @@ github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXP github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= -github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= -github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= +github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= -github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo= +github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM= github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= -github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= -github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= +github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= -github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= -github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= +github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -683,16 +690,16 @@ github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/stripe/stripe-go/v74 v74.30.0 h1:0Kf0KkeFnY7iRhOwvTerX0Ia1BRw+eV1CVJ51mGYAUY= github.com/stripe/stripe-go/v74 v74.30.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= @@ -722,8 +729,8 @@ github.com/uptrace/opentelemetry-go-extra/otelutil v0.3.2 h1:3/aHKUq7qaFMWxyQV0W github.com/uptrace/opentelemetry-go-extra/otelutil v0.3.2/go.mod h1:Zit4b8AQXaXvA68+nzmbyDzqiyFRISyw1JiD5JqUBjw= github.com/uptrace/opentelemetry-go-extra/otelzap v0.3.2 h1:cj/Z6FKTTYBnstI0Lni9PA+k2foounKIPUmj1LBwNiQ= github.com/uptrace/opentelemetry-go-extra/otelzap v0.3.2/go.mod h1:LDaXk90gKEC2nC7JH3Lpnhfu+2V7o/TsqomJJmqA39o= -github.com/uptrace/uptrace-go v1.39.0 h1:MszuE3eX/z86xzYywN2JBtYcmsS4ofdo1VMDhRvkWrI= -github.com/uptrace/uptrace-go v1.39.0/go.mod h1:FquipEqgTMXPbhdhenjbiLHG1R5WYdxVH6zgwHeMzzA= +github.com/uptrace/uptrace-go v1.43.0 h1:5QuCdyFJdWUEXx6Fr6sYfezdgO6n6lnkOvUTLlyQO7U= +github.com/uptrace/uptrace-go v1.43.0/go.mod h1:ehDTIdtBSolg4Z0CCvg1C8yR6VX1YFDqBcg2KmsXWn0= github.com/viant/assertly v0.4.8/go.mod h1:aGifi++jvCrUaklKEKT0BU95igDNaqkvz+49uaYMPRU= github.com/viant/toolbox v0.24.0/go.mod h1:OxMCG57V0PXuIP2HNQrtJf2CjqdmbrOx5EkMILuUhzM= github.com/vishvananda/netlink v1.3.2-0.20260109214200-c6faf428e8f8 h1:/EaCkwYyCH9rDgccb78ZTaGwo7UGjjdh0iyCa3+miRs= @@ -750,8 +757,6 @@ github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1 go.etcd.io/etcd/api/v3 v3.5.1/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= go.etcd.io/etcd/client/pkg/v3 v3.5.1/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= go.etcd.io/etcd/client/v2 v2.305.1/go.mod h1:pMEacxZW7o8pg4CrFE7pquyCJJzZvkvdD2RibOCCCGs= -go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU= -go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ= go.opencensus.io v0.18.0/go.mod h1:vKdFvxhtzZ9onBp9VKHK8z/sRpBMnKAsufL7wlDrCOA= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= @@ -762,41 +767,45 @@ go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/bridges/otelslog v0.15.0 h1:yOYhGNPZseueTTvWp5iBD3/CthrmvayUXYEX862dDi4= -go.opentelemetry.io/contrib/bridges/otelslog v0.15.0/go.mod h1:CvaNVqIfcybc+7xqZNubbE+26K6P7AKZF/l0lE2kdCk= -go.opentelemetry.io/contrib/instrumentation/runtime v0.65.0 h1:n8qdwrebNEHF/zHpueuZ4OacdJ8CdSaP7xef9WRZXTQ= -go.opentelemetry.io/contrib/instrumentation/runtime v0.65.0/go.mod h1:Z1pjGxUL3nJ/IbDDfL6rBD0Xbz7ZOViRqrIUg4l1CYE= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/contrib/bridges/otelslog v0.18.0 h1:hhPGP3zvvy1xWT9RTy970wlniSxFttBIsAK1gvMguJM= +go.opentelemetry.io/contrib/bridges/otelslog v0.18.0/go.mod h1:twJF7inoMza6kxMcF8JOdL3mPmtOZu7GEr34CUNE6Dg= +go.opentelemetry.io/contrib/instrumentation/runtime v0.68.0 h1:jhVIQEprwUTV+KfzzliLidclhoTOoHTgdz96kAyR8mU= +go.opentelemetry.io/contrib/instrumentation/runtime v0.68.0/go.mod h1:4HsdbLUbernaTnA8CNaNE+1g026SciXb3juRYe3l8EY= +go.opentelemetry.io/contrib/processors/minsev v0.16.0 h1:bjTZkvAKnG1mqWgCjU7RkOkHRTMsGlJO/UlqjRCweeU= +go.opentelemetry.io/contrib/processors/minsev v0.16.0/go.mod h1:R2mmaDsqsWb+Y0mQkPifiCwifdotrG4fFoD4z0tim+g= +go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= +go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 h1:HIBTQ3VO5aupLKjC90JgMqpezVXwFuq6Ryjn0/izoag= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0/go.mod h1:ji9vId85hMxqfvICA0Jt8JqEdrXaAkcpkI9HPXya0ro= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 h1:OFnwLJr+pF3iHrlGSzbxyuo6/6HyBlnlN1CWEJmBVcw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0/go.mod h1:716wFneO0ov19A2beH5hjfh9AK5z/VWNAtDijp1Y0/g= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0 h1:w53CDeOA/Kurp7yRsegSr6pbbr759dOvJ+yNmWM6Hxs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0/go.mod h1:BOmGMCbAtvcJiSJ+hLuhgPLdDbimnraSl8irz3iY8sY= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0 h1:KrC1YrQeSt46ITMWAbgQx1M1eV1/1TKzttrBzymPmss= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0/go.mod h1:zDSEzoEqsOrgBeGvH66KRgxh90VonFyJqBHA0Pk3+rM= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0 h1:KdRxPiAoMptR3vfWzvjjvutTsSiwbC2uG0496rzZNfo= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0/go.mod h1:K/qSA+3G7Eovxi4K09wzrAgkWRnosS0DAOZeEpve7sM= go.opentelemetry.io/otel/log v0.19.0 h1:KUZs/GOsw79TBBMfDWsXS+KZ4g2Ckzksd1ymzsIEbo4= go.opentelemetry.io/otel/log v0.19.0/go.mod h1:5DQYeGmxVIr4n0/BcJvF4upsraHjg6vudJJpnkL6Ipk= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= -go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= +go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= +go.opentelemetry.io/otel/metric/x v0.68.0 h1:TA/cBT23D3MnxYPwHL7YFOdYGdx0A0v+s7Mzotpd1dU= +go.opentelemetry.io/otel/metric/x v0.68.0/go.mod h1:agudOmvWhwUTjgibWDzxD2PoWYnpw5Ht5jISYOD2Hd4= +go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= +go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM= go.opentelemetry.io/otel/sdk/log v0.19.0 h1:scYVLqT22D2gqXItnWiocLUKGH9yvkkeql5dBDiXyko= go.opentelemetry.io/otel/sdk/log v0.19.0/go.mod h1:vFBowwXGLlW9AvpuF7bMgnNI95LiW10szrOdvzBHlAg= go.opentelemetry.io/otel/sdk/log/logtest v0.19.0 h1:BEbF7ZBB6qQloV/Ub1+3NQoOUnVtcGkU3XX4Ws3GQfk= go.opentelemetry.io/otel/sdk/log/logtest v0.19.0/go.mod h1:Lua81/3yM0wOmoHTokLj9y9ADeA02v1naRrVrkAZuKk= -go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE= +go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4= +go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= +go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= -go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= -go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= +go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= @@ -808,10 +817,11 @@ go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN8 go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= -go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= go4.org v0.0.0-20180809161055-417644f6feb5/go.mod h1:MkTOUMDaeVYJUOUsaDXIhWPZYa1yOyC1qaOBpL57BhE= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= @@ -838,6 +848,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -865,8 +877,8 @@ golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -913,8 +925,8 @@ golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -948,8 +960,8 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -1018,12 +1030,11 @@ golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1033,8 +1044,8 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -1097,8 +1108,8 @@ golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -1216,10 +1227,10 @@ google.golang.org/genproto v0.0.0-20211129164237-f09f9a12af12/go.mod h1:5CzLGKJ6 google.golang.org/genproto v0.0.0-20211203200212-54befc351ae9/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= -google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 h1:ax2KzoSRIZU/M0cIxri3pKxy99vniH1PVxWC6si/eZI= +google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688/go.mod h1:1RJ9BQGyNdZwkGc1eTqkErfRZ6RJyYPHZo73BZ1vQqI= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 h1:cYNAzI2sUwhmCcoj9TxvihSrqsxt6uIkj3rDRhSDmW4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio= google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= @@ -1250,8 +1261,8 @@ google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnD google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= -google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= -google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= +google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= @@ -1266,14 +1277,12 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= @@ -1302,24 +1311,24 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -k8s.io/api v0.35.3 h1:pA2fiBc6+N9PDf7SAiluKGEBuScsTzd2uYBkA5RzNWQ= -k8s.io/api v0.35.3/go.mod h1:9Y9tkBcFwKNq2sxwZTQh1Njh9qHl81D0As56tu42GA4= -k8s.io/apiextensions-apiserver v0.35.3 h1:2fQUhEO7P17sijylbdwt0nBdXP0TvHrHj0KeqHD8FiU= -k8s.io/apiextensions-apiserver v0.35.3/go.mod h1:tK4Kz58ykRpwAEkXUb634HD1ZAegEElktz/B3jgETd8= -k8s.io/apimachinery v0.35.3 h1:MeaUwQCV3tjKP4bcwWGgZ/cp/vpsRnQzqO6J6tJyoF8= -k8s.io/apimachinery v0.35.3/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= -k8s.io/apiserver v0.35.3 h1:D2eIcfJ05hEAEewoSDg+05e0aSRwx8Y4Agvd/wiomUI= -k8s.io/apiserver v0.35.3/go.mod h1:JI0n9bHYzSgIxgIrfe21dbduJ9NHzKJ6RchcsmIKWKY= -k8s.io/client-go v0.35.3 h1:s1lZbpN4uI6IxeTM2cpdtrwHcSOBML1ODNTCCfsP1pg= -k8s.io/client-go v0.35.3/go.mod h1:RzoXkc0mzpWIDvBrRnD+VlfXP+lRzqQjCmKtiwZ8Q9c= -k8s.io/component-base v0.35.3 h1:mbKbzoIMy7JDWS/wqZobYW1JDVRn/RKRaoMQHP9c4P0= -k8s.io/component-base v0.35.3/go.mod h1:IZ8LEG30kPN4Et5NeC7vjNv5aU73ku5MS15iZyvyMYk= -k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= -k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 h1:HhDfevmPS+OalTjQRKbTHppRIz01AWi8s45TMXStgYY= -k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= -k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM= -k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +k8s.io/api v0.37.0 h1:Z//Vj9N7RA/yS2sDmxyeo7h+RR4zbUrd2vrd3Z0TbB4= +k8s.io/api v0.37.0/go.mod h1:LKXgcJWMc+f4OLbP5SFR8rulEg07zZhpi/zMULiBImk= +k8s.io/apiextensions-apiserver v0.37.0 h1:zRMQ3+/LIE5oZ0tVvXwYHC+dIkSP5cjNWju7AZU1LOI= +k8s.io/apiextensions-apiserver v0.37.0/go.mod h1:HU0PfSBwchHL5iDau6jjt9zU6ryWkDDlaVUiq91NK80= +k8s.io/apimachinery v0.37.0 h1:Np2AbDtf8x6RDHiD8T9LbKJ9gaegeVNa8yNm5FuGKm0= +k8s.io/apimachinery v0.37.0/go.mod h1:RN3nhprFSCxOi5Selxd7oMTXOe/c+ZbcE7Im+TS2zkE= +k8s.io/apiserver v0.37.0 h1:TXg7OxsOWrAH8J4Zi/gBAZuMw1Dfdd+6cca2h4qjRqo= +k8s.io/apiserver v0.37.0/go.mod h1:OddHDF4gy9qyIb8o/3+qaeP6S0vEObWLgOygVqXksv0= +k8s.io/client-go v0.37.0 h1:nsN31fy8wBySuZ+QRnKmrjRSQLOG2rvoGN0tKd12zhQ= +k8s.io/client-go v0.37.0/go.mod h1:FcGqw+Ll/gNQiq+nPGY1Oyt9y7SgDh1d3MW3RFDEbn0= +k8s.io/component-base v0.37.0 h1:3SdSa4+itMdFTDFTeR8CxKGmSTSMXFlKL4ky8OqjguM= +k8s.io/component-base v0.37.0/go.mod h1:LjOebp4R9y6LODWZQv102ZQxGheLcDO2ZJLAw6bbh4I= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= +k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad h1:oXImqH8mQNk7PmvzKhmN3ddJoY6OnyM225MXwGHPm0A= +k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I= +k8s.io/utils v0.0.0-20260626114624-be93311217bd h1:Ea7fgQ5we8Y9T0OX5o0dAHzQOBRI07D/dEYRaB9ZZEs= +k8s.io/utils v0.0.0-20260626114624-be93311217bd/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= @@ -1329,8 +1338,8 @@ sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5E sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= -sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.4.2 h1:qdOxHwrl2Kaag1aQEarlYcOA9vSyGCp3CIki3aW8c4Q= +sigs.k8s.io/structured-merge-diff/v6 v6.4.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= sourcegraph.com/sourcegraph/go-diff v0.5.0/go.mod h1:kuch7UrkMzY0X+p9CRK03kfuPQ2zzQcaEFbx8wA8rck= From 31a0e0a0c329d77644770bba791317c46ecc42d2 Mon Sep 17 00:00:00 2001 From: Dmytro Rashko Date: Mon, 21 Sep 2026 15:35:21 +0200 Subject: [PATCH 13/21] docs: add migration spec set and CLAUDE.md agent guide Add the specification documents produced for the mark3labs/mcp-go -> official go-sdk migration, plus a Claude Code entry point. specs/ holds the migration materials: requirements, design, plan, research comparison and the skill notes. specs/tools/001-migrate-mcp-go-to-official-sdk/ is the current copy and carries the run summary; the older specs/migrate-mcp-go-to-official-sdk/ copy shares five byte-identical files. CLAUDE.md mirrors the repository guide for Claude Code. Signed-off-by: Dmytro Rashko --- CLAUDE.md | 209 ++++++ .../migrate-mcp-go-to-official-sdk/design.md | 472 ++++++++++++++ specs/migrate-mcp-go-to-official-sdk/plan.md | 535 ++++++++++++++++ .../requirements.md | 25 + .../research/sdk-comparison.md | 222 +++++++ .../rough-idea.md | 20 + specs/migrate-mcp-go-to-official-sdk/skill.md | 442 +++++++++++++ .../PROMPT.md | 0 .../design.md | 472 ++++++++++++++ .../plan.md | 597 ++++++++++++++++++ .../requirements.md | 25 + .../research/sdk-comparison.md | 222 +++++++ .../rough-idea.md | 20 + .../skill.md | 442 +++++++++++++ .../summary.md | 20 + 15 files changed, 3723 insertions(+) create mode 100644 CLAUDE.md create mode 100644 specs/migrate-mcp-go-to-official-sdk/design.md create mode 100644 specs/migrate-mcp-go-to-official-sdk/plan.md create mode 100644 specs/migrate-mcp-go-to-official-sdk/requirements.md create mode 100644 specs/migrate-mcp-go-to-official-sdk/research/sdk-comparison.md create mode 100644 specs/migrate-mcp-go-to-official-sdk/rough-idea.md create mode 100644 specs/migrate-mcp-go-to-official-sdk/skill.md create mode 100644 specs/tools/001-migrate-mcp-go-to-official-sdk/PROMPT.md create mode 100644 specs/tools/001-migrate-mcp-go-to-official-sdk/design.md create mode 100644 specs/tools/001-migrate-mcp-go-to-official-sdk/plan.md create mode 100644 specs/tools/001-migrate-mcp-go-to-official-sdk/requirements.md create mode 100644 specs/tools/001-migrate-mcp-go-to-official-sdk/research/sdk-comparison.md create mode 100644 specs/tools/001-migrate-mcp-go-to-official-sdk/rough-idea.md create mode 100644 specs/tools/001-migrate-mcp-go-to-official-sdk/skill.md create mode 100644 specs/tools/001-migrate-mcp-go-to-official-sdk/summary.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..eea798eb --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,209 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Quick Reference + +### Build & Test +```bash +make build # Build all platform binaries +make test # Run tests with coverage and linting +make lint # Run golangci-lint +make lint-fix # Auto-fix linting issues +make fmt # Format code with go fmt +``` + +### Run Locally +```bash +go run ./cmd # Run directly +./bin/kagent-tools --stdio # Stdio transport +./bin/kagent-tools --http --port 8084 # HTTP transport +``` + +### Test Specific Components +```bash +go test -v ./pkg/k8s # Test specific package +go test -v -cover ./... # All tests with coverage +``` + +## Architecture Overview + +This is a Go-based MCP (Model Context Protocol) server that wraps Kubernetes and cloud-native tool CLIs. Rather than reimplementing functionality, it provides a unified MCP interface to existing command-line tools. + +### Core Design +- **Single responsibility packages**: Each `pkg/` subdirectory handles one tool category (k8s, helm, istio, etc.) +- **CLI wrapper pattern**: Tools call external CLIs (kubectl, helm, istioctl, etc.) and return formatted results +- **MCP SDK integration**: Uses `github.com/modelcontextprotocol/go-sdk` for all tool registration and communication +- **Multiple transports**: Supports stdio (for direct client integration) and HTTP/SSE (for web integration) +- **Type-safe parameters**: All tool parameters validated using `request.RequireString()`, `request.RequireBool()`, etc. + +### Package Structure +``` +pkg/ +├── k8s/ # Kubernetes operations via kubectl +├── helm/ # Helm package management +├── istio/ # Istio service mesh via istioctl +├── argo/ # Argo Rollouts via kubectl plugins +├── cilium/ # Cilium CNI operations +├── prometheus/ # Prometheus API queries +├── utils/ # Common shell command execution +├── logger/ # Structured logging +``` + +### Key Implementation Files +- `cmd/main.go`: MCP server setup, CLI flag handling, transport initialization +- `pkg/[category]/[category].go`: Tool registration and handler implementation +- Tool handlers follow: parse params → execute CLI → format result → return MCP result + +## Development Practices + +### MCP Tool Implementation Pattern +When adding a new tool, follow this structure: + +1. **Define in RegisterTools()**: Use `mcp.NewTool()` with parameters +2. **Type-safe parsing**: Use `request.RequireString()`, `request.RequireBool()` for validation +3. **CLI execution**: Use `runCommand()` utility for consistent error handling +4. **Result formatting**: Return `mcp.NewToolResultText()` for success or `mcp.NewToolResultError()` for failures + +Example from existing code (pkg/k8s/k8s.go style): +```go +func (t *Tools) RegisterTools(server *mcp.Server) error { + tool := mcp.NewTool("tool_name", + mcp.WithDescription("What this tool does"), + mcp.WithString("param", mcp.Required(), mcp.Description("Parameter description")), + ) + server.AddTool(tool, t.handleToolName) + return nil +} + +func (t *Tools) handleToolName(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + param, err := request.RequireString("param") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + result, err := runCommand(ctx, "external-cli", []string{param}) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed: %v", err)), nil + } + return mcp.NewToolResultText(result), nil +} +``` + +### Testing Requirements ⚠️ 80% Coverage Required + +**IMPORTANT**: This project enforces 80% test coverage. This is a hard requirement: +- **Overall threshold**: 80% coverage across entire codebase (CI enforces this) +- **Per-package minimum**: 70% for all packages +- **Critical packages**: 90% for k8s, helm, istio, argo (tool wrapper packages) +- **Unit tests only**: Coverage calculated from unit tests (integration tests supplementary) + +**How to check coverage locally**: +```bash +make test # Runs tests with coverage +make coverage-report # Generates HTML report (open coverage.html) +go test -cover ./pkg/example # Check specific package +``` + +**How to improve coverage**: +1. Run `make coverage-report` and open `coverage.html` +2. Find red (uncovered) lines in your package +3. Write table-driven tests for uncovered functions +4. Run `make test` again to verify improvement +5. See coverage.md for detailed guidance + +**Testing Patterns** (follow these strictly): +- **Table-driven tests**: Recommended for all scenarios (see examples in pkg/k8s/*_test.go) +- **Mock external dependencies**: Don't test kubectl/helm directly, test our wrappers +- **Test error paths**: Not just happy path (error handling must be covered) +- **Test edge cases**: Boundary conditions, empty inputs, etc. +- **Integration tests** in `test/integration/`: For testing actual tool execution + +**CI Enforcement**: Coverage check is automated in CI pipeline: +- Build fails if overall coverage < 80% +- Build fails if any package < 70% +- Build fails if critical packages < 90% +- Cannot merge without passing coverage check + +**See Also**: coverage.md (detailed coverage guide), quickstart.md (developer quick start) + +### Code Quality +- Run `make lint` before submitting changes +- Use `go fmt ./...` for formatting (also: `make fmt`) +- Keep functions focused and testable +- Use context for cancellation in long-running operations + +## Common Tasks + +### Adding a New Tool +1. Create function in appropriate `pkg/[category]/` file +2. Register with MCP SDK using `mcp.NewTool()` in `RegisterTools()` +3. Parse params with `request.RequireString()`, `request.RequireBool()`, etc. +4. Execute using `runCommand()` utility +5. Return results using `mcp.NewToolResultText()` or `mcp.NewToolResultError()` +6. Add unit tests with 80%+ coverage +7. Update README.md tool list + +### Debugging +```bash +LOG_LEVEL=debug go run ./cmd # Debug logging +go run ./cmd --stdio # Stdio transport (easier to debug) +``` + +### Docker Testing +```bash +make docker-build # Build Docker image +make run # Run in Docker +``` + +### Integration with External Tools +Most tools depend on these being installed and in PATH: +- `kubectl` - for k8s tools +- `helm` - for helm tools +- `istioctl` - for istio tools +- `cilium` - for cilium tools + +The `KUBECONFIG` environment variable is respected by k8s tools. + +## Important Design Notes + +### Why CLI Wrappers? +This approach allows: +- Minimal dependencies (no large Go SDK libraries) +- Feature parity with latest CLI versions +- Users can test locally without complex setup +- Easy to keep in sync with upstream tools + +### Error Handling +- Always wrap errors with context: `fmt.Errorf("failed to do X: %w", err)` +- Return MCP errors using `mcp.NewToolResultError()` with descriptive messages +- External tool failures are caught and returned as readable errors + +### Logging +Use structured logging via logr (see pkg/logger/): +```go +logger := logr.FromContextOrDiscard(ctx) +logger.Info("executing command", "command", cmd, "args", args) +logger.Error(err, "command failed", "command", cmd) +``` + +## Contribution Standards + +From CONTRIBUTION.md - key principles: +- **Principle I**: Use official MCP SDK patterns +- **Principle II**: Type-safe input validation +- **Principle III**: Write tests BEFORE implementation (TDD) +- **Principle IV**: Modular packages under `pkg/` +- **Principle V**: Structured logging and input sanitization + +Follow Conventional Commits: +- `feat(scope): description` - New feature +- `fix(scope): description` - Bug fix +- `test(scope): description` - Test changes +- `docs(scope): description` - Documentation + +## Active Technologies +- Go 1.x (from go.mod and project setup) + Go standard library, testing libraries (built-in), MCP SDK from `github.com/modelcontextprotocol/go-sdk` (002-test-coverage) +- N/A (test coverage is metadata-only) (002-test-coverage) + +## Recent Changes +- 002-test-coverage: Added Go 1.x (from go.mod and project setup) + Go standard library, testing libraries (built-in), MCP SDK from `github.com/modelcontextprotocol/go-sdk` diff --git a/specs/migrate-mcp-go-to-official-sdk/design.md b/specs/migrate-mcp-go-to-official-sdk/design.md new file mode 100644 index 00000000..0addfe8d --- /dev/null +++ b/specs/migrate-mcp-go-to-official-sdk/design.md @@ -0,0 +1,472 @@ +# Design: Migrate mark3labs/mcp-go → modelcontextprotocol/go-sdk + +## Overview + +This document describes the design for replacing the community MCP Go SDK +(`github.com/mark3labs/mcp-go v0.43.2`) with the official MCP Go SDK +(`github.com/modelcontextprotocol/go-sdk`) across the `kagent-tools` server. + +The migration is a **drop-in SDK swap with a type-safety uplift**: all externally +visible behaviour (tool names, parameter names, transport protocols) is preserved, +while the internal implementation switches from dynamic map-based parameter parsing +to concrete Go struct types. + +No new tools are added. No tools are removed. No CLI flags change. + +--- + +## Detailed Requirements + +### R1 — Concrete Go struct types (no `map[any]any`) + +Every tool handler MUST receive its parameters as a named, exported Go struct. +Dynamic maps (`map[string]any`, `map[string]interface{}`, `map[any]any`) are +forbidden as tool parameter containers. Existing uses of `map[string]interface{}` +in `ToolError.Context` must also be replaced with a concrete type. + +### R2 — Full feature parity + +All 40+ tools across eight packages (k8s, helm, istio, argo, cilium, prometheus, +kubescape, utils) must be registered and functional after migration. Tool names, +parameter names, and descriptions must match the current implementation exactly. + +### R3 — Both transports preserved + +The server must continue to support: +- **stdio** (`--stdio` flag): communicates over stdin/stdout +- **HTTP Streamable** (default): listens on `--port` (default 8084) + +### R4 — Telemetry / OpenTelemetry tracing preserved + +The OTel tracing middleware that records tool name, arguments, duration, and +error state on every `tools/call` invocation must be rewritten using the +official SDK's `AddReceivingMiddleware` API. No tracing spans may be lost. + +### R5 — 80 % overall / 70 % per-package / 90 % critical-package coverage + +Test coverage thresholds defined in CLAUDE.md are unchanged. All updated +packages must pass `make test` after migration. + +### R6 — No breaking changes to the public `RegisterTools` interface + +Each `pkg/*/` package exposes `RegisterTools(s *mcp.Server, ...)`. The function +signature changes only the type of the first argument (from `*server.MCPServer` +to `*mcp.Server`). Callers in `cmd/main.go` are updated accordingly. + +--- + +## Architecture Overview + +```mermaid +graph TD + subgraph cmd + main["cmd/main.go
cobra CLI"] + end + + subgraph internal + tel["internal/telemetry
OTel middleware"] + errs["internal/errors
ToolError → MCP result"] + mcputil["internal/mcputil ← NEW
TextResult / ErrorResult helpers"] + end + + subgraph sdk ["github.com/modelcontextprotocol/go-sdk/mcp"] + Server["mcp.Server"] + AddTool["mcp.AddTool[In,Out]"] + Transports["StdioTransport
StreamableHTTPHandler"] + Middleware["AddReceivingMiddleware"] + end + + subgraph tools ["pkg/*"] + k8s; helm; istio; argo; cilium; prometheus; kubescape; utils + end + + main -->|"NewServer + transports"| sdk + main -->|"registerMCP"| tools + main -->|"AddReceivingMiddleware"| tel + tools -->|"mcp.AddTool + *Params structs"| AddTool + tools -->|"mcputil.TextResult / ErrorResult"| mcputil + errs -->|"&mcp.CallToolResult{IsError:true}"| sdk + mcputil -->|"&mcp.CallToolResult{Content:[...]}"| sdk + tel -->|"mcp.Middleware"| Middleware +``` + +### Key Architectural Decisions + +| Decision | Rationale | +|----------|-----------| +| Use generic `mcp.AddTool[In, Out]` (not low-level `server.AddTool`) | Auto-derives JSON schema from struct tags; eliminates manual `mcp.WithString/Bool/Number` option calls | +| Introduce `internal/mcputil` package | Single source for `TextResult`/`ErrorResult` helpers; avoids duplicating `&mcp.CallToolResult{...}` literals across 40+ handlers | +| Replace per-handler `WithTracing` wrapper with server-level middleware | Cleaner separation; one middleware intercepts all tool calls; no adapter boilerplate per handler | +| Replace `ToolError.Context map[string]interface{}` with `map[string]string` | Satisfies R1; `interface{}` was only ever used with string values | + +--- + +## Components and Interfaces + +### `internal/mcputil` (new package) + +```go +package mcputil + +import "github.com/modelcontextprotocol/go-sdk/mcp" + +// TextResult wraps a plain text string in a successful CallToolResult. +func TextResult(text string) *mcp.CallToolResult + +// ErrorResult wraps an error message in a tool-error CallToolResult (IsError=true). +func ErrorResult(msg string) *mcp.CallToolResult +``` + +### `internal/telemetry/middleware.go` (rewritten) + +```go +// Before +type ToolHandler func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) +func WithTracing(toolName string, handler ToolHandler) ToolHandler +func AdaptToolHandler(th ToolHandler) server.ToolHandlerFunc + +// After +// NewTracingMiddleware returns a mcp.Middleware that records OTel spans for +// every tools/call invocation. The tool name is read from req.(*mcp.CallToolRequest).Params.Name. +func NewTracingMiddleware() mcp.Middleware +``` + +All other telemetry helpers (`HTTPMiddleware`, `ExtractHTTPHeaders`, `StartSpan`, +`RecordError`, `RecordSuccess`, `AddEvent`) are unchanged. + +### `internal/errors/tool_errors.go` + +```go +// Context field type change +type ToolError struct { + // ... + Context map[string]string `json:"context,omitempty"` // was map[string]interface{} +} + +// ToMCPResult — result type changes; import changes from mark3labs to go-sdk +func (e *ToolError) ToMCPResult() *mcp.CallToolResult { + return mcputil.ErrorResult(message.String()) +} + +// WithContext parameter type change +func (e *ToolError) WithContext(key string, value string) *ToolError +``` + +### `pkg/*/` — Tool handler pattern + +Every handler is converted to the typed `ToolHandlerFor` pattern: + +```go +// Params struct — one per tool +type Params struct { + Field string `json:"field_name" jsonschema:"description[,required][,default=val]"` + // ... +} + +// Handler +func handle( + ctx context.Context, + req *mcp.CallToolRequest, + args Params, +) (*mcp.CallToolResult, any, error) { + // args.Field is already populated + return mcputil.TextResult(result), nil, nil +} + +// Registration +func RegisterTools(s *mcp.Server, readOnly bool) { + mcp.AddTool(s, &mcp.Tool{ + Name: "tool_name", + Description: "...", + }, handle) +} +``` + +### `cmd/main.go` + +```go +// Server creation +mcpServer := mcp.NewServer(&mcp.Implementation{Name: Name, Version: Version}, nil) + +// Middleware +mcpServer.AddReceivingMiddleware(telemetry.NewTracingMiddleware()) + +// Tool registration map +toolProviderMap := map[string]func(*mcp.Server){ + "k8s": func(s *mcp.Server) { k8s.RegisterTools(s, nil, kubeconfig, readOnly) }, + // ... +} + +// Stdio transport +func runStdioServer(ctx context.Context, s *mcp.Server) { + if err := s.Run(ctx, &mcp.StdioTransport{}); err != nil { ... } +} + +// HTTP transport +handler := mcp.NewStreamableHTTPHandler( + func(r *http.Request) *mcp.Server { return mcpServer }, + nil, +) +mux.Handle("/", telemetry.HTTPMiddleware(handler)) +``` + +--- + +## Data Models + +### Params Structs per Package + +All structs use `json` tags for field names and `jsonschema` tags for descriptions +and constraints. Required fields have `,required` appended to the jsonschema tag. + +#### `pkg/k8s` — KubectlGetParams (representative) + +```go +type KubectlGetParams struct { + ResourceType string `json:"resource_type" jsonschema:"K8s resource type (pod/deploy/svc),required"` + ResourceName string `json:"resource_name" jsonschema:"name of the specific resource"` + Namespace string `json:"namespace" jsonschema:"namespace to query"` + AllNamespaces bool `json:"all_namespaces" jsonschema:"query across all namespaces"` + Output string `json:"output" jsonschema:"output format (wide/json/yaml),default=wide"` +} +type KubectlLogsParams struct { + PodName string `json:"pod_name" jsonschema:"pod name,required"` + Namespace string `json:"namespace" jsonschema:"namespace,default=default"` + Container string `json:"container" jsonschema:"container name"` + TailLines int `json:"tail_lines" jsonschema:"number of log lines,default=50"` +} +type ScaleDeploymentParams struct { + Name string `json:"name" jsonschema:"deployment name,required"` + Namespace string `json:"namespace" jsonschema:"namespace,default=default"` + Replicas int `json:"replicas" jsonschema:"desired replica count,default=1"` +} +// ... one struct per handler, following the same pattern +``` + +#### `pkg/helm` (representative) + +```go +type HelmListParams struct { + Namespace string `json:"namespace" jsonschema:"filter by namespace"` + AllNamespaces bool `json:"all_namespaces" jsonschema:"list across all namespaces"` + All bool `json:"all" jsonschema:"show all releases including non-deployed"` + Uninstalled bool `json:"uninstalled" jsonschema:"show uninstalled releases"` + Failed bool `json:"failed" jsonschema:"show failed releases"` + Deployed bool `json:"deployed" jsonschema:"show deployed releases"` + Pending bool `json:"pending" jsonschema:"show pending releases"` + Filter string `json:"filter" jsonschema:"regex filter for release names"` + Output string `json:"output" jsonschema:"output format (table/json/yaml)"` +} +type HelmGetReleaseParams struct { + Name string `json:"name" jsonschema:"release name,required"` + Namespace string `json:"namespace" jsonschema:"namespace,required"` + Output string `json:"output" jsonschema:"output format (all/hooks/manifest/notes/values)"` +} +// ... one struct per handler +``` + +#### `pkg/argo` (representative) + +```go +type VerifyArgoControllerParams struct { + Namespace string `json:"namespace" jsonschema:"namespace to check,default=argo-rollouts"` + Label string `json:"label" jsonschema:"pod label selector,default=app.kubernetes.io/component=rollouts-controller"` +} +type PromoteRolloutParams struct { + RolloutName string `json:"rollout_name" jsonschema:"name of the rollout,required"` + Namespace string `json:"namespace" jsonschema:"namespace"` + Full bool `json:"full" jsonschema:"fully promote skipping all pauses"` +} +// ... +``` + +#### `pkg/cilium` (representative) + +```go +type UpgradeCiliumParams struct { + ClusterName string `json:"cluster_name" jsonschema:"cluster name"` + DatapathMode string `json:"datapath_mode" jsonschema:"datapath mode (tunnel/native-routing)"` +} +type InstallCiliumParams struct { + ClusterName string `json:"cluster_name" jsonschema:"cluster name"` + ClusterID string `json:"cluster_id" jsonschema:"unique cluster ID for cluster mesh"` + DatapathMode string `json:"datapath_mode" jsonschema:"datapath mode"` +} +type ConnectRemoteClusterParams struct { + ClusterName string `json:"cluster_name" jsonschema:"remote cluster name,required"` + Context string `json:"context" jsonschema:"kubeconfig context for remote cluster"` +} +type ToggleHubbleParams struct { + Enable bool `json:"enable" jsonschema:"true to enable Hubble,default=true"` +} +// ... +``` + +--- + +## Error Handling + +### Tool-level errors (visible to the LLM) + +Returned as `*mcp.CallToolResult` with `IsError: true`. The LLM sees the error +text as tool output and can reason about it. + +```go +// All paths that previously called mcp.NewToolResultError(msg): +return mcputil.ErrorResult(msg), nil, nil + +// ToolError.ToMCPResult(): +return mcputil.ErrorResult(message.String()) +``` + +### Protocol-level errors (terminates the JSON-RPC call) + +Returned as the `error` return value. Reserved for unexpected internal failures +that the LLM cannot meaningfully recover from. + +```go +return nil, nil, fmt.Errorf("internal error: %w", err) +``` + +### Validation + +Required fields in param structs are validated automatically by the SDK before +the handler is called. Manual `if param == "" { return error }` guards in +handlers are removed where the field is declared `required` in the jsonschema tag. +Optional guards for business logic remain. + +--- + +## Acceptance Criteria + +### AC-1: Dependency update + +**Given** `go.mod` is updated to remove `github.com/mark3labs/mcp-go` +**When** `go mod tidy` is run +**Then** no references to `mark3labs/mcp-go` remain in `go.mod` or `go.sum` + +### AC-2: No dynamic maps in tool params + +**Given** the migrated codebase +**When** `grep -r "map\[string\]any\|map\[string\]interface{}" pkg/ internal/` is run +**Then** zero matches are found inside tool handler functions or param types + +### AC-3: All tools register and are discoverable + +**Given** the server is started in stdio mode +**When** a `tools/list` request is sent +**Then** all tool names present before migration are returned in the response + +### AC-4: Stdio transport works + +**Given** the server binary is run with `--stdio` +**When** a `tools/call` JSON-RPC request is piped to stdin +**Then** a valid JSON-RPC response with tool result is written to stdout + +### AC-5: HTTP/Streamable transport works + +**Given** the server is started without `--stdio` on port 8084 +**When** an HTTP MCP client connects and calls a tool +**Then** the response is returned with correct content + +### AC-6: Telemetry traces recorded + +**Given** an OTel exporter is configured +**When** a tool call is made +**Then** a span named `mcp.tool.` is recorded with `mcp.tool.name` and duration attributes + +### AC-7: Test coverage thresholds pass + +**Given** `make test` is run +**Then** overall coverage ≥ 80%, per-package ≥ 70%, critical packages ≥ 90% + +### AC-8: Linter passes + +**Given** `make lint` is run +**Then** zero linting errors are reported + +--- + +## Testing Strategy + +### Unit tests (primary) + +Each `pkg/*/` package uses the table-driven pattern from CLAUDE.md. After migration, +tests call the typed handler directly: + +```go +func TestHandleKubectlGet(t *testing.T) { + cases := []struct { + name string + args KubectlGetParams + wantErr bool + }{ + {name: "missing resource_type", args: KubectlGetParams{}, wantErr: true}, + {name: "valid get pods", args: KubectlGetParams{ResourceType: "pod", Namespace: "default"}, wantErr: false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + result, _, err := handleKubectlGet(context.Background(), &mcp.CallToolRequest{}, tc.args) + // assert + }) + } +} +``` + +No need to construct `mcp.CallToolRequest.Params.Arguments` maps in unit tests — +args are passed directly to the handler function. + +### Middleware tests + +`internal/telemetry/middleware_test.go` uses an in-memory transport pair +(`mcp.NewInMemoryTransports()`) to exercise the full request-response cycle +including middleware. + +### Integration / E2E tests + +`test/e2e/helpers_test.go` starts the full server binary and exercises both +transports. These tests are unchanged in scope; only the client-side MCP +type imports are updated. + +--- + +## Appendices + +### A. Technology Choices + +| Component | Choice | Reason | +|-----------|--------|--------| +| MCP SDK | `github.com/modelcontextprotocol/go-sdk` | Official Anthropic/MCP Foundation SDK; long-term support; supports MCP spec 2025-06-18 | +| HTTP transport | `mcp.NewStreamableHTTPHandler` | Implements MCP spec 2025-03-26 streamable HTTP; supersedes legacy SSE | +| Schema generation | `mcp.AddTool[In, Out]` generics | Auto-derives JSON schema from struct tags; eliminates boilerplate | +| Result helpers | `internal/mcputil.TextResult/ErrorResult` | Single source of truth; go-sdk has no built-in equivalents | + +### B. API Mapping Summary + +| mark3labs | go-sdk | +|-----------|--------| +| `server.NewMCPServer(n,v)` | `mcp.NewServer(&mcp.Implementation{Name:n,Version:v}, nil)` | +| `server.NewStdioServer(s).Listen(ctx,in,out)` | `s.Run(ctx, &mcp.StdioTransport{})` | +| `server.NewStreamableHTTPServer(s,opts)` | `mcp.NewStreamableHTTPHandler(func(r)*mcp.Server{return s}, nil)` | +| `mcp.ParseString(req,k,d)` | Struct field with `json` tag | +| `mcp.ParseInt(req,k,d)` | Struct field with `json` tag | +| `mcp.NewTool(name, opts...)` | `&mcp.Tool{Name:"...",Description:"..."}` | +| `s.AddTool(tool, handler)` | `mcp.AddTool(s, tool, typedHandler)` | +| `mcp.NewToolResultText(t)` | `mcputil.TextResult(t)` | +| `mcp.NewToolResultError(t)` | `mcputil.ErrorResult(t)` | +| handler `(req, err)` 2-return | handler `(req, any, err)` 3-return | +| `server.ToolHandlerFunc` | `mcp.ToolHandlerFor[In, Out]` | +| `server.AdaptToolHandler` | `mcp.Middleware` via `AddReceivingMiddleware` | + +### C. Alternative Approaches Considered + +**Keep low-level `server.AddTool` with manual schemas** — rejected. This would +require replicating the existing `mcp.WithString/Bool/Number` boilerplate in a +new form and would not achieve R1 (typed structs). + +**Use `map[string]any` args in handlers** — rejected. Explicitly forbidden by R1 +and is a regression in type safety compared to even the mark3labs API. + +**Introduce a compatibility shim layer** — rejected. A thin adapter keeping the +old signatures would prevent tests from using the cleaner direct-invocation +pattern and would accumulate technical debt. diff --git a/specs/migrate-mcp-go-to-official-sdk/plan.md b/specs/migrate-mcp-go-to-official-sdk/plan.md new file mode 100644 index 00000000..6f44577c --- /dev/null +++ b/specs/migrate-mcp-go-to-official-sdk/plan.md @@ -0,0 +1,535 @@ +# Implementation Plan: mark3labs/mcp-go → modelcontextprotocol/go-sdk + +## Checklist + +- [ ] Step 1: Swap dependency and establish build baseline +- [ ] Step 2: Create `internal/mcputil` helpers +- [ ] Step 3: Migrate `internal/errors` — fix `ToolError` +- [ ] Step 4: Migrate `internal/telemetry` — rewrite to `mcp.Middleware` +- [ ] Step 5: Migrate `pkg/utils` +- [ ] Step 6: Migrate `pkg/prometheus` +- [ ] Step 7: Migrate `pkg/argo` +- [ ] Step 8: Migrate `pkg/cilium` +- [ ] Step 9: Migrate `pkg/helm` +- [ ] Step 10: Migrate `pkg/istio` +- [ ] Step 11: Migrate `pkg/k8s` +- [ ] Step 12: Migrate `pkg/kubescape` +- [ ] Step 13: Migrate `cmd/main.go` — wire everything together +- [ ] Step 14: Update E2E test helpers +- [ ] Step 15: Final validation + +--- + +## Step 1: Swap dependency and establish build baseline + +**Objective:** Replace the mark3labs dependency with the official SDK so every +subsequent step compiles against the new API from the start. + +**Implementation guidance:** +1. In `go.mod`, remove the `github.com/mark3labs/mcp-go` line. +2. Run `go get github.com/modelcontextprotocol/go-sdk@latest`. +3. Run `go mod tidy`. +4. The project will NOT compile at this point — that is expected. Every file + that imports `mark3labs` will report errors. +5. Do NOT fix any files yet — just verify that `go mod` resolves the new SDK. + +**Test requirements:** +- `go mod verify` passes (module graph is consistent). +- `go list -m github.com/modelcontextprotocol/go-sdk` prints the resolved version. + +**Integration notes:** +- No code changes outside `go.mod`/`go.sum` in this step. +- Commit the `go.mod`/`go.sum` change independently for easy bisect. + +**Demo:** `go list -m github.com/modelcontextprotocol/go-sdk` outputs the new version. + +--- + +## Step 2: Create `internal/mcputil` helpers + +**Objective:** Provide `TextResult` and `ErrorResult` helper functions that all +tool packages will use. Having these in place before migrating any package avoids +writing raw `&mcp.CallToolResult{Content: ...}` literals 40+ times. + +**Implementation guidance:** +1. Create `internal/mcputil/mcputil.go`: +```go +package mcputil + +import "github.com/modelcontextprotocol/go-sdk/mcp" + +func TextResult(text string) *mcp.CallToolResult { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: text}}, + } +} + +func ErrorResult(msg string) *mcp.CallToolResult { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{&mcp.TextContent{Text: msg}}, + } +} +``` +2. Create `internal/mcputil/mcputil_test.go` with table-driven tests covering + both helpers (verify `IsError`, `Content[0].(*mcp.TextContent).Text`). + +**Test requirements:** +- `go test ./internal/mcputil/...` passes with 100% coverage. + +**Integration notes:** +- This package has no dependency on any `pkg/*` or other `internal` packages — + it can be compiled independently even while the rest of the codebase has errors. + +**Demo:** `go test ./internal/mcputil/...` reports PASS. + +--- + +## Step 3: Migrate `internal/errors` — fix `ToolError` + +**Objective:** Fix `ToMCPResult()` which calls `mcp.NewToolResultError` (does not +exist in go-sdk), and replace `map[string]interface{}` with `map[string]string` +in `ToolError.Context`. + +**Implementation guidance:** +1. Update import: remove `mark3labs/mcp-go/mcp`, add `kagent-dev/tools/internal/mcputil`. +2. Change `ToolError.Context` field type: `map[string]interface{}` → `map[string]string`. +3. Update `WithContext(key string, value interface{})` → `WithContext(key, value string)`. +4. Update `NewToolError` constructor: `Context: make(map[string]string)`. +5. Replace `ToMCPResult()` body: `return mcp.NewToolResultError(message.String())` → + `return mcputil.ErrorResult(message.String())`. +6. Update `WithContext` call sites in the same file (all callers pass string values). + +**Test requirements:** +- `go test ./internal/errors/...` passes. +- Existing tests updated to pass string values to `WithContext`. +- Coverage ≥ 70%. + +**Integration notes:** +- `internal/errors` depends only on `internal/mcputil` (already done in Step 2). +- `pkg/*` packages that call `WithContext` will need their call sites updated when + each package is migrated (Steps 5–12) — not required here. + +**Demo:** `go test ./internal/errors/... ./internal/mcputil/...` reports PASS. + +--- + +## Step 4: Migrate `internal/telemetry` — rewrite to `mcp.Middleware` + +**Objective:** Remove the per-handler `WithTracing` wrapper and the `AdaptToolHandler` +adapter. Replace with a single server-level `NewTracingMiddleware()` factory that +returns an `mcp.Middleware` and intercepts all tool calls. + +**Implementation guidance:** +1. In `middleware.go`: + - Remove `import "github.com/mark3labs/mcp-go/server"`. + - Change import to `"github.com/modelcontextprotocol/go-sdk/mcp"`. + - Delete type `ToolHandler`. + - Delete functions `WithTracing` and `AdaptToolHandler`. + - Add: +```go +// NewTracingMiddleware returns an mcp.Middleware that records an OTel span +// for every MCP method call, with richer attributes for tools/call. +func NewTracingMiddleware() mcp.Middleware { + return func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + tracer := otel.Tracer("kagent-tools/mcp") + spanName := fmt.Sprintf("mcp.method.%s", method) + + // Enrich span name and attributes for tool calls + toolName := "" + if ctr, ok := req.(*mcp.CallToolRequest); ok { + toolName = ctr.Params.Name + spanName = fmt.Sprintf("mcp.tool.%s", toolName) + } + + ctx, span := tracer.Start(ctx, spanName) + defer span.End() + + headers := ExtractHTTPHeaders(ctx) + for k, v := range headers { + span.SetAttributes(attribute.String(fmt.Sprintf("http.header.%s", k), v)) + } + if toolName != "" { + span.SetAttributes(attribute.String("mcp.tool.name", toolName)) + } + span.AddEvent("mcp.method.start") + start := time.Now() + + result, err := next(ctx, method, req) + + span.SetAttributes(attribute.Float64("mcp.tool.duration_seconds", time.Since(start).Seconds())) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } else { + span.SetStatus(codes.Ok, "completed") + if ctr, ok := result.(*mcp.CallToolResult); ok { + span.SetAttributes(attribute.Bool("mcp.result.is_error", ctr.IsError)) + span.SetAttributes(attribute.Int("mcp.result.content_count", len(ctr.Content))) + } + } + return result, err + } + } +} +``` + +2. Update `middleware_test.go`: + - Remove test for `WithTracing` and `AdaptToolHandler`. + - Add test for `NewTracingMiddleware` using `mcp.NewInMemoryTransports()` to + create a real client-server pair with middleware applied; verify span is recorded. + +**Test requirements:** +- `go test ./internal/telemetry/...` passes. +- Coverage ≥ 70%. +- `WithTracing` and `AdaptToolHandler` are not referenced anywhere. + +**Integration notes:** +- `cmd/main.go` will call `mcpServer.AddReceivingMiddleware(telemetry.NewTracingMiddleware())` + in Step 13. +- Until Step 13, `NewTracingMiddleware` is defined but not yet wired. + +**Demo:** `go test ./internal/telemetry/...` reports PASS. + +--- + +## Step 5: Migrate `pkg/utils` + +**Objective:** Update `pkg/utils/common.go` (and any related files) to use +go-sdk types. `pkg/utils` is a leaf package with no dependencies on other `pkg/*` +packages, making it the safest starting point. + +**Implementation guidance:** +1. Replace imports: remove `mark3labs/mcp-go/mcp` and `mark3labs/mcp-go/server`, + add `modelcontextprotocol/go-sdk/mcp` and `kagent-dev/tools/internal/mcputil`. +2. For each tool handler: + - Define a `Params` struct with `json` and `jsonschema` tags. + - Change handler signature to `func(ctx, *mcp.CallToolRequest, Params) (*mcp.CallToolResult, any, error)`. + - Replace `mcp.ParseString(request, ...)` with struct field access. + - Replace `mcp.NewToolResultText(...)` with `mcputil.TextResult(...)`. + - Replace `mcp.NewToolResultError(...)` with `mcputil.ErrorResult(...)`. +3. Update `RegisterTools` signature: `func RegisterTools(s *mcp.Server, readOnly bool)`. +4. Replace `s.AddTool(mcp.NewTool(...), handler)` with `mcp.AddTool(s, &mcp.Tool{...}, handler)`. +5. Update `*_test.go`: call handlers directly with typed args structs. + +**Test requirements:** +- `go test ./pkg/utils/...` passes. +- Coverage ≥ 70%. +- No references to `mark3labs` in package. + +**Demo:** `go test ./pkg/utils/...` PASS. + +--- + +## Step 6: Migrate `pkg/prometheus` + +**Objective:** Migrate the Prometheus query tools. This package also includes +`promql.go` which uses MCP types for result construction. + +**Implementation guidance:** +1. Same handler migration pattern as Step 5. +2. Key params structs to define: + - `PrometheusQueryParams` (query string, time range, step) + - `PrometheusQueryRangeParams` + - `PrometheusInstantQueryParams` +3. Update `promql.go` if it constructs `mcp.CallToolResult` directly — replace + with `mcputil.TextResult` / `mcputil.ErrorResult`. +4. Update `prometheus_test.go` to use typed args. + +**Test requirements:** +- `go test ./pkg/prometheus/...` passes. +- Coverage ≥ 70%. + +**Demo:** `go test ./pkg/prometheus/...` PASS. + +--- + +## Step 7: Migrate `pkg/argo` + +**Objective:** Migrate the 8 Argo Rollouts tool handlers. + +**Implementation guidance:** +1. Define params structs: + - `VerifyArgoControllerParams` (namespace, label) + - `VerifyKubectlPluginParams` (no params — empty struct `struct{}`) + - `ListRolloutsParams` (namespace, type) + - `CheckPluginLogsParams` (namespace, timeout) + - `PromoteRolloutParams` (rollout_name, namespace, full bool) + - `PauseRolloutParams` (rollout_name, namespace) + - `SetRolloutImageParams` (rollout_name, container_image, namespace) + - `VerifyGatewayPluginParams` (version, namespace, should_install bool) +2. For handlers with no parameters (e.g., `handleVerifyKubectlPluginInstall`), + use an empty struct: `type VerifyKubectlPluginParams struct{}`. +3. Remove `WithTracing` wrapping from `RegisterTools` — tracing is now server-wide. +4. Update `argo_test.go`. + +**Test requirements:** +- `go test ./pkg/argo/...` passes. +- Coverage ≥ 90% (critical package per CLAUDE.md). + +**Demo:** `go test ./pkg/argo/...` PASS with ≥ 90% coverage shown. + +--- + +## Step 8: Migrate `pkg/cilium` + +**Objective:** Migrate the 12 Cilium tool handlers. + +**Implementation guidance:** +1. Define params structs for each handler: + - `CiliumStatusParams` — empty struct + - `UpgradeCiliumParams` (cluster_name, datapath_mode) + - `InstallCiliumParams` (cluster_name, cluster_id, datapath_mode) + - `UninstallCiliumParams` — empty struct + - `ConnectRemoteClusterParams` (cluster_name required, context) + - `DisconnectRemoteClusterParams` (cluster_name required) + - `ListBGPPeersParams` — empty struct + - `ListBGPRoutesParams` — empty struct + - `ClusterMeshStatusParams` — empty struct + - `FeaturesStatusParams` — empty struct + - `ToggleHubbleParams` (enable bool, default=true) + - `ToggleClusterMeshParams` (enable bool, default=true) +2. For boolean-toggle handlers, note that bool default in jsonschema tag must be + specified: `jsonschema:"enable Hubble,default=true"`. +3. Update `cilium_test.go`. + +**Test requirements:** +- `go test ./pkg/cilium/...` passes. +- Coverage ≥ 90%. + +**Demo:** `go test ./pkg/cilium/...` PASS. + +--- + +## Step 9: Migrate `pkg/helm` + +**Objective:** Migrate the 6 Helm tool handlers. + +**Implementation guidance:** +1. Define params structs: + - `HelmListParams` (namespace, all_namespaces, all, uninstalled, failed, deployed, pending, filter, output) + - `HelmGetReleaseParams` (name required, namespace required, output) + - `HelmUpgradeParams` (name required, chart required, namespace, version, values_file, set, wait bool, timeout, create_namespace bool, install bool) + - `HelmUninstallParams` (name required, namespace required, keep_history bool) + - `HelmRepoAddParams` (name required, url required, username, password, force_update bool) + - `HelmRepoUpdateParams` — empty struct +2. Update `helm_test.go`. +3. Verify security validation calls (`security.ValidateName`, etc.) still occur + after struct population — these are business-logic checks that remain. + +**Test requirements:** +- `go test ./pkg/helm/...` passes. +- Coverage ≥ 90%. + +**Demo:** `go test ./pkg/helm/...` PASS. + +--- + +## Step 10: Migrate `pkg/istio` + +**Objective:** Migrate all Istio tool handlers. + +**Implementation guidance:** +1. Define params structs for each istio handler (proxy-status, analyze, install, + upgrade, verify-install, etc.) — follow the same struct pattern. +2. Update `istio_test.go`. + +**Test requirements:** +- `go test ./pkg/istio/...` passes. +- Coverage ≥ 90%. + +**Demo:** `go test ./pkg/istio/...` PASS. + +--- + +## Step 11: Migrate `pkg/k8s` + +**Objective:** Migrate the largest and most critical package — all kubectl-based +Kubernetes tool handlers. + +**Implementation guidance:** +1. Define params structs for all handlers: + - `KubectlGetParams`, `KubectlLogsParams`, `ScaleDeploymentParams`, + `PatchResourceParams`, `ApplyManifestParams`, `DeleteResourceParams`, + `CheckServiceConnectivityParams`, `GetEventsParams`, `ExecCommandParams`, + `GetAvailableAPIResourcesParams`, `DescribeResourceParams`, + `ManageAnnotationParams`, `ManageLabelParams`, `SetAnnotationsParams`, + and any others present. +2. Required fields identified from current `if param == "" { return error }` guards: + use `jsonschema:"...,required"` for these, then remove the redundant guard. +3. Keep non-trivial business-logic guards (e.g., security validation). +4. Update `k8s_test.go` — this is the largest test file; use table-driven tests + for all params variations. + +**Test requirements:** +- `go test ./pkg/k8s/...` passes. +- Coverage ≥ 90%. + +**Demo:** `go test ./pkg/k8s/...` PASS with ≥ 90% coverage. + +--- + +## Step 12: Migrate `pkg/kubescape` + +**Objective:** Migrate all Kubescape scan and report tool handlers. + +**Implementation guidance:** +1. Define params structs for each handler (scan, get vulnerability manifests, + get configuration scans, get application profiles, etc.). +2. Update `kubescape_test.go`. + +**Test requirements:** +- `go test ./pkg/kubescape/...` passes. +- Coverage ≥ 90%. + +**Demo:** `go test ./pkg/kubescape/...` PASS. + +--- + +## Step 13: Migrate `cmd/main.go` — wire everything together + +**Objective:** Update the entry point to use the new SDK server, transports, +and middleware. This is the integration step that makes the full binary compile +and run end-to-end. + +**Implementation guidance:** +1. Remove `import "github.com/mark3labs/mcp-go/server"`. +2. Add `import "github.com/modelcontextprotocol/go-sdk/mcp"`. +3. Replace server creation: +```go +mcpServer := mcp.NewServer(&mcp.Implementation{ + Name: Name, + Version: Version, +}, nil) +``` +4. Add telemetry middleware: +```go +mcpServer.AddReceivingMiddleware(telemetry.NewTracingMiddleware()) +``` +5. Update `toolProviderMap` type: `map[string]func(*mcp.Server)`. +6. Replace `runStdioServer`: +```go +func runStdioServer(ctx context.Context, s *mcp.Server) { + logger.Get().Info("Running KAgent Tools Server STDIO:", "tools", strings.Join(tools, ",")) + if err := s.Run(ctx, &mcp.StdioTransport{}); err != nil { + logger.Get().Info("Stdio server stopped", "error", err) + } +} +``` +7. Replace HTTP server setup: +```go +httpHandler := mcp.NewStreamableHTTPHandler( + func(r *http.Request) *mcp.Server { return mcpServer }, + nil, +) +mux.Handle("/", telemetry.HTTPMiddleware(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + httpHandler.ServeHTTP(w, r) + }, +))) +``` +8. Remove the `server.WithHeartbeatInterval` option (no equivalent in go-sdk + StreamableHTTPHandler; rely on HTTP keep-alive). +9. Verify `registerMCP(mcpServer, ...)` compiles with `*mcp.Server` argument. + +**Test requirements:** +- `go build ./cmd/...` succeeds with zero errors. +- `go run ./cmd -- --stdio` starts and responds to `tools/list`. +- `make lint` passes. + +**Integration notes:** +- This is the first step where `grep -r "mark3labs" .` should return zero results. + +**Demo:** +```bash +echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | go run ./cmd -- --stdio +``` +Returns a JSON response listing all tools. + +--- + +## Step 14: Update E2E test helpers + +**Objective:** Update `test/e2e/helpers_test.go` to use go-sdk client types for +integration test scaffolding. + +**Implementation guidance:** +1. Replace `mark3labs` client types with go-sdk equivalents: +```go +// Before: mark3labs client construction +// After: +client := mcp.NewClient(&mcp.Implementation{Name: "test-client"}, nil) +transport := &mcp.CommandTransport{Command: exec.Command("./bin/kagent-tools", "--stdio")} +session, err := client.Connect(ctx, transport, nil) +``` +2. Update tool invocations: +```go +res, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "kubectl_get", + Arguments: map[string]any{"resource_type": "pod"}, +}) +``` +3. Replace result assertions: +```go +// Check IsError flag +if res.IsError { t.Fatalf(...) } +text := res.Content[0].(*mcp.TextContent).Text +``` + +**Test requirements:** +- `go test ./test/e2e/...` passes (or is skipped gracefully when cluster unavailable). + +**Demo:** `go test ./test/e2e/... -run TestToolsList` PASS. + +--- + +## Step 15: Final validation + +**Objective:** Confirm all quality gates pass, no mark3labs references remain, +and the binary behaves identically to before migration. + +**Implementation guidance:** +1. Run full test suite: +```bash +make test +``` +2. Verify zero mark3labs references: +```bash +grep -r "mark3labs" . --include="*.go" --include="go.mod" +# must return: no output +``` +3. Verify no `map[any]any` or `map[string]interface{}` in tool params: +```bash +grep -r "map\[string\]interface{}\|map\[string\]any\|map\[any\]" pkg/ internal/ --include="*.go" +# must return: no output +``` +4. Run linter: +```bash +make lint +``` +5. Build all platform binaries: +```bash +make build +``` +6. Smoke test both transports: +```bash +# Stdio +echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' \ + | ./bin/kagent-tools --stdio + +# HTTP +./bin/kagent-tools --port 8085 & +sleep 1 +curl -s http://localhost:8085/health +kill %1 +``` + +**Test requirements:** +- `make test` exits 0. +- `make lint` exits 0. +- `make build` exits 0. +- Both smoke tests return expected responses. +- `grep -r "mark3labs" .` returns no matches. + +**Demo:** CI pipeline passes (or equivalent local `make test && make lint && make build`). diff --git a/specs/migrate-mcp-go-to-official-sdk/requirements.md b/specs/migrate-mcp-go-to-official-sdk/requirements.md new file mode 100644 index 00000000..1d116695 --- /dev/null +++ b/specs/migrate-mcp-go-to-official-sdk/requirements.md @@ -0,0 +1,25 @@ +# Requirements Q&A + +> This file captures requirements clarification questions and answers gathered during the PDD process. +> Questions and answers are appended in real time. + +--- + +## Q1: What type safety requirements apply to the SDK migration? + +**Q:** Should the migration use any dynamic/generic map types (e.g. `map[string]any`, `map[any]any`) for tool parameters or results, or should concrete Go struct types be used? + +**A:** Use Go struct types throughout. Avoid `map[any]any` and prefer typed structs for all tool parameters, inputs, and outputs. This applies to parameter parsing, result construction, and any intermediate data structures introduced during the migration. + +--- + +## Research findings appended + +See `research/sdk-comparison.md` and `skill.md` for the full API mapping. + +Key confirmed facts from official SDK examples and pkg.go.dev: +- `mcp.AddTool` is a generic function that auto-derives JSON schema from the typed `In` param struct. +- `ToolHandlerFor[In, Out any]` signature returns `(*CallToolResult, any, error)` — three values. +- No `NewToolResultText` / `NewToolResultError` helpers — must construct `CallToolResult` directly or add local helpers. +- Middleware uses `AddReceivingMiddleware` with `mcp.MethodHandler` / `mcp.Middleware` types. +- `ToolError.Context` field (`map[string]interface{}`) violates no-map-any-any rule and must be replaced. diff --git a/specs/migrate-mcp-go-to-official-sdk/research/sdk-comparison.md b/specs/migrate-mcp-go-to-official-sdk/research/sdk-comparison.md new file mode 100644 index 00000000..c45ce756 --- /dev/null +++ b/specs/migrate-mcp-go-to-official-sdk/research/sdk-comparison.md @@ -0,0 +1,222 @@ +# SDK Comparison: mark3labs/mcp-go vs modelcontextprotocol/go-sdk + +## Sources +- https://github.com/modelcontextprotocol/go-sdk +- https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp +- https://github.com/modelcontextprotocol/go-sdk/tree/main/examples + +--- + +## Current dependency (mark3labs/mcp-go v0.43.2) + +### Imports used in this project +``` +"github.com/mark3labs/mcp-go/mcp" +"github.com/mark3labs/mcp-go/server" +``` + +### Server lifecycle +```go +// Create server +mcpServer := server.NewMCPServer(name, version) + +// Stdio mode +stdioServer := server.NewStdioServer(mcpServer) +stdioServer.Listen(ctx, os.Stdin, os.Stdout) + +// HTTP/SSE mode +sseServer := server.NewStreamableHTTPServer(mcpServer, + server.WithHeartbeatInterval(30*time.Second), +) +sseServer.ServeHTTP(w, r) +``` + +### Tool definition & registration +```go +// Define tool with option-function pattern +tool := mcp.NewTool("tool_name", + mcp.WithDescription("description"), + mcp.WithString("param", + mcp.Required(), + mcp.Description("param description"), + ), + mcp.WithBoolean("flag", + mcp.Description("flag description"), + ), + mcp.WithNumber("count", + mcp.Description("count description"), + ), +) +// Register on server +mcpServer.AddTool(tool, handler) +``` + +### Handler signature +```go +type ToolHandlerFunc func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) +// Note: CallToolRequest is a value type (not pointer) in mark3labs +``` + +### Parameter parsing +```go +// String with default +val := mcp.ParseString(request, "param_name", "default") +// Int with default +count := mcp.ParseInt(request, "count", 50) +// Bool equivalent (parsed as string) +flag := mcp.ParseString(request, "flag", "") == "true" +``` + +### Result construction +```go +// Success +return mcp.NewToolResultText("output text"), nil +// Error (tool-level, not protocol error) +return mcp.NewToolResultError("error message"), nil +``` + +### Middleware / telemetry adapter +```go +// Adapter wraps a typed ToolHandler into server.ToolHandlerFunc +func AdaptToolHandler(th ToolHandler) server.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return th(ctx, req) + } +} +``` + +### request.Params access (used in telemetry) +```go +request.Params.Name // tool name string +request.Params.Arguments // map[string]interface{} or nil +``` + +--- + +## Target dependency (modelcontextprotocol/go-sdk, latest) + +### Import +```go +"github.com/modelcontextprotocol/go-sdk/mcp" +``` + +### Server lifecycle +```go +// Create server +server := mcp.NewServer(&mcp.Implementation{Name: "name", Version: "v1.0"}, nil) + +// Stdio mode (blocks until client disconnects) +server.Run(ctx, &mcp.StdioTransport{}) + +// HTTP/SSE mode (legacy SSE, spec 2024-11-05) +handler := mcp.NewSSEHandler(func(r *http.Request) *mcp.Server { + return server +}, nil) +http.ListenAndServe(addr, handler) + +// HTTP Streamable mode (spec 2025-03-26+) +handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { + return server +}, nil) +http.ListenAndServe(addr, handler) +``` + +### Tool definition & registration (typed — PREFERRED) +```go +// Define typed params struct +type MyToolParams struct { + Param string `json:"param" jsonschema:"description of param,required"` + Flag bool `json:"flag" jsonschema:"flag description"` + Count int `json:"count" jsonschema:"count description"` +} + +// Register — schema auto-derived from struct tags +mcp.AddTool(server, &mcp.Tool{ + Name: "tool_name", + Description: "description", +}, func(ctx context.Context, req *mcp.CallToolRequest, args MyToolParams) (*mcp.CallToolResult, any, error) { + // args.Param, args.Flag, args.Count are already populated and validated + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "output"}}, + }, nil, nil +}) +``` + +### Tool definition & registration (low-level — avoid if possible) +```go +// Low-level: handler receives raw CallToolRequest, no auto-validation +server.AddTool(&mcp.Tool{ + Name: "tool_name", + Description: "description", + InputSchema: &jsonschema.Schema{ /* ... */ }, +}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // manual parsing required + return &mcp.CallToolResult{...}, nil +}) +``` + +### Handler signatures +```go +// Typed (preferred) — ToolHandlerFor[In, Out any] +func(ctx context.Context, req *mcp.CallToolRequest, args MyParams) (*mcp.CallToolResult, any, error) + +// Low-level — ToolHandler +func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) +``` + +### Result construction +```go +// Success +return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "output text"}}, +}, nil, nil + +// Tool-level error (IsError=true, not a protocol error) +return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{&mcp.TextContent{Text: "error message"}}, +}, nil, nil + +// Protocol-level error (returns as Go error) +return nil, nil, fmt.Errorf("protocol error: %w", err) +``` + +### Middleware +```go +type MethodHandler func(ctx context.Context, method string, req Request) (Result, error) +type Middleware func(next MethodHandler) MethodHandler + +server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + // pre-processing + result, err := next(ctx, method, req) + // post-processing + return result, err + } +}) + +// Access tool info inside middleware: +if ctr, ok := req.(*mcp.CallToolRequest); ok { + _ = ctr.Params.Name // tool name + _ = ctr.Params.Arguments // json.RawMessage +} +// Access tool result in middleware: +if ctr, ok := result.(*mcp.CallToolResult); ok { + _ = ctr.IsError + _ = ctr.StructuredContent +} +``` + +### Key types +```go +mcp.Implementation{Name string; Version string} +mcp.ServerOptions{} +mcp.Tool{Name string; Description string; InputSchema *jsonschema.Schema; OutputSchema *jsonschema.Schema} +mcp.CallToolRequest // = ServerRequest[*CallToolParamsRaw] +mcp.CallToolResult{Content []Content; IsError bool; StructuredContent any} +mcp.Content // interface +mcp.TextContent{Text string; Meta Meta; Annotations *Annotations} +mcp.StdioTransport{} +mcp.SSEHandler // http.Handler for SSE +mcp.StreamableHTTPHandler // http.Handler for streamable HTTP +``` diff --git a/specs/migrate-mcp-go-to-official-sdk/rough-idea.md b/specs/migrate-mcp-go-to-official-sdk/rough-idea.md new file mode 100644 index 00000000..54d29f75 --- /dev/null +++ b/specs/migrate-mcp-go-to-official-sdk/rough-idea.md @@ -0,0 +1,20 @@ +# Rough Idea + +## Summary + +Migrate `github.com/mark3labs/mcp-go` to the official MCP Go SDK at `https://github.com/modelcontextprotocol/go-sdk`. + +## Context + +The project currently depends on the community-maintained MCP Go SDK (`github.com/mark3labs/mcp-go v0.43.2`). The official MCP Go SDK has been released at `github.com/modelcontextprotocol/go-sdk`. The migration should ensure all existing functionality is preserved while adopting the officially-supported library. + +## Current State + +- **Dependency**: `github.com/mark3labs/mcp-go v0.43.2` +- **Usage**: Tool registration, MCP server setup, transport handling (stdio, HTTP/SSE), tool result types +- **Files affected**: `cmd/main.go`, all `pkg/*/` tool packages +- **CLAUDE.md** already references `github.com/modelcontextprotocol/go-sdk` as the active technology + +## Goal + +Replace all usage of `github.com/mark3labs/mcp-go` with `github.com/modelcontextprotocol/go-sdk` across the codebase, maintaining full feature parity and test coverage requirements. diff --git a/specs/migrate-mcp-go-to-official-sdk/skill.md b/specs/migrate-mcp-go-to-official-sdk/skill.md new file mode 100644 index 00000000..d5738ffc --- /dev/null +++ b/specs/migrate-mcp-go-to-official-sdk/skill.md @@ -0,0 +1,442 @@ +# Migration Skill: mark3labs/mcp-go → modelcontextprotocol/go-sdk + +> Reference document for migrating `github.com/mark3labs/mcp-go` to the official +> `github.com/modelcontextprotocol/go-sdk`. Use this as the authoritative lookup +> during implementation. All patterns use concrete Go struct types — no `map[any]any`. + +--- + +## 1. Dependency Change + +```diff +# go.mod +- github.com/mark3labs/mcp-go v0.43.2 ++ github.com/modelcontextprotocol/go-sdk +``` + +```bash +go get github.com/modelcontextprotocol/go-sdk@latest +go mod tidy +``` + +--- + +## 2. Import Paths + +| mark3labs | go-sdk | +|-----------|--------| +| `"github.com/mark3labs/mcp-go/mcp"` | `"github.com/modelcontextprotocol/go-sdk/mcp"` | +| `"github.com/mark3labs/mcp-go/server"` | _(removed — all under `mcp` package)_ | + +--- + +## 3. Server Creation + +### mark3labs +```go +import "github.com/mark3labs/mcp-go/server" + +mcpServer := server.NewMCPServer(Name, Version) +``` + +### go-sdk +```go +import "github.com/modelcontextprotocol/go-sdk/mcp" + +mcpServer := mcp.NewServer(&mcp.Implementation{ + Name: Name, + Version: Version, +}, nil) +``` + +--- + +## 4. Tool Handler Signature + +This is the most impactful change. Replace dynamic parsing with typed structs. + +### mark3labs +```go +func handleMyTool(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + param := mcp.ParseString(request, "param_name", "") + count := mcp.ParseInt(request, "count", 50) + flag := mcp.ParseString(request, "flag", "") == "true" + // ... +} +``` + +### go-sdk (REQUIRED pattern — typed structs, no map[any]any) +```go +// 1. Define a params struct for every tool +type MyToolParams struct { + ParamName string `json:"param_name" jsonschema:"description of param"` + Count int `json:"count" jsonschema:"number of lines,default=50"` + Flag bool `json:"flag" jsonschema:"enable flag"` +} + +// 2. Handler receives populated, validated struct directly +func handleMyTool(ctx context.Context, req *mcp.CallToolRequest, args MyToolParams) (*mcp.CallToolResult, any, error) { + // args.ParamName, args.Count, args.Flag are already set + // ... +} +``` + +**Key rules:** +- Every tool MUST have a dedicated params struct. +- Fields validated as `required` in jsonschema will return a tool error automatically. +- Handler returns THREE values: `(*mcp.CallToolResult, any, error)` — the middle `any` is the structured output (return `nil` if unused). +- `req` is a pointer (`*mcp.CallToolRequest`), not a value. + +--- + +## 5. Tool Definition & Registration + +### mark3labs +```go +tool := mcp.NewTool("tool_name", + mcp.WithDescription("description"), + mcp.WithString("param", mcp.Required(), mcp.Description("...")), + mcp.WithBoolean("flag", mcp.Description("...")), + mcp.WithNumber("count", mcp.Description("...")), +) +mcpServer.AddTool(tool, handler) +``` + +### go-sdk +```go +// Schema is auto-derived from the params struct — no need to list params manually. +mcp.AddTool(mcpServer, &mcp.Tool{ + Name: "tool_name", + Description: "description", +}, handleMyTool) +``` + +**Struct tags that drive schema generation:** + +| Tag | Purpose | +|-----|---------| +| `json:"field_name"` | JSON key name (required) | +| `jsonschema:"description text"` | Field description shown in schema | +| `jsonschema:"description,required"` | Mark field as required | +| `jsonschema:"description,default=value"` | Provide default value | + +--- + +## 6. Result Construction + +### mark3labs → go-sdk + +| Scenario | mark3labs | go-sdk | +|----------|-----------|--------| +| **Success** | `mcp.NewToolResultText("text")` | `&mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "text"}}}` | +| **Tool error** | `mcp.NewToolResultError("msg")` | `&mcp.CallToolResult{IsError: true, Content: []mcp.Content{&mcp.TextContent{Text: "msg"}}}` | +| **Protocol error** | `return nil, fmt.Errorf("...")` | `return nil, nil, fmt.Errorf("...")` | + +### Helper functions to define (add to `pkg/utils/` or `internal/mcputil/`) + +Since the official SDK has no `NewToolResultText`/`NewToolResultError` helpers, +define these once and reuse: + +```go +package mcputil + +import "github.com/modelcontextprotocol/go-sdk/mcp" + +func TextResult(text string) *mcp.CallToolResult { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: text}}, + } +} + +func ErrorResult(msg string) *mcp.CallToolResult { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{&mcp.TextContent{Text: msg}}, + } +} +``` + +--- + +## 7. Transport / Server Startup + +### Stdio transport + +#### mark3labs +```go +stdioServer := server.NewStdioServer(mcpServer) +stdioServer.Listen(ctx, os.Stdin, os.Stdout) +``` + +#### go-sdk +```go +// Run blocks until client disconnects or ctx is cancelled +if err := mcpServer.Run(ctx, &mcp.StdioTransport{}); err != nil { + logger.Get().Info("Stdio server stopped", "error", err) +} +``` + +### HTTP/SSE transport + +#### mark3labs +```go +sseServer := server.NewStreamableHTTPServer(mcpServer, + server.WithHeartbeatInterval(30*time.Second), +) +mux.Handle("/", sseServer) +``` + +#### go-sdk +```go +// StreamableHTTPHandler (MCP spec 2025-03-26+) +handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { + return mcpServer +}, nil) +mux.Handle("/", handler) + +// OR legacy SSEHandler (MCP spec 2024-11-05) +handler := mcp.NewSSEHandler(func(r *http.Request) *mcp.Server { + return mcpServer +}, nil) +mux.Handle("/", handler) +``` + +> **Note:** `WithHeartbeatInterval` has no direct equivalent — check +> `StreamableHTTPOptions` for any keepalive options in the installed version. + +--- + +## 8. Middleware / Telemetry + +The telemetry `WithTracing` wrapper currently adapts `ToolHandler` → `server.ToolHandlerFunc`. +With go-sdk, use `AddReceivingMiddleware` instead. + +### go-sdk middleware signature +```go +type MethodHandler func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) +type Middleware func(next mcp.MethodHandler) mcp.MethodHandler + +mcpServer.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + // Intercept tool calls + if ctr, ok := req.(*mcp.CallToolRequest); ok { + toolName := ctr.Params.Name + _ = toolName // use for spans + } + result, err := next(ctx, method, req) + // Inspect tool results + if ctr, ok := result.(*mcp.CallToolResult); ok { + _ = ctr.IsError + } + return result, err + } +}) +``` + +### Migrating `internal/telemetry/middleware.go` + +1. Remove `ToolHandler` type alias (no longer needed). +2. Remove `AdaptToolHandler` function. +3. Expose a `NewTracingMiddleware(tracer) mcp.Middleware` function instead. +4. The `WithTracing(toolName, handler)` wrapper pattern is replaced by a single + server-level middleware that extracts tool name from `req.(*mcp.CallToolRequest).Params.Name`. + +### Accessing request context in middleware +```go +// Tool name +ctr.Params.Name + +// Arguments (json.RawMessage, not map — use json.Unmarshal to read) +ctr.Params.Arguments + +// Session ID +req.GetSession().ID() +``` + +--- + +## 9. internal/errors/tool_errors.go + +`ToMCPResult()` calls `mcp.NewToolResultError(...)` which does not exist in go-sdk. + +### Fix +```go +// Before (mark3labs) +return mcp.NewToolResultError(message.String()) + +// After (go-sdk) +return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{&mcp.TextContent{Text: message.String()}}, +} +``` + +Also replace `map[string]interface{}` in `ToolError.Context` with a concrete struct +or `map[string]string` to honour the "no map[any]any" requirement. + +--- + +## 10. RegisterTools Function Signature + +All `pkg/*/` packages export a `RegisterTools` function. Signature changes from: + +```go +// mark3labs +func RegisterTools(s *server.MCPServer, readOnly bool) +``` + +to: + +```go +// go-sdk +func RegisterTools(s *mcp.Server, readOnly bool) +``` + +`cmd/main.go` `registerMCP` function and its `toolProviderMap` closures update accordingly: + +```go +// Before +toolProviderMap := map[string]func(*server.MCPServer){...} + +// After +toolProviderMap := map[string]func(*mcp.Server){...} +``` + +--- + +## 11. Params Struct Reference (per package) + +Define one `*Params` struct per tool handler. Name it `Params`. + +### Example: k8s package + +```go +// kubectl_get +type KubectlGetParams struct { + ResourceType string `json:"resource_type" jsonschema:"type of K8s resource (pod/deploy/svc..),required"` + ResourceName string `json:"resource_name" jsonschema:"name of the resource"` + Namespace string `json:"namespace" jsonschema:"namespace to query"` + AllNamespaces bool `json:"all_namespaces" jsonschema:"query all namespaces"` + Output string `json:"output" jsonschema:"output format (wide/json/yaml),default=wide"` +} + +// kubectl_logs +type KubectlLogsParams struct { + PodName string `json:"pod_name" jsonschema:"name of the pod,required"` + Namespace string `json:"namespace" jsonschema:"namespace,default=default"` + Container string `json:"container" jsonschema:"container name"` + TailLines int `json:"tail_lines" jsonschema:"number of log lines,default=50"` +} + +// scale_deployment +type ScaleDeploymentParams struct { + Name string `json:"name" jsonschema:"deployment name,required"` + Namespace string `json:"namespace" jsonschema:"namespace,default=default"` + Replicas int `json:"replicas" jsonschema:"desired replica count,default=1"` +} +``` + +### Example: helm package + +```go +type HelmListParams struct { + Namespace string `json:"namespace" jsonschema:"filter by namespace"` + AllNamespaces bool `json:"all_namespaces" jsonschema:"list across all namespaces"` + All bool `json:"all" jsonschema:"show all releases"` + Uninstalled bool `json:"uninstalled" jsonschema:"show uninstalled releases"` + Failed bool `json:"failed" jsonschema:"show failed releases"` + Deployed bool `json:"deployed" jsonschema:"show deployed releases"` + Pending bool `json:"pending" jsonschema:"show pending releases"` + Filter string `json:"filter" jsonschema:"regex filter for release names"` + Output string `json:"output" jsonschema:"output format"` +} +``` + +--- + +## 12. Test Migration + +Tests using mark3labs types must be updated: + +```go +// Before (mark3labs) +req := mcp.CallToolRequest{} +req.Params.Arguments = map[string]interface{}{"param": "value"} + +// After (go-sdk — construct the typed params struct directly in tests) +args := MyToolParams{ParamName: "value", Count: 10} +// Call handler directly with args, bypassing request parsing: +result, _, err := handleMyTool(ctx, &mcp.CallToolRequest{}, args) +``` + +For mock-based tests in `pkg/*/`, inject args directly into the typed handler — +no need to construct `CallToolRequest` params at all for unit tests. + +--- + +## 13. Files to Modify (complete list) + +| File | Change | +|------|--------| +| `go.mod` / `go.sum` | Replace dependency | +| `cmd/main.go` | Server creation, transports, `registerMCP` signature | +| `internal/telemetry/middleware.go` | Replace `ToolHandler` type, remove `AdaptToolHandler`, add `mcp.Middleware` factory | +| `internal/telemetry/middleware_test.go` | Update test types | +| `internal/errors/tool_errors.go` | Fix `ToMCPResult()`, fix `Context` map type | +| `pkg/k8s/k8s.go` | Params structs, handler signatures, registration | +| `pkg/k8s/k8s_test.go` | Update test helpers | +| `pkg/helm/helm.go` | Params structs, handler signatures, registration | +| `pkg/helm/helm_test.go` | Update test helpers | +| `pkg/istio/istio.go` | Params structs, handler signatures, registration | +| `pkg/istio/istio_test.go` | Update test helpers | +| `pkg/argo/argo.go` | Params structs, handler signatures, registration | +| `pkg/argo/argo_test.go` | Update test helpers | +| `pkg/cilium/cilium.go` | Params structs, handler signatures, registration | +| `pkg/cilium/cilium_test.go` | Update test helpers | +| `pkg/prometheus/prometheus.go` | Params structs, handler signatures, registration | +| `pkg/prometheus/prometheus_test.go` | Update test helpers | +| `pkg/prometheus/promql.go` | Update MCP types | +| `pkg/kubescape/kubescape.go` | Params structs, handler signatures, registration | +| `pkg/kubescape/kubescape_test.go` | Update test helpers | +| `pkg/utils/common.go` | Update MCP types | +| `pkg/utils/datetime_test.go` | Update test types | +| `test/e2e/helpers_test.go` | Update client/server setup | + +--- + +## 14. Migration Order (recommended) + +1. **`go.mod`** — swap dependency, run `go mod tidy` +2. **`internal/mcputil/`** — create `TextResult` / `ErrorResult` helpers (new file) +3. **`internal/errors/tool_errors.go`** — fix `ToMCPResult()` and `Context` field type +4. **`internal/telemetry/middleware.go`** — rewrite to `mcp.Middleware` pattern +5. **`pkg/utils/`** — update types (least dependent) +6. **`pkg/prometheus/`** — update types +7. **`pkg/argo/`**, **`pkg/cilium/`**, **`pkg/helm/`**, **`pkg/istio/`**, **`pkg/k8s/`**, **`pkg/kubescape/`** — update each package (params structs + handler signatures + registration) +8. **`cmd/main.go`** — update server creation and transport wiring +9. **All `*_test.go`** — update test helpers per package +10. **`test/e2e/`** — update integration test helpers + +Run `make test` and `make lint` after each package to catch regressions early. + +--- + +## 15. Quick Reference Card + +``` +REMOVED (mark3labs) → REPLACEMENT (go-sdk) +───────────────────────────────────────────────────────────────── +server.NewMCPServer(n,v) → mcp.NewServer(&mcp.Implementation{Name:n,Version:v}, nil) +server.NewStdioServer(s) → s.Run(ctx, &mcp.StdioTransport{}) +server.NewStreamableHTTP(s) → mcp.NewStreamableHTTPHandler(func(r)*mcp.Server{return s}, nil) +server.ToolHandlerFunc → mcp.ToolHandlerFor[In,Out] or mcp.ToolHandler +mcp.CallToolRequest (value) → *mcp.CallToolRequest (pointer) +mcp.ParseString(req,k,d) → struct field (typed params) +mcp.ParseInt(req,k,d) → struct field (typed params) +mcp.NewTool(name, opts...) → &mcp.Tool{Name:"...", Description:"..."} +s.AddTool(tool, handler) → mcp.AddTool(s, &mcp.Tool{...}, typedHandler) +mcp.NewToolResultText(t) → mcputil.TextResult(t) [local helper] +mcp.NewToolResultError(t) → mcputil.ErrorResult(t) [local helper] +handler returns (res, err) → handler returns (res, any, err) +───────────────────────────────────────────────────────────────── +``` diff --git a/specs/tools/001-migrate-mcp-go-to-official-sdk/PROMPT.md b/specs/tools/001-migrate-mcp-go-to-official-sdk/PROMPT.md new file mode 100644 index 00000000..e69de29b diff --git a/specs/tools/001-migrate-mcp-go-to-official-sdk/design.md b/specs/tools/001-migrate-mcp-go-to-official-sdk/design.md new file mode 100644 index 00000000..0addfe8d --- /dev/null +++ b/specs/tools/001-migrate-mcp-go-to-official-sdk/design.md @@ -0,0 +1,472 @@ +# Design: Migrate mark3labs/mcp-go → modelcontextprotocol/go-sdk + +## Overview + +This document describes the design for replacing the community MCP Go SDK +(`github.com/mark3labs/mcp-go v0.43.2`) with the official MCP Go SDK +(`github.com/modelcontextprotocol/go-sdk`) across the `kagent-tools` server. + +The migration is a **drop-in SDK swap with a type-safety uplift**: all externally +visible behaviour (tool names, parameter names, transport protocols) is preserved, +while the internal implementation switches from dynamic map-based parameter parsing +to concrete Go struct types. + +No new tools are added. No tools are removed. No CLI flags change. + +--- + +## Detailed Requirements + +### R1 — Concrete Go struct types (no `map[any]any`) + +Every tool handler MUST receive its parameters as a named, exported Go struct. +Dynamic maps (`map[string]any`, `map[string]interface{}`, `map[any]any`) are +forbidden as tool parameter containers. Existing uses of `map[string]interface{}` +in `ToolError.Context` must also be replaced with a concrete type. + +### R2 — Full feature parity + +All 40+ tools across eight packages (k8s, helm, istio, argo, cilium, prometheus, +kubescape, utils) must be registered and functional after migration. Tool names, +parameter names, and descriptions must match the current implementation exactly. + +### R3 — Both transports preserved + +The server must continue to support: +- **stdio** (`--stdio` flag): communicates over stdin/stdout +- **HTTP Streamable** (default): listens on `--port` (default 8084) + +### R4 — Telemetry / OpenTelemetry tracing preserved + +The OTel tracing middleware that records tool name, arguments, duration, and +error state on every `tools/call` invocation must be rewritten using the +official SDK's `AddReceivingMiddleware` API. No tracing spans may be lost. + +### R5 — 80 % overall / 70 % per-package / 90 % critical-package coverage + +Test coverage thresholds defined in CLAUDE.md are unchanged. All updated +packages must pass `make test` after migration. + +### R6 — No breaking changes to the public `RegisterTools` interface + +Each `pkg/*/` package exposes `RegisterTools(s *mcp.Server, ...)`. The function +signature changes only the type of the first argument (from `*server.MCPServer` +to `*mcp.Server`). Callers in `cmd/main.go` are updated accordingly. + +--- + +## Architecture Overview + +```mermaid +graph TD + subgraph cmd + main["cmd/main.go
cobra CLI"] + end + + subgraph internal + tel["internal/telemetry
OTel middleware"] + errs["internal/errors
ToolError → MCP result"] + mcputil["internal/mcputil ← NEW
TextResult / ErrorResult helpers"] + end + + subgraph sdk ["github.com/modelcontextprotocol/go-sdk/mcp"] + Server["mcp.Server"] + AddTool["mcp.AddTool[In,Out]"] + Transports["StdioTransport
StreamableHTTPHandler"] + Middleware["AddReceivingMiddleware"] + end + + subgraph tools ["pkg/*"] + k8s; helm; istio; argo; cilium; prometheus; kubescape; utils + end + + main -->|"NewServer + transports"| sdk + main -->|"registerMCP"| tools + main -->|"AddReceivingMiddleware"| tel + tools -->|"mcp.AddTool + *Params structs"| AddTool + tools -->|"mcputil.TextResult / ErrorResult"| mcputil + errs -->|"&mcp.CallToolResult{IsError:true}"| sdk + mcputil -->|"&mcp.CallToolResult{Content:[...]}"| sdk + tel -->|"mcp.Middleware"| Middleware +``` + +### Key Architectural Decisions + +| Decision | Rationale | +|----------|-----------| +| Use generic `mcp.AddTool[In, Out]` (not low-level `server.AddTool`) | Auto-derives JSON schema from struct tags; eliminates manual `mcp.WithString/Bool/Number` option calls | +| Introduce `internal/mcputil` package | Single source for `TextResult`/`ErrorResult` helpers; avoids duplicating `&mcp.CallToolResult{...}` literals across 40+ handlers | +| Replace per-handler `WithTracing` wrapper with server-level middleware | Cleaner separation; one middleware intercepts all tool calls; no adapter boilerplate per handler | +| Replace `ToolError.Context map[string]interface{}` with `map[string]string` | Satisfies R1; `interface{}` was only ever used with string values | + +--- + +## Components and Interfaces + +### `internal/mcputil` (new package) + +```go +package mcputil + +import "github.com/modelcontextprotocol/go-sdk/mcp" + +// TextResult wraps a plain text string in a successful CallToolResult. +func TextResult(text string) *mcp.CallToolResult + +// ErrorResult wraps an error message in a tool-error CallToolResult (IsError=true). +func ErrorResult(msg string) *mcp.CallToolResult +``` + +### `internal/telemetry/middleware.go` (rewritten) + +```go +// Before +type ToolHandler func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) +func WithTracing(toolName string, handler ToolHandler) ToolHandler +func AdaptToolHandler(th ToolHandler) server.ToolHandlerFunc + +// After +// NewTracingMiddleware returns a mcp.Middleware that records OTel spans for +// every tools/call invocation. The tool name is read from req.(*mcp.CallToolRequest).Params.Name. +func NewTracingMiddleware() mcp.Middleware +``` + +All other telemetry helpers (`HTTPMiddleware`, `ExtractHTTPHeaders`, `StartSpan`, +`RecordError`, `RecordSuccess`, `AddEvent`) are unchanged. + +### `internal/errors/tool_errors.go` + +```go +// Context field type change +type ToolError struct { + // ... + Context map[string]string `json:"context,omitempty"` // was map[string]interface{} +} + +// ToMCPResult — result type changes; import changes from mark3labs to go-sdk +func (e *ToolError) ToMCPResult() *mcp.CallToolResult { + return mcputil.ErrorResult(message.String()) +} + +// WithContext parameter type change +func (e *ToolError) WithContext(key string, value string) *ToolError +``` + +### `pkg/*/` — Tool handler pattern + +Every handler is converted to the typed `ToolHandlerFor` pattern: + +```go +// Params struct — one per tool +type Params struct { + Field string `json:"field_name" jsonschema:"description[,required][,default=val]"` + // ... +} + +// Handler +func handle( + ctx context.Context, + req *mcp.CallToolRequest, + args Params, +) (*mcp.CallToolResult, any, error) { + // args.Field is already populated + return mcputil.TextResult(result), nil, nil +} + +// Registration +func RegisterTools(s *mcp.Server, readOnly bool) { + mcp.AddTool(s, &mcp.Tool{ + Name: "tool_name", + Description: "...", + }, handle) +} +``` + +### `cmd/main.go` + +```go +// Server creation +mcpServer := mcp.NewServer(&mcp.Implementation{Name: Name, Version: Version}, nil) + +// Middleware +mcpServer.AddReceivingMiddleware(telemetry.NewTracingMiddleware()) + +// Tool registration map +toolProviderMap := map[string]func(*mcp.Server){ + "k8s": func(s *mcp.Server) { k8s.RegisterTools(s, nil, kubeconfig, readOnly) }, + // ... +} + +// Stdio transport +func runStdioServer(ctx context.Context, s *mcp.Server) { + if err := s.Run(ctx, &mcp.StdioTransport{}); err != nil { ... } +} + +// HTTP transport +handler := mcp.NewStreamableHTTPHandler( + func(r *http.Request) *mcp.Server { return mcpServer }, + nil, +) +mux.Handle("/", telemetry.HTTPMiddleware(handler)) +``` + +--- + +## Data Models + +### Params Structs per Package + +All structs use `json` tags for field names and `jsonschema` tags for descriptions +and constraints. Required fields have `,required` appended to the jsonschema tag. + +#### `pkg/k8s` — KubectlGetParams (representative) + +```go +type KubectlGetParams struct { + ResourceType string `json:"resource_type" jsonschema:"K8s resource type (pod/deploy/svc),required"` + ResourceName string `json:"resource_name" jsonschema:"name of the specific resource"` + Namespace string `json:"namespace" jsonschema:"namespace to query"` + AllNamespaces bool `json:"all_namespaces" jsonschema:"query across all namespaces"` + Output string `json:"output" jsonschema:"output format (wide/json/yaml),default=wide"` +} +type KubectlLogsParams struct { + PodName string `json:"pod_name" jsonschema:"pod name,required"` + Namespace string `json:"namespace" jsonschema:"namespace,default=default"` + Container string `json:"container" jsonschema:"container name"` + TailLines int `json:"tail_lines" jsonschema:"number of log lines,default=50"` +} +type ScaleDeploymentParams struct { + Name string `json:"name" jsonschema:"deployment name,required"` + Namespace string `json:"namespace" jsonschema:"namespace,default=default"` + Replicas int `json:"replicas" jsonschema:"desired replica count,default=1"` +} +// ... one struct per handler, following the same pattern +``` + +#### `pkg/helm` (representative) + +```go +type HelmListParams struct { + Namespace string `json:"namespace" jsonschema:"filter by namespace"` + AllNamespaces bool `json:"all_namespaces" jsonschema:"list across all namespaces"` + All bool `json:"all" jsonschema:"show all releases including non-deployed"` + Uninstalled bool `json:"uninstalled" jsonschema:"show uninstalled releases"` + Failed bool `json:"failed" jsonschema:"show failed releases"` + Deployed bool `json:"deployed" jsonschema:"show deployed releases"` + Pending bool `json:"pending" jsonschema:"show pending releases"` + Filter string `json:"filter" jsonschema:"regex filter for release names"` + Output string `json:"output" jsonschema:"output format (table/json/yaml)"` +} +type HelmGetReleaseParams struct { + Name string `json:"name" jsonschema:"release name,required"` + Namespace string `json:"namespace" jsonschema:"namespace,required"` + Output string `json:"output" jsonschema:"output format (all/hooks/manifest/notes/values)"` +} +// ... one struct per handler +``` + +#### `pkg/argo` (representative) + +```go +type VerifyArgoControllerParams struct { + Namespace string `json:"namespace" jsonschema:"namespace to check,default=argo-rollouts"` + Label string `json:"label" jsonschema:"pod label selector,default=app.kubernetes.io/component=rollouts-controller"` +} +type PromoteRolloutParams struct { + RolloutName string `json:"rollout_name" jsonschema:"name of the rollout,required"` + Namespace string `json:"namespace" jsonschema:"namespace"` + Full bool `json:"full" jsonschema:"fully promote skipping all pauses"` +} +// ... +``` + +#### `pkg/cilium` (representative) + +```go +type UpgradeCiliumParams struct { + ClusterName string `json:"cluster_name" jsonschema:"cluster name"` + DatapathMode string `json:"datapath_mode" jsonschema:"datapath mode (tunnel/native-routing)"` +} +type InstallCiliumParams struct { + ClusterName string `json:"cluster_name" jsonschema:"cluster name"` + ClusterID string `json:"cluster_id" jsonschema:"unique cluster ID for cluster mesh"` + DatapathMode string `json:"datapath_mode" jsonschema:"datapath mode"` +} +type ConnectRemoteClusterParams struct { + ClusterName string `json:"cluster_name" jsonschema:"remote cluster name,required"` + Context string `json:"context" jsonschema:"kubeconfig context for remote cluster"` +} +type ToggleHubbleParams struct { + Enable bool `json:"enable" jsonschema:"true to enable Hubble,default=true"` +} +// ... +``` + +--- + +## Error Handling + +### Tool-level errors (visible to the LLM) + +Returned as `*mcp.CallToolResult` with `IsError: true`. The LLM sees the error +text as tool output and can reason about it. + +```go +// All paths that previously called mcp.NewToolResultError(msg): +return mcputil.ErrorResult(msg), nil, nil + +// ToolError.ToMCPResult(): +return mcputil.ErrorResult(message.String()) +``` + +### Protocol-level errors (terminates the JSON-RPC call) + +Returned as the `error` return value. Reserved for unexpected internal failures +that the LLM cannot meaningfully recover from. + +```go +return nil, nil, fmt.Errorf("internal error: %w", err) +``` + +### Validation + +Required fields in param structs are validated automatically by the SDK before +the handler is called. Manual `if param == "" { return error }` guards in +handlers are removed where the field is declared `required` in the jsonschema tag. +Optional guards for business logic remain. + +--- + +## Acceptance Criteria + +### AC-1: Dependency update + +**Given** `go.mod` is updated to remove `github.com/mark3labs/mcp-go` +**When** `go mod tidy` is run +**Then** no references to `mark3labs/mcp-go` remain in `go.mod` or `go.sum` + +### AC-2: No dynamic maps in tool params + +**Given** the migrated codebase +**When** `grep -r "map\[string\]any\|map\[string\]interface{}" pkg/ internal/` is run +**Then** zero matches are found inside tool handler functions or param types + +### AC-3: All tools register and are discoverable + +**Given** the server is started in stdio mode +**When** a `tools/list` request is sent +**Then** all tool names present before migration are returned in the response + +### AC-4: Stdio transport works + +**Given** the server binary is run with `--stdio` +**When** a `tools/call` JSON-RPC request is piped to stdin +**Then** a valid JSON-RPC response with tool result is written to stdout + +### AC-5: HTTP/Streamable transport works + +**Given** the server is started without `--stdio` on port 8084 +**When** an HTTP MCP client connects and calls a tool +**Then** the response is returned with correct content + +### AC-6: Telemetry traces recorded + +**Given** an OTel exporter is configured +**When** a tool call is made +**Then** a span named `mcp.tool.` is recorded with `mcp.tool.name` and duration attributes + +### AC-7: Test coverage thresholds pass + +**Given** `make test` is run +**Then** overall coverage ≥ 80%, per-package ≥ 70%, critical packages ≥ 90% + +### AC-8: Linter passes + +**Given** `make lint` is run +**Then** zero linting errors are reported + +--- + +## Testing Strategy + +### Unit tests (primary) + +Each `pkg/*/` package uses the table-driven pattern from CLAUDE.md. After migration, +tests call the typed handler directly: + +```go +func TestHandleKubectlGet(t *testing.T) { + cases := []struct { + name string + args KubectlGetParams + wantErr bool + }{ + {name: "missing resource_type", args: KubectlGetParams{}, wantErr: true}, + {name: "valid get pods", args: KubectlGetParams{ResourceType: "pod", Namespace: "default"}, wantErr: false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + result, _, err := handleKubectlGet(context.Background(), &mcp.CallToolRequest{}, tc.args) + // assert + }) + } +} +``` + +No need to construct `mcp.CallToolRequest.Params.Arguments` maps in unit tests — +args are passed directly to the handler function. + +### Middleware tests + +`internal/telemetry/middleware_test.go` uses an in-memory transport pair +(`mcp.NewInMemoryTransports()`) to exercise the full request-response cycle +including middleware. + +### Integration / E2E tests + +`test/e2e/helpers_test.go` starts the full server binary and exercises both +transports. These tests are unchanged in scope; only the client-side MCP +type imports are updated. + +--- + +## Appendices + +### A. Technology Choices + +| Component | Choice | Reason | +|-----------|--------|--------| +| MCP SDK | `github.com/modelcontextprotocol/go-sdk` | Official Anthropic/MCP Foundation SDK; long-term support; supports MCP spec 2025-06-18 | +| HTTP transport | `mcp.NewStreamableHTTPHandler` | Implements MCP spec 2025-03-26 streamable HTTP; supersedes legacy SSE | +| Schema generation | `mcp.AddTool[In, Out]` generics | Auto-derives JSON schema from struct tags; eliminates boilerplate | +| Result helpers | `internal/mcputil.TextResult/ErrorResult` | Single source of truth; go-sdk has no built-in equivalents | + +### B. API Mapping Summary + +| mark3labs | go-sdk | +|-----------|--------| +| `server.NewMCPServer(n,v)` | `mcp.NewServer(&mcp.Implementation{Name:n,Version:v}, nil)` | +| `server.NewStdioServer(s).Listen(ctx,in,out)` | `s.Run(ctx, &mcp.StdioTransport{})` | +| `server.NewStreamableHTTPServer(s,opts)` | `mcp.NewStreamableHTTPHandler(func(r)*mcp.Server{return s}, nil)` | +| `mcp.ParseString(req,k,d)` | Struct field with `json` tag | +| `mcp.ParseInt(req,k,d)` | Struct field with `json` tag | +| `mcp.NewTool(name, opts...)` | `&mcp.Tool{Name:"...",Description:"..."}` | +| `s.AddTool(tool, handler)` | `mcp.AddTool(s, tool, typedHandler)` | +| `mcp.NewToolResultText(t)` | `mcputil.TextResult(t)` | +| `mcp.NewToolResultError(t)` | `mcputil.ErrorResult(t)` | +| handler `(req, err)` 2-return | handler `(req, any, err)` 3-return | +| `server.ToolHandlerFunc` | `mcp.ToolHandlerFor[In, Out]` | +| `server.AdaptToolHandler` | `mcp.Middleware` via `AddReceivingMiddleware` | + +### C. Alternative Approaches Considered + +**Keep low-level `server.AddTool` with manual schemas** — rejected. This would +require replicating the existing `mcp.WithString/Bool/Number` boilerplate in a +new form and would not achieve R1 (typed structs). + +**Use `map[string]any` args in handlers** — rejected. Explicitly forbidden by R1 +and is a regression in type safety compared to even the mark3labs API. + +**Introduce a compatibility shim layer** — rejected. A thin adapter keeping the +old signatures would prevent tests from using the cleaner direct-invocation +pattern and would accumulate technical debt. diff --git a/specs/tools/001-migrate-mcp-go-to-official-sdk/plan.md b/specs/tools/001-migrate-mcp-go-to-official-sdk/plan.md new file mode 100644 index 00000000..23e842ab --- /dev/null +++ b/specs/tools/001-migrate-mcp-go-to-official-sdk/plan.md @@ -0,0 +1,597 @@ +# Implementation Plan: mark3labs/mcp-go → modelcontextprotocol/go-sdk + +## Current Code Status + +Last verified: 2026-09-21 from the current repository state. + +This section is the single source of truth for migration progress. The original +step details below remain as historical implementation guidance, but the code no +longer matches the early plan exactly: the repository uses `internal/mcp` as the +SDK adapter/helper package instead of the planned `internal/mcputil` package. + +### Completed + +- [x] Step 1: Dependency is on `github.com/modelcontextprotocol/go-sdk`; no Go + source imports `github.com/mark3labs/mcp-go`. +- [x] Step 2: SDK helper/adaptation layer exists as `internal/mcp`, including + result constructors, typed `AddTool`, schema relaxation, and tool middleware. +- [x] Step 3: `internal/errors.ToolError.Context` is `map[string]string` and + `WithContext` takes `(key, value string)`. Non-string callers converted at the + call site: prometheus `status_code` (int -> decimal string), helm `helm_args` + ([]string -> space-joined). +- [x] Step 4: Per-tool tracing wrappers are gone; server-level MCP middleware is + centralized in `internal/mcp.ToolMiddleware()`. +- [x] Step 5: `pkg/utils` uses the go-sdk path through `internal/mcp`. +- [x] Step 6: `pkg/prometheus` uses the go-sdk path through `internal/mcp`. +- [x] Step 7: `pkg/argo` uses the go-sdk path through `internal/mcp`. +- [x] Step 8: `pkg/cilium` uses the go-sdk path through `internal/mcp`. +- [x] Step 9: `pkg/helm` uses the go-sdk path through `internal/mcp`. +- [x] Step 10: `pkg/istio` uses the go-sdk path through `internal/mcp`. +- [x] Step 11: `pkg/k8s` uses the go-sdk path through `internal/mcp`. +- [x] Step 12: `pkg/kubescape` builds its responses from concrete output structs, + and the tests decode those structs. +- [x] Step 13: `cmd/main.go` creates a go-sdk server, registers provider tools, + attaches MCP receiving middleware, and serves stdio plus Streamable HTTP. +- [x] Step 14: `test/e2e/helpers_test.go` uses the go-sdk client/session APIs. +- [x] Step 15: Final validation passes — see "Latest Verification". +- [x] Step 16: Every handler returns a typed `Out` instead of `any`. Raw CLI text + uses the shared `mcp.TextOutput` wrapper via `mcp.TextResult` / `mcp.TextError` + / `mcp.TextOf`; structured responses return their concrete DTO. `pkg/kubescape` + DTOs gained `omitempty` on map fields, `CheckStatus.Details` is typed as + `[]PodCheckEntry`, and `handleGetConfigurationScan` returns `mcp.TextOutput` + because `v1beta1.WorkloadConfigurationScan` cannot infer an output schema. + `pkg/prometheus` re-indents dynamic JSON with `json.Indent` instead of an + `interface{}` round-trip. No production file registers `Out=any`, and no + production file uses `interface{}` / untyped maps (verified by grep). + +### Still Open + +None. + +### Latest Verification + +Verified 2026-09-21 on `feature/mcp-sdk-migration` (typed-output pass). + +- `go build ./...` and `go vet ./...` pass; `gofmt -l` is clean. +- `make lint` passes with `0 issues` (golangci-lint v2.13.2, pinned in the + Makefile, with `.golangci.yml` for the go 1.27 directive). +- `go test ./pkg/... ./internal/... ./cmd/...` — 19/19 packages PASS, 0 failures. +- Coverage: every `pkg/` package is above the 80% gate (lowest `pkg/kubescape` + at 86.9%). The two internals below 80% (`internal/commands`, `internal/cmd`) + are pre-existing and unchanged by this work. +- `grep -rn "CallToolResult, any, error" pkg/ internal/ cmd/` (excluding tests) + returns nothing — no handler registers `Out=any`. +- `grep -rn "interface{}|map[string]interface{}|[]interface{}|map[string]any|[]any" pkg/ internal/ cmd/` + (excluding tests) returns nothing. +- `grep -rn "interface{}" test/e2e/` returns nothing — the e2e helpers now return + `*mcp.CallToolResult` and `[]*mcp.Tool`. +- `TestEveryToolHasValidOutputSchema` (`cmd/tools_output_schema_test.go`) registers + every provider on an in-memory transport and asserts each advertised tool carries + a JSON-serializable output schema; it passes, which also proves `mcp.AddTool` does + not panic on any `Out` type. +- Tool parity: `TestNoToolNameRegressions` passes, so all 124 names from v0.2.1 + are still advertised. +- e2e: the suite compiles under `-tags=test`; running it needs the Kind cluster + (see the note below), which is not available in this environment. + +Note: the e2e suite needs the repo's kind `extraPortMappings` (30884/30885) and +helm 3. It fails locally on helm 4 (server-side apply rejects the duplicate +`containerPort: 8084` in the chart) and when the host cannot reach the NodePort; +both are environment/chart issues that predate this migration and affect `main` +identically. + +--- + +## Step 1: Swap dependency and establish build baseline + +**Objective:** Replace the mark3labs dependency with the official SDK so every +subsequent step compiles against the new API from the start. + +**Implementation guidance:** +1. In `go.mod`, remove the `github.com/mark3labs/mcp-go` line. +2. Run `go get github.com/modelcontextprotocol/go-sdk@latest`. +3. Run `go mod tidy`. +4. The project will NOT compile at this point — that is expected. Every file + that imports `mark3labs` will report errors. +5. Do NOT fix any files yet — just verify that `go mod` resolves the new SDK. + +**Test requirements:** +- `go mod verify` passes (module graph is consistent). +- `go list -m github.com/modelcontextprotocol/go-sdk` prints the resolved version. + +**Integration notes:** +- No code changes outside `go.mod`/`go.sum` in this step. +- Commit the `go.mod`/`go.sum` change independently for easy bisect. + +**Demo:** `go list -m github.com/modelcontextprotocol/go-sdk` outputs the new version. + +--- + +## Step 2: Create `internal/mcputil` helpers + +**Objective:** Provide `TextResult` and `ErrorResult` helper functions that all +tool packages will use. Having these in place before migrating any package avoids +writing raw `&mcp.CallToolResult{Content: ...}` literals 40+ times. + +**Implementation guidance:** +1. Create `internal/mcputil/mcputil.go`: +```go +package mcputil + +import "github.com/modelcontextprotocol/go-sdk/mcp" + +func TextResult(text string) *mcp.CallToolResult { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: text}}, + } +} + +func ErrorResult(msg string) *mcp.CallToolResult { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{&mcp.TextContent{Text: msg}}, + } +} +``` +2. Create `internal/mcputil/mcputil_test.go` with table-driven tests covering + both helpers (verify `IsError`, `Content[0].(*mcp.TextContent).Text`). + +**Test requirements:** +- `go test ./internal/mcputil/...` passes with 100% coverage. + +**Integration notes:** +- This package has no dependency on any `pkg/*` or other `internal` packages — + it can be compiled independently even while the rest of the codebase has errors. + +**Demo:** `go test ./internal/mcputil/...` reports PASS. + +--- + +## Step 3: Migrate `internal/errors` — fix `ToolError` + +**Objective:** Fix `ToMCPResult()` which calls `mcp.NewToolResultError` (does not +exist in go-sdk), and replace `map[string]interface{}` with `map[string]string` +in `ToolError.Context`. + +**Implementation guidance:** +1. Update import: remove `mark3labs/mcp-go/mcp`, add `kagent-dev/tools/internal/mcputil`. +2. Change `ToolError.Context` field type: `map[string]interface{}` → `map[string]string`. +3. Update `WithContext(key string, value interface{})` → `WithContext(key, value string)`. +4. Update `NewToolError` constructor: `Context: make(map[string]string)`. +5. Replace `ToMCPResult()` body: `return mcp.NewToolResultError(message.String())` → + `return mcputil.ErrorResult(message.String())`. +6. Update `WithContext` call sites in the same file (all callers pass string values). + +**Test requirements:** +- `go test ./internal/errors/...` passes. +- Existing tests updated to pass string values to `WithContext`. +- Coverage ≥ 70%. + +**Integration notes:** +- `internal/errors` depends only on `internal/mcputil` (already done in Step 2). +- `pkg/*` packages that call `WithContext` will need their call sites updated when + each package is migrated (Steps 5–12) — not required here. + +**Demo:** `go test ./internal/errors/... ./internal/mcputil/...` reports PASS. + +--- + +## Step 4: Migrate `internal/telemetry` — rewrite to `mcp.Middleware` + +**Objective:** Remove the per-handler `WithTracing` wrapper and the `AdaptToolHandler` +adapter. Replace with a single server-level `NewTracingMiddleware()` factory that +returns an `mcp.Middleware` and intercepts all tool calls. + +**Implementation guidance:** +1. In `middleware.go`: + - Remove `import "github.com/mark3labs/mcp-go/server"`. + - Change import to `"github.com/modelcontextprotocol/go-sdk/mcp"`. + - Delete type `ToolHandler`. + - Delete functions `WithTracing` and `AdaptToolHandler`. + - Add: +```go +// NewTracingMiddleware returns an mcp.Middleware that records an OTel span +// for every MCP method call, with richer attributes for tools/call. +func NewTracingMiddleware() mcp.Middleware { + return func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + tracer := otel.Tracer("kagent-tools/mcp") + spanName := fmt.Sprintf("mcp.method.%s", method) + + // Enrich span name and attributes for tool calls + toolName := "" + if ctr, ok := req.(*mcp.CallToolRequest); ok { + toolName = ctr.Params.Name + spanName = fmt.Sprintf("mcp.tool.%s", toolName) + } + + ctx, span := tracer.Start(ctx, spanName) + defer span.End() + + headers := ExtractHTTPHeaders(ctx) + for k, v := range headers { + span.SetAttributes(attribute.String(fmt.Sprintf("http.header.%s", k), v)) + } + if toolName != "" { + span.SetAttributes(attribute.String("mcp.tool.name", toolName)) + } + span.AddEvent("mcp.method.start") + start := time.Now() + + result, err := next(ctx, method, req) + + span.SetAttributes(attribute.Float64("mcp.tool.duration_seconds", time.Since(start).Seconds())) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } else { + span.SetStatus(codes.Ok, "completed") + if ctr, ok := result.(*mcp.CallToolResult); ok { + span.SetAttributes(attribute.Bool("mcp.result.is_error", ctr.IsError)) + span.SetAttributes(attribute.Int("mcp.result.content_count", len(ctr.Content))) + } + } + return result, err + } + } +} +``` + +2. Update `middleware_test.go`: + - Remove test for `WithTracing` and `AdaptToolHandler`. + - Add test for `NewTracingMiddleware` using `mcp.NewInMemoryTransports()` to + create a real client-server pair with middleware applied; verify span is recorded. + +**Test requirements:** +- `go test ./internal/telemetry/...` passes. +- Coverage ≥ 70%. +- `WithTracing` and `AdaptToolHandler` are not referenced anywhere. + +**Integration notes:** +- `cmd/main.go` will call `mcpServer.AddReceivingMiddleware(telemetry.NewTracingMiddleware())` + in Step 13. +- Until Step 13, `NewTracingMiddleware` is defined but not yet wired. + +**Demo:** `go test ./internal/telemetry/...` reports PASS. + +--- + +## Step 5: Migrate `pkg/utils` + +**Objective:** Update `pkg/utils/common.go` (and any related files) to use +go-sdk types. `pkg/utils` is a leaf package with no dependencies on other `pkg/*` +packages, making it the safest starting point. + +**Implementation guidance:** +1. Replace imports: remove `mark3labs/mcp-go/mcp` and `mark3labs/mcp-go/server`, + add `modelcontextprotocol/go-sdk/mcp` and `kagent-dev/tools/internal/mcputil`. +2. For each tool handler: + - Define a `Params` struct with `json` and `jsonschema` tags. + - Change handler signature to `func(ctx, *mcp.CallToolRequest, Params) (*mcp.CallToolResult, any, error)`. + - Replace `mcp.ParseString(request, ...)` with struct field access. + - Replace `mcp.NewToolResultText(...)` with `mcputil.TextResult(...)`. + - Replace `mcp.NewToolResultError(...)` with `mcputil.ErrorResult(...)`. +3. Update `RegisterTools` signature: `func RegisterTools(s *mcp.Server, readOnly bool)`. +4. Replace `s.AddTool(mcp.NewTool(...), handler)` with `mcp.AddTool(s, &mcp.Tool{...}, handler)`. +5. Update `*_test.go`: call handlers directly with typed args structs. + +**Test requirements:** +- `go test ./pkg/utils/...` passes. +- Coverage ≥ 70%. +- No references to `mark3labs` in package. + +**Demo:** `go test ./pkg/utils/...` PASS. + +--- + +## Step 6: Migrate `pkg/prometheus` + +**Objective:** Migrate the Prometheus query tools. This package also includes +`promql.go` which uses MCP types for result construction. + +**Implementation guidance:** +1. Same handler migration pattern as Step 5. +2. Key params structs to define: + - `PrometheusQueryParams` (query string, time range, step) + - `PrometheusQueryRangeParams` + - `PrometheusInstantQueryParams` +3. Update `promql.go` if it constructs `mcp.CallToolResult` directly — replace + with `mcputil.TextResult` / `mcputil.ErrorResult`. +4. Update `prometheus_test.go` to use typed args. + +**Test requirements:** +- `go test ./pkg/prometheus/...` passes. +- Coverage ≥ 70%. + +**Demo:** `go test ./pkg/prometheus/...` PASS. + +--- + +## Step 7: Migrate `pkg/argo` + +**Objective:** Migrate the 8 Argo Rollouts tool handlers. + +**Implementation guidance:** +1. Define params structs: + - `VerifyArgoControllerParams` (namespace, label) + - `VerifyKubectlPluginParams` (no params — empty struct `struct{}`) + - `ListRolloutsParams` (namespace, type) + - `CheckPluginLogsParams` (namespace, timeout) + - `PromoteRolloutParams` (rollout_name, namespace, full bool) + - `PauseRolloutParams` (rollout_name, namespace) + - `SetRolloutImageParams` (rollout_name, container_image, namespace) + - `VerifyGatewayPluginParams` (version, namespace, should_install bool) +2. For handlers with no parameters (e.g., `handleVerifyKubectlPluginInstall`), + use an empty struct: `type VerifyKubectlPluginParams struct{}`. +3. Remove `WithTracing` wrapping from `RegisterTools` — tracing is now server-wide. +4. Update `argo_test.go`. + +**Test requirements:** +- `go test ./pkg/argo/...` passes. +- Coverage ≥ 90% (critical package per CLAUDE.md). + +**Demo:** `go test ./pkg/argo/...` PASS with ≥ 90% coverage shown. + +--- + +## Step 8: Migrate `pkg/cilium` + +**Objective:** Migrate the 12 Cilium tool handlers. + +**Implementation guidance:** +1. Define params structs for each handler: + - `CiliumStatusParams` — empty struct + - `UpgradeCiliumParams` (cluster_name, datapath_mode) + - `InstallCiliumParams` (cluster_name, cluster_id, datapath_mode) + - `UninstallCiliumParams` — empty struct + - `ConnectRemoteClusterParams` (cluster_name required, context) + - `DisconnectRemoteClusterParams` (cluster_name required) + - `ListBGPPeersParams` — empty struct + - `ListBGPRoutesParams` — empty struct + - `ClusterMeshStatusParams` — empty struct + - `FeaturesStatusParams` — empty struct + - `ToggleHubbleParams` (enable bool, default=true) + - `ToggleClusterMeshParams` (enable bool, default=true) +2. For boolean-toggle handlers, note that bool default in jsonschema tag must be + specified: `jsonschema:"enable Hubble,default=true"`. +3. Update `cilium_test.go`. + +**Test requirements:** +- `go test ./pkg/cilium/...` passes. +- Coverage ≥ 90%. + +**Demo:** `go test ./pkg/cilium/...` PASS. + +--- + +## Step 9: Migrate `pkg/helm` + +**Objective:** Migrate the 6 Helm tool handlers. + +**Implementation guidance:** +1. Define params structs: + - `HelmListParams` (namespace, all_namespaces, all, uninstalled, failed, deployed, pending, filter, output) + - `HelmGetReleaseParams` (name required, namespace required, output) + - `HelmUpgradeParams` (name required, chart required, namespace, version, values_file, set, wait bool, timeout, create_namespace bool, install bool) + - `HelmUninstallParams` (name required, namespace required, keep_history bool) + - `HelmRepoAddParams` (name required, url required, username, password, force_update bool) + - `HelmRepoUpdateParams` — empty struct +2. Update `helm_test.go`. +3. Verify security validation calls (`security.ValidateName`, etc.) still occur + after struct population — these are business-logic checks that remain. + +**Test requirements:** +- `go test ./pkg/helm/...` passes. +- Coverage ≥ 90%. + +**Demo:** `go test ./pkg/helm/...` PASS. + +--- + +## Step 10: Migrate `pkg/istio` + +**Objective:** Migrate all Istio tool handlers. + +**Implementation guidance:** +1. Define params structs for each istio handler (proxy-status, analyze, install, + upgrade, verify-install, etc.) — follow the same struct pattern. +2. Update `istio_test.go`. + +**Test requirements:** +- `go test ./pkg/istio/...` passes. +- Coverage ≥ 90%. + +**Demo:** `go test ./pkg/istio/...` PASS. + +--- + +## Step 11: Migrate `pkg/k8s` + +**Objective:** Migrate the largest and most critical package — all kubectl-based +Kubernetes tool handlers. + +**Implementation guidance:** +1. Define params structs for all handlers: + - `KubectlGetParams`, `KubectlLogsParams`, `ScaleDeploymentParams`, + `PatchResourceParams`, `ApplyManifestParams`, `DeleteResourceParams`, + `CheckServiceConnectivityParams`, `GetEventsParams`, `ExecCommandParams`, + `GetAvailableAPIResourcesParams`, `DescribeResourceParams`, + `ManageAnnotationParams`, `ManageLabelParams`, `SetAnnotationsParams`, + and any others present. +2. Required fields identified from current `if param == "" { return error }` guards: + use `jsonschema:"...,required"` for these, then remove the redundant guard. +3. Keep non-trivial business-logic guards (e.g., security validation). +4. Update `k8s_test.go` — this is the largest test file; use table-driven tests + for all params variations. + +**Test requirements:** +- `go test ./pkg/k8s/...` passes. +- Coverage ≥ 90%. + +**Demo:** `go test ./pkg/k8s/...` PASS with ≥ 90% coverage. + +--- + +## Step 12: Migrate `pkg/kubescape` + +**Objective:** Migrate all Kubescape scan and report tool handlers. + +**Implementation guidance:** +1. Define params structs for each handler (scan, get vulnerability manifests, + get configuration scans, get application profiles, etc.). +2. Update `kubescape_test.go`. + +**Test requirements:** +- `go test ./pkg/kubescape/...` passes. +- Coverage ≥ 90%. + +**Demo:** `go test ./pkg/kubescape/...` PASS. + +--- + +## Step 13: Migrate `cmd/main.go` — wire everything together + +**Objective:** Update the entry point to use the new SDK server, transports, +and middleware. This is the integration step that makes the full binary compile +and run end-to-end. + +**Implementation guidance:** +1. Remove `import "github.com/mark3labs/mcp-go/server"`. +2. Add `import "github.com/modelcontextprotocol/go-sdk/mcp"`. +3. Replace server creation: +```go +mcpServer := mcp.NewServer(&mcp.Implementation{ + Name: Name, + Version: Version, +}, nil) +``` +4. Add telemetry middleware: +```go +mcpServer.AddReceivingMiddleware(telemetry.NewTracingMiddleware()) +``` +5. Update `toolProviderMap` type: `map[string]func(*mcp.Server)`. +6. Replace `runStdioServer`: +```go +func runStdioServer(ctx context.Context, s *mcp.Server) { + logger.Get().Info("Running KAgent Tools Server STDIO:", "tools", strings.Join(tools, ",")) + if err := s.Run(ctx, &mcp.StdioTransport{}); err != nil { + logger.Get().Info("Stdio server stopped", "error", err) + } +} +``` +7. Replace HTTP server setup: +```go +httpHandler := mcp.NewStreamableHTTPHandler( + func(r *http.Request) *mcp.Server { return mcpServer }, + nil, +) +mux.Handle("/", telemetry.HTTPMiddleware(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + httpHandler.ServeHTTP(w, r) + }, +))) +``` +8. Remove the `server.WithHeartbeatInterval` option (no equivalent in go-sdk + StreamableHTTPHandler; rely on HTTP keep-alive). +9. Verify `registerMCP(mcpServer, ...)` compiles with `*mcp.Server` argument. + +**Test requirements:** +- `go build ./cmd/...` succeeds with zero errors. +- `go run ./cmd -- --stdio` starts and responds to `tools/list`. +- `make lint` passes. + +**Integration notes:** +- This is the first step where `grep -r "mark3labs" .` should return zero results. + +**Demo:** +```bash +echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | go run ./cmd -- --stdio +``` +Returns a JSON response listing all tools. + +--- + +## Step 14: Update E2E test helpers + +**Objective:** Update `test/e2e/helpers_test.go` to use go-sdk client types for +integration test scaffolding. + +**Implementation guidance:** +1. Replace `mark3labs` client types with go-sdk equivalents: +```go +// Before: mark3labs client construction +// After: +client := mcp.NewClient(&mcp.Implementation{Name: "test-client"}, nil) +transport := &mcp.CommandTransport{Command: exec.Command("./bin/kagent-tools", "--stdio")} +session, err := client.Connect(ctx, transport, nil) +``` +2. Update tool invocations: +```go +res, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "kubectl_get", + Arguments: map[string]any{"resource_type": "pod"}, +}) +``` +3. Replace result assertions: +```go +// Check IsError flag +if res.IsError { t.Fatalf(...) } +text := res.Content[0].(*mcp.TextContent).Text +``` + +**Test requirements:** +- `go test ./test/e2e/...` passes (or is skipped gracefully when cluster unavailable). + +**Demo:** `go test ./test/e2e/... -run TestToolsList` PASS. + +--- + +## Step 15: Final validation + +**Objective:** Confirm all quality gates pass, no mark3labs references remain, +and the binary behaves identically to before migration. + +**Implementation guidance:** +1. Run full test suite: +```bash +make test +``` +2. Verify zero mark3labs references: +```bash +grep -r "mark3labs" . --include="*.go" --include="go.mod" +# must return: no output +``` +3. Verify no `map[any]any` or `map[string]interface{}` in tool params: +```bash +grep -r "map\[string\]interface{}\|map\[string\]any\|map\[any\]" pkg/ internal/ --include="*.go" +# must return: no output +``` +4. Run linter: +```bash +make lint +``` +5. Build all platform binaries: +```bash +make build +``` +6. Smoke test both transports: +```bash +# Stdio +echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' \ + | ./bin/kagent-tools --stdio + +# HTTP +./bin/kagent-tools --port 8085 & +sleep 1 +curl -s http://localhost:8085/health +kill %1 +``` + +**Test requirements:** +- `make test` exits 0. +- `make lint` exits 0. +- `make build` exits 0. +- Both smoke tests return expected responses. +- `grep -r "mark3labs" .` returns no matches. + +**Demo:** CI pipeline passes (or equivalent local `make test && make lint && make build`). diff --git a/specs/tools/001-migrate-mcp-go-to-official-sdk/requirements.md b/specs/tools/001-migrate-mcp-go-to-official-sdk/requirements.md new file mode 100644 index 00000000..1d116695 --- /dev/null +++ b/specs/tools/001-migrate-mcp-go-to-official-sdk/requirements.md @@ -0,0 +1,25 @@ +# Requirements Q&A + +> This file captures requirements clarification questions and answers gathered during the PDD process. +> Questions and answers are appended in real time. + +--- + +## Q1: What type safety requirements apply to the SDK migration? + +**Q:** Should the migration use any dynamic/generic map types (e.g. `map[string]any`, `map[any]any`) for tool parameters or results, or should concrete Go struct types be used? + +**A:** Use Go struct types throughout. Avoid `map[any]any` and prefer typed structs for all tool parameters, inputs, and outputs. This applies to parameter parsing, result construction, and any intermediate data structures introduced during the migration. + +--- + +## Research findings appended + +See `research/sdk-comparison.md` and `skill.md` for the full API mapping. + +Key confirmed facts from official SDK examples and pkg.go.dev: +- `mcp.AddTool` is a generic function that auto-derives JSON schema from the typed `In` param struct. +- `ToolHandlerFor[In, Out any]` signature returns `(*CallToolResult, any, error)` — three values. +- No `NewToolResultText` / `NewToolResultError` helpers — must construct `CallToolResult` directly or add local helpers. +- Middleware uses `AddReceivingMiddleware` with `mcp.MethodHandler` / `mcp.Middleware` types. +- `ToolError.Context` field (`map[string]interface{}`) violates no-map-any-any rule and must be replaced. diff --git a/specs/tools/001-migrate-mcp-go-to-official-sdk/research/sdk-comparison.md b/specs/tools/001-migrate-mcp-go-to-official-sdk/research/sdk-comparison.md new file mode 100644 index 00000000..c45ce756 --- /dev/null +++ b/specs/tools/001-migrate-mcp-go-to-official-sdk/research/sdk-comparison.md @@ -0,0 +1,222 @@ +# SDK Comparison: mark3labs/mcp-go vs modelcontextprotocol/go-sdk + +## Sources +- https://github.com/modelcontextprotocol/go-sdk +- https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp +- https://github.com/modelcontextprotocol/go-sdk/tree/main/examples + +--- + +## Current dependency (mark3labs/mcp-go v0.43.2) + +### Imports used in this project +``` +"github.com/mark3labs/mcp-go/mcp" +"github.com/mark3labs/mcp-go/server" +``` + +### Server lifecycle +```go +// Create server +mcpServer := server.NewMCPServer(name, version) + +// Stdio mode +stdioServer := server.NewStdioServer(mcpServer) +stdioServer.Listen(ctx, os.Stdin, os.Stdout) + +// HTTP/SSE mode +sseServer := server.NewStreamableHTTPServer(mcpServer, + server.WithHeartbeatInterval(30*time.Second), +) +sseServer.ServeHTTP(w, r) +``` + +### Tool definition & registration +```go +// Define tool with option-function pattern +tool := mcp.NewTool("tool_name", + mcp.WithDescription("description"), + mcp.WithString("param", + mcp.Required(), + mcp.Description("param description"), + ), + mcp.WithBoolean("flag", + mcp.Description("flag description"), + ), + mcp.WithNumber("count", + mcp.Description("count description"), + ), +) +// Register on server +mcpServer.AddTool(tool, handler) +``` + +### Handler signature +```go +type ToolHandlerFunc func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) +// Note: CallToolRequest is a value type (not pointer) in mark3labs +``` + +### Parameter parsing +```go +// String with default +val := mcp.ParseString(request, "param_name", "default") +// Int with default +count := mcp.ParseInt(request, "count", 50) +// Bool equivalent (parsed as string) +flag := mcp.ParseString(request, "flag", "") == "true" +``` + +### Result construction +```go +// Success +return mcp.NewToolResultText("output text"), nil +// Error (tool-level, not protocol error) +return mcp.NewToolResultError("error message"), nil +``` + +### Middleware / telemetry adapter +```go +// Adapter wraps a typed ToolHandler into server.ToolHandlerFunc +func AdaptToolHandler(th ToolHandler) server.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return th(ctx, req) + } +} +``` + +### request.Params access (used in telemetry) +```go +request.Params.Name // tool name string +request.Params.Arguments // map[string]interface{} or nil +``` + +--- + +## Target dependency (modelcontextprotocol/go-sdk, latest) + +### Import +```go +"github.com/modelcontextprotocol/go-sdk/mcp" +``` + +### Server lifecycle +```go +// Create server +server := mcp.NewServer(&mcp.Implementation{Name: "name", Version: "v1.0"}, nil) + +// Stdio mode (blocks until client disconnects) +server.Run(ctx, &mcp.StdioTransport{}) + +// HTTP/SSE mode (legacy SSE, spec 2024-11-05) +handler := mcp.NewSSEHandler(func(r *http.Request) *mcp.Server { + return server +}, nil) +http.ListenAndServe(addr, handler) + +// HTTP Streamable mode (spec 2025-03-26+) +handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { + return server +}, nil) +http.ListenAndServe(addr, handler) +``` + +### Tool definition & registration (typed — PREFERRED) +```go +// Define typed params struct +type MyToolParams struct { + Param string `json:"param" jsonschema:"description of param,required"` + Flag bool `json:"flag" jsonschema:"flag description"` + Count int `json:"count" jsonschema:"count description"` +} + +// Register — schema auto-derived from struct tags +mcp.AddTool(server, &mcp.Tool{ + Name: "tool_name", + Description: "description", +}, func(ctx context.Context, req *mcp.CallToolRequest, args MyToolParams) (*mcp.CallToolResult, any, error) { + // args.Param, args.Flag, args.Count are already populated and validated + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "output"}}, + }, nil, nil +}) +``` + +### Tool definition & registration (low-level — avoid if possible) +```go +// Low-level: handler receives raw CallToolRequest, no auto-validation +server.AddTool(&mcp.Tool{ + Name: "tool_name", + Description: "description", + InputSchema: &jsonschema.Schema{ /* ... */ }, +}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // manual parsing required + return &mcp.CallToolResult{...}, nil +}) +``` + +### Handler signatures +```go +// Typed (preferred) — ToolHandlerFor[In, Out any] +func(ctx context.Context, req *mcp.CallToolRequest, args MyParams) (*mcp.CallToolResult, any, error) + +// Low-level — ToolHandler +func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) +``` + +### Result construction +```go +// Success +return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "output text"}}, +}, nil, nil + +// Tool-level error (IsError=true, not a protocol error) +return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{&mcp.TextContent{Text: "error message"}}, +}, nil, nil + +// Protocol-level error (returns as Go error) +return nil, nil, fmt.Errorf("protocol error: %w", err) +``` + +### Middleware +```go +type MethodHandler func(ctx context.Context, method string, req Request) (Result, error) +type Middleware func(next MethodHandler) MethodHandler + +server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + // pre-processing + result, err := next(ctx, method, req) + // post-processing + return result, err + } +}) + +// Access tool info inside middleware: +if ctr, ok := req.(*mcp.CallToolRequest); ok { + _ = ctr.Params.Name // tool name + _ = ctr.Params.Arguments // json.RawMessage +} +// Access tool result in middleware: +if ctr, ok := result.(*mcp.CallToolResult); ok { + _ = ctr.IsError + _ = ctr.StructuredContent +} +``` + +### Key types +```go +mcp.Implementation{Name string; Version string} +mcp.ServerOptions{} +mcp.Tool{Name string; Description string; InputSchema *jsonschema.Schema; OutputSchema *jsonschema.Schema} +mcp.CallToolRequest // = ServerRequest[*CallToolParamsRaw] +mcp.CallToolResult{Content []Content; IsError bool; StructuredContent any} +mcp.Content // interface +mcp.TextContent{Text string; Meta Meta; Annotations *Annotations} +mcp.StdioTransport{} +mcp.SSEHandler // http.Handler for SSE +mcp.StreamableHTTPHandler // http.Handler for streamable HTTP +``` diff --git a/specs/tools/001-migrate-mcp-go-to-official-sdk/rough-idea.md b/specs/tools/001-migrate-mcp-go-to-official-sdk/rough-idea.md new file mode 100644 index 00000000..54d29f75 --- /dev/null +++ b/specs/tools/001-migrate-mcp-go-to-official-sdk/rough-idea.md @@ -0,0 +1,20 @@ +# Rough Idea + +## Summary + +Migrate `github.com/mark3labs/mcp-go` to the official MCP Go SDK at `https://github.com/modelcontextprotocol/go-sdk`. + +## Context + +The project currently depends on the community-maintained MCP Go SDK (`github.com/mark3labs/mcp-go v0.43.2`). The official MCP Go SDK has been released at `github.com/modelcontextprotocol/go-sdk`. The migration should ensure all existing functionality is preserved while adopting the officially-supported library. + +## Current State + +- **Dependency**: `github.com/mark3labs/mcp-go v0.43.2` +- **Usage**: Tool registration, MCP server setup, transport handling (stdio, HTTP/SSE), tool result types +- **Files affected**: `cmd/main.go`, all `pkg/*/` tool packages +- **CLAUDE.md** already references `github.com/modelcontextprotocol/go-sdk` as the active technology + +## Goal + +Replace all usage of `github.com/mark3labs/mcp-go` with `github.com/modelcontextprotocol/go-sdk` across the codebase, maintaining full feature parity and test coverage requirements. diff --git a/specs/tools/001-migrate-mcp-go-to-official-sdk/skill.md b/specs/tools/001-migrate-mcp-go-to-official-sdk/skill.md new file mode 100644 index 00000000..d5738ffc --- /dev/null +++ b/specs/tools/001-migrate-mcp-go-to-official-sdk/skill.md @@ -0,0 +1,442 @@ +# Migration Skill: mark3labs/mcp-go → modelcontextprotocol/go-sdk + +> Reference document for migrating `github.com/mark3labs/mcp-go` to the official +> `github.com/modelcontextprotocol/go-sdk`. Use this as the authoritative lookup +> during implementation. All patterns use concrete Go struct types — no `map[any]any`. + +--- + +## 1. Dependency Change + +```diff +# go.mod +- github.com/mark3labs/mcp-go v0.43.2 ++ github.com/modelcontextprotocol/go-sdk +``` + +```bash +go get github.com/modelcontextprotocol/go-sdk@latest +go mod tidy +``` + +--- + +## 2. Import Paths + +| mark3labs | go-sdk | +|-----------|--------| +| `"github.com/mark3labs/mcp-go/mcp"` | `"github.com/modelcontextprotocol/go-sdk/mcp"` | +| `"github.com/mark3labs/mcp-go/server"` | _(removed — all under `mcp` package)_ | + +--- + +## 3. Server Creation + +### mark3labs +```go +import "github.com/mark3labs/mcp-go/server" + +mcpServer := server.NewMCPServer(Name, Version) +``` + +### go-sdk +```go +import "github.com/modelcontextprotocol/go-sdk/mcp" + +mcpServer := mcp.NewServer(&mcp.Implementation{ + Name: Name, + Version: Version, +}, nil) +``` + +--- + +## 4. Tool Handler Signature + +This is the most impactful change. Replace dynamic parsing with typed structs. + +### mark3labs +```go +func handleMyTool(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + param := mcp.ParseString(request, "param_name", "") + count := mcp.ParseInt(request, "count", 50) + flag := mcp.ParseString(request, "flag", "") == "true" + // ... +} +``` + +### go-sdk (REQUIRED pattern — typed structs, no map[any]any) +```go +// 1. Define a params struct for every tool +type MyToolParams struct { + ParamName string `json:"param_name" jsonschema:"description of param"` + Count int `json:"count" jsonschema:"number of lines,default=50"` + Flag bool `json:"flag" jsonschema:"enable flag"` +} + +// 2. Handler receives populated, validated struct directly +func handleMyTool(ctx context.Context, req *mcp.CallToolRequest, args MyToolParams) (*mcp.CallToolResult, any, error) { + // args.ParamName, args.Count, args.Flag are already set + // ... +} +``` + +**Key rules:** +- Every tool MUST have a dedicated params struct. +- Fields validated as `required` in jsonschema will return a tool error automatically. +- Handler returns THREE values: `(*mcp.CallToolResult, any, error)` — the middle `any` is the structured output (return `nil` if unused). +- `req` is a pointer (`*mcp.CallToolRequest`), not a value. + +--- + +## 5. Tool Definition & Registration + +### mark3labs +```go +tool := mcp.NewTool("tool_name", + mcp.WithDescription("description"), + mcp.WithString("param", mcp.Required(), mcp.Description("...")), + mcp.WithBoolean("flag", mcp.Description("...")), + mcp.WithNumber("count", mcp.Description("...")), +) +mcpServer.AddTool(tool, handler) +``` + +### go-sdk +```go +// Schema is auto-derived from the params struct — no need to list params manually. +mcp.AddTool(mcpServer, &mcp.Tool{ + Name: "tool_name", + Description: "description", +}, handleMyTool) +``` + +**Struct tags that drive schema generation:** + +| Tag | Purpose | +|-----|---------| +| `json:"field_name"` | JSON key name (required) | +| `jsonschema:"description text"` | Field description shown in schema | +| `jsonschema:"description,required"` | Mark field as required | +| `jsonschema:"description,default=value"` | Provide default value | + +--- + +## 6. Result Construction + +### mark3labs → go-sdk + +| Scenario | mark3labs | go-sdk | +|----------|-----------|--------| +| **Success** | `mcp.NewToolResultText("text")` | `&mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "text"}}}` | +| **Tool error** | `mcp.NewToolResultError("msg")` | `&mcp.CallToolResult{IsError: true, Content: []mcp.Content{&mcp.TextContent{Text: "msg"}}}` | +| **Protocol error** | `return nil, fmt.Errorf("...")` | `return nil, nil, fmt.Errorf("...")` | + +### Helper functions to define (add to `pkg/utils/` or `internal/mcputil/`) + +Since the official SDK has no `NewToolResultText`/`NewToolResultError` helpers, +define these once and reuse: + +```go +package mcputil + +import "github.com/modelcontextprotocol/go-sdk/mcp" + +func TextResult(text string) *mcp.CallToolResult { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: text}}, + } +} + +func ErrorResult(msg string) *mcp.CallToolResult { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{&mcp.TextContent{Text: msg}}, + } +} +``` + +--- + +## 7. Transport / Server Startup + +### Stdio transport + +#### mark3labs +```go +stdioServer := server.NewStdioServer(mcpServer) +stdioServer.Listen(ctx, os.Stdin, os.Stdout) +``` + +#### go-sdk +```go +// Run blocks until client disconnects or ctx is cancelled +if err := mcpServer.Run(ctx, &mcp.StdioTransport{}); err != nil { + logger.Get().Info("Stdio server stopped", "error", err) +} +``` + +### HTTP/SSE transport + +#### mark3labs +```go +sseServer := server.NewStreamableHTTPServer(mcpServer, + server.WithHeartbeatInterval(30*time.Second), +) +mux.Handle("/", sseServer) +``` + +#### go-sdk +```go +// StreamableHTTPHandler (MCP spec 2025-03-26+) +handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { + return mcpServer +}, nil) +mux.Handle("/", handler) + +// OR legacy SSEHandler (MCP spec 2024-11-05) +handler := mcp.NewSSEHandler(func(r *http.Request) *mcp.Server { + return mcpServer +}, nil) +mux.Handle("/", handler) +``` + +> **Note:** `WithHeartbeatInterval` has no direct equivalent — check +> `StreamableHTTPOptions` for any keepalive options in the installed version. + +--- + +## 8. Middleware / Telemetry + +The telemetry `WithTracing` wrapper currently adapts `ToolHandler` → `server.ToolHandlerFunc`. +With go-sdk, use `AddReceivingMiddleware` instead. + +### go-sdk middleware signature +```go +type MethodHandler func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) +type Middleware func(next mcp.MethodHandler) mcp.MethodHandler + +mcpServer.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + // Intercept tool calls + if ctr, ok := req.(*mcp.CallToolRequest); ok { + toolName := ctr.Params.Name + _ = toolName // use for spans + } + result, err := next(ctx, method, req) + // Inspect tool results + if ctr, ok := result.(*mcp.CallToolResult); ok { + _ = ctr.IsError + } + return result, err + } +}) +``` + +### Migrating `internal/telemetry/middleware.go` + +1. Remove `ToolHandler` type alias (no longer needed). +2. Remove `AdaptToolHandler` function. +3. Expose a `NewTracingMiddleware(tracer) mcp.Middleware` function instead. +4. The `WithTracing(toolName, handler)` wrapper pattern is replaced by a single + server-level middleware that extracts tool name from `req.(*mcp.CallToolRequest).Params.Name`. + +### Accessing request context in middleware +```go +// Tool name +ctr.Params.Name + +// Arguments (json.RawMessage, not map — use json.Unmarshal to read) +ctr.Params.Arguments + +// Session ID +req.GetSession().ID() +``` + +--- + +## 9. internal/errors/tool_errors.go + +`ToMCPResult()` calls `mcp.NewToolResultError(...)` which does not exist in go-sdk. + +### Fix +```go +// Before (mark3labs) +return mcp.NewToolResultError(message.String()) + +// After (go-sdk) +return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{&mcp.TextContent{Text: message.String()}}, +} +``` + +Also replace `map[string]interface{}` in `ToolError.Context` with a concrete struct +or `map[string]string` to honour the "no map[any]any" requirement. + +--- + +## 10. RegisterTools Function Signature + +All `pkg/*/` packages export a `RegisterTools` function. Signature changes from: + +```go +// mark3labs +func RegisterTools(s *server.MCPServer, readOnly bool) +``` + +to: + +```go +// go-sdk +func RegisterTools(s *mcp.Server, readOnly bool) +``` + +`cmd/main.go` `registerMCP` function and its `toolProviderMap` closures update accordingly: + +```go +// Before +toolProviderMap := map[string]func(*server.MCPServer){...} + +// After +toolProviderMap := map[string]func(*mcp.Server){...} +``` + +--- + +## 11. Params Struct Reference (per package) + +Define one `*Params` struct per tool handler. Name it `Params`. + +### Example: k8s package + +```go +// kubectl_get +type KubectlGetParams struct { + ResourceType string `json:"resource_type" jsonschema:"type of K8s resource (pod/deploy/svc..),required"` + ResourceName string `json:"resource_name" jsonschema:"name of the resource"` + Namespace string `json:"namespace" jsonschema:"namespace to query"` + AllNamespaces bool `json:"all_namespaces" jsonschema:"query all namespaces"` + Output string `json:"output" jsonschema:"output format (wide/json/yaml),default=wide"` +} + +// kubectl_logs +type KubectlLogsParams struct { + PodName string `json:"pod_name" jsonschema:"name of the pod,required"` + Namespace string `json:"namespace" jsonschema:"namespace,default=default"` + Container string `json:"container" jsonschema:"container name"` + TailLines int `json:"tail_lines" jsonschema:"number of log lines,default=50"` +} + +// scale_deployment +type ScaleDeploymentParams struct { + Name string `json:"name" jsonschema:"deployment name,required"` + Namespace string `json:"namespace" jsonschema:"namespace,default=default"` + Replicas int `json:"replicas" jsonschema:"desired replica count,default=1"` +} +``` + +### Example: helm package + +```go +type HelmListParams struct { + Namespace string `json:"namespace" jsonschema:"filter by namespace"` + AllNamespaces bool `json:"all_namespaces" jsonschema:"list across all namespaces"` + All bool `json:"all" jsonschema:"show all releases"` + Uninstalled bool `json:"uninstalled" jsonschema:"show uninstalled releases"` + Failed bool `json:"failed" jsonschema:"show failed releases"` + Deployed bool `json:"deployed" jsonschema:"show deployed releases"` + Pending bool `json:"pending" jsonschema:"show pending releases"` + Filter string `json:"filter" jsonschema:"regex filter for release names"` + Output string `json:"output" jsonschema:"output format"` +} +``` + +--- + +## 12. Test Migration + +Tests using mark3labs types must be updated: + +```go +// Before (mark3labs) +req := mcp.CallToolRequest{} +req.Params.Arguments = map[string]interface{}{"param": "value"} + +// After (go-sdk — construct the typed params struct directly in tests) +args := MyToolParams{ParamName: "value", Count: 10} +// Call handler directly with args, bypassing request parsing: +result, _, err := handleMyTool(ctx, &mcp.CallToolRequest{}, args) +``` + +For mock-based tests in `pkg/*/`, inject args directly into the typed handler — +no need to construct `CallToolRequest` params at all for unit tests. + +--- + +## 13. Files to Modify (complete list) + +| File | Change | +|------|--------| +| `go.mod` / `go.sum` | Replace dependency | +| `cmd/main.go` | Server creation, transports, `registerMCP` signature | +| `internal/telemetry/middleware.go` | Replace `ToolHandler` type, remove `AdaptToolHandler`, add `mcp.Middleware` factory | +| `internal/telemetry/middleware_test.go` | Update test types | +| `internal/errors/tool_errors.go` | Fix `ToMCPResult()`, fix `Context` map type | +| `pkg/k8s/k8s.go` | Params structs, handler signatures, registration | +| `pkg/k8s/k8s_test.go` | Update test helpers | +| `pkg/helm/helm.go` | Params structs, handler signatures, registration | +| `pkg/helm/helm_test.go` | Update test helpers | +| `pkg/istio/istio.go` | Params structs, handler signatures, registration | +| `pkg/istio/istio_test.go` | Update test helpers | +| `pkg/argo/argo.go` | Params structs, handler signatures, registration | +| `pkg/argo/argo_test.go` | Update test helpers | +| `pkg/cilium/cilium.go` | Params structs, handler signatures, registration | +| `pkg/cilium/cilium_test.go` | Update test helpers | +| `pkg/prometheus/prometheus.go` | Params structs, handler signatures, registration | +| `pkg/prometheus/prometheus_test.go` | Update test helpers | +| `pkg/prometheus/promql.go` | Update MCP types | +| `pkg/kubescape/kubescape.go` | Params structs, handler signatures, registration | +| `pkg/kubescape/kubescape_test.go` | Update test helpers | +| `pkg/utils/common.go` | Update MCP types | +| `pkg/utils/datetime_test.go` | Update test types | +| `test/e2e/helpers_test.go` | Update client/server setup | + +--- + +## 14. Migration Order (recommended) + +1. **`go.mod`** — swap dependency, run `go mod tidy` +2. **`internal/mcputil/`** — create `TextResult` / `ErrorResult` helpers (new file) +3. **`internal/errors/tool_errors.go`** — fix `ToMCPResult()` and `Context` field type +4. **`internal/telemetry/middleware.go`** — rewrite to `mcp.Middleware` pattern +5. **`pkg/utils/`** — update types (least dependent) +6. **`pkg/prometheus/`** — update types +7. **`pkg/argo/`**, **`pkg/cilium/`**, **`pkg/helm/`**, **`pkg/istio/`**, **`pkg/k8s/`**, **`pkg/kubescape/`** — update each package (params structs + handler signatures + registration) +8. **`cmd/main.go`** — update server creation and transport wiring +9. **All `*_test.go`** — update test helpers per package +10. **`test/e2e/`** — update integration test helpers + +Run `make test` and `make lint` after each package to catch regressions early. + +--- + +## 15. Quick Reference Card + +``` +REMOVED (mark3labs) → REPLACEMENT (go-sdk) +───────────────────────────────────────────────────────────────── +server.NewMCPServer(n,v) → mcp.NewServer(&mcp.Implementation{Name:n,Version:v}, nil) +server.NewStdioServer(s) → s.Run(ctx, &mcp.StdioTransport{}) +server.NewStreamableHTTP(s) → mcp.NewStreamableHTTPHandler(func(r)*mcp.Server{return s}, nil) +server.ToolHandlerFunc → mcp.ToolHandlerFor[In,Out] or mcp.ToolHandler +mcp.CallToolRequest (value) → *mcp.CallToolRequest (pointer) +mcp.ParseString(req,k,d) → struct field (typed params) +mcp.ParseInt(req,k,d) → struct field (typed params) +mcp.NewTool(name, opts...) → &mcp.Tool{Name:"...", Description:"..."} +s.AddTool(tool, handler) → mcp.AddTool(s, &mcp.Tool{...}, typedHandler) +mcp.NewToolResultText(t) → mcputil.TextResult(t) [local helper] +mcp.NewToolResultError(t) → mcputil.ErrorResult(t) [local helper] +handler returns (res, err) → handler returns (res, any, err) +───────────────────────────────────────────────────────────────── +``` diff --git a/specs/tools/001-migrate-mcp-go-to-official-sdk/summary.md b/specs/tools/001-migrate-mcp-go-to-official-sdk/summary.md new file mode 100644 index 00000000..5501a0e3 --- /dev/null +++ b/specs/tools/001-migrate-mcp-go-to-official-sdk/summary.md @@ -0,0 +1,20 @@ +# Run Summary + +## Metadata + +| Field | Value | +|-------|-------| +| Spec | `tools/001-migrate-mcp-go-to-official-sdk` | +| Agent | `task-1776639014218582000` | +| Outcome | **completed** | +| Retries | 0 / 10 | +| Started | 2026-04-20T00:50:14+02:00 | +| Duration | 8m30s | + +## Gates + +No gates defined. + +## Result + +All gates passed and changes were merged successfully. From a6ecb79e04bf7943df6f4952be8bed75d34d5328 Mon Sep 17 00:00:00 2001 From: Dmytro Rashko Date: Mon, 21 Sep 2026 16:11:28 +0200 Subject: [PATCH 14/21] docs: correct CLAUDE.md to point at AGENTS.md and drop stale API CLAUDE.md documented the pre-migration mark3labs API throughout: the tool registration example used mcp.NewTool with WithString/WithDescription, handlers used request.RequireString()/RequireBool(), and results used mcp.NewToolResultText(). None of that exists in the codebase after the go-sdk migration, so the examples would not compile and an agent following them would write handlers that break. It also had no mention of the typed-output contract that the migration introduced. It referenced things that are not in the repository either: pkg/logger (logging lives in internal/logger), make coverage-report (no such target), coverage.md, and test/integration/. Replace the duplicated and outdated content with a short file that directs readers to AGENTS.md as the single source of truth and adds only what AGENTS.md does not cover: the run-local flags, the registration/typed-output points that are easy to get wrong, and the logging package location. Also correct a claim both files made: that CI enforces the 80% coverage threshold. The go-unit-tests job runs "go test -v -cover", which reports coverage but has no gate, so a threshold breach cannot fail the build. Both files now describe 80% as the standard to check manually, and note that internal/commands and internal/cmd sit below it. Every claim in the rewritten file was checked against the repository: the CLI flags against "--help", each linked file for existence, per-package coverage with "go test -cover", the DCO check against the open pull request, and the claim that no provider imports the SDK directly with grep (all 127 registrations go through internal/mcp). Signed-off-by: Dmytro Rashko --- AGENTS.md | 6 +- CLAUDE.md | 235 ++++++++++++------------------------------------------ 2 files changed, 54 insertions(+), 187 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cdcd1abd..54ff6e0e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -248,7 +248,11 @@ The `internal/cache` package provides a thread-safe generic `Cache[T]` with TTL: - **Ginkgo v2 + Gomega** for behavioral tests - **testify** for assertions and mocking - Table-driven tests for comprehensive coverage -- **Minimum 80% test coverage** enforced by CI +- **Minimum 80% test coverage** is the repository standard. CI runs + `go test -v -cover` and reports coverage but has no threshold gate, so the + standard is on you to check (`go test -cover ./pkg/...`). Every `pkg/` package + currently exceeds it; `internal/commands` and `internal/cmd` are below it and + predate the standard. ### Mock Infrastructure diff --git a/CLAUDE.md b/CLAUDE.md index eea798eb..387de288 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,209 +1,72 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +This file provides guidance to Claude Code (claude.ai/code) when working in this repository. -## Quick Reference +**The repository guide lives in [`AGENTS.md`](./AGENTS.md).** It covers the architecture, +tool-provider layout, the typed MCP input/output contract, error handling, caching, testing, +CI/CD, commit conventions and the "what not to do" list. Read it before making changes; it is +the single source of truth and is kept current. -### Build & Test -```bash -make build # Build all platform binaries -make test # Run tests with coverage and linting -make lint # Run golangci-lint -make lint-fix # Auto-fix linting issues -make fmt # Format code with go fmt -``` - -### Run Locally -```bash -go run ./cmd # Run directly -./bin/kagent-tools --stdio # Stdio transport -./bin/kagent-tools --http --port 8084 # HTTP transport -``` - -### Test Specific Components -```bash -go test -v ./pkg/k8s # Test specific package -go test -v -cover ./... # All tests with coverage -``` +This file only adds the few things AGENTS.md does not spell out. ## Architecture Overview -This is a Go-based MCP (Model Context Protocol) server that wraps Kubernetes and cloud-native tool CLIs. Rather than reimplementing functionality, it provides a unified MCP interface to existing command-line tools. +A Go MCP (Model Context Protocol) server that wraps Kubernetes and cloud-native CLIs +(`kubectl`, `helm`, `istioctl`, `cilium`, `kubectl-argo-rollouts`, `kubescape`, +the Prometheus HTTP API) behind a single typed MCP interface. It does not reimplement the +tools' behaviour; it validates input, invokes the CLI, and returns a typed result. -### Core Design -- **Single responsibility packages**: Each `pkg/` subdirectory handles one tool category (k8s, helm, istio, etc.) -- **CLI wrapper pattern**: Tools call external CLIs (kubectl, helm, istioctl, etc.) and return formatted results -- **MCP SDK integration**: Uses `github.com/modelcontextprotocol/go-sdk` for all tool registration and communication -- **Multiple transports**: Supports stdio (for direct client integration) and HTTP/SSE (for web integration) -- **Type-safe parameters**: All tool parameters validated using `request.RequireString()`, `request.RequireBool()`, etc. +Two design points that are easy to get wrong: -### Package Structure -``` -pkg/ -├── k8s/ # Kubernetes operations via kubectl -├── helm/ # Helm package management -├── istio/ # Istio service mesh via istioctl -├── argo/ # Argo Rollouts via kubectl plugins -├── cilium/ # Cilium CNI operations -├── prometheus/ # Prometheus API queries -├── utils/ # Common shell command execution -├── logger/ # Structured logging -``` +- **Registration goes through `internal/mcp`, not the SDK directly.** `mcp.AddTool` records the + provider for metrics and relaxes the inferred input schema so optional fields stay optional. + Never call `sdk.AddTool` from a provider. +- **Every handler returns a concrete `Out` type.** The SDK infers an output schema from it, + populates `structuredContent`, and validates the value on every call — including error paths. + See "Typed MCP Inputs and Outputs" in AGENTS.md for the three pitfalls that break tools. -### Key Implementation Files -- `cmd/main.go`: MCP server setup, CLI flag handling, transport initialization -- `pkg/[category]/[category].go`: Tool registration and handler implementation -- Tool handlers follow: parse params → execute CLI → format result → return MCP result +## Run Locally -## Development Practices - -### MCP Tool Implementation Pattern -When adding a new tool, follow this structure: - -1. **Define in RegisterTools()**: Use `mcp.NewTool()` with parameters -2. **Type-safe parsing**: Use `request.RequireString()`, `request.RequireBool()` for validation -3. **CLI execution**: Use `runCommand()` utility for consistent error handling -4. **Result formatting**: Return `mcp.NewToolResultText()` for success or `mcp.NewToolResultError()` for failures - -Example from existing code (pkg/k8s/k8s.go style): -```go -func (t *Tools) RegisterTools(server *mcp.Server) error { - tool := mcp.NewTool("tool_name", - mcp.WithDescription("What this tool does"), - mcp.WithString("param", mcp.Required(), mcp.Description("Parameter description")), - ) - server.AddTool(tool, t.handleToolName) - return nil -} - -func (t *Tools) handleToolName(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - param, err := request.RequireString("param") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - result, err := runCommand(ctx, "external-cli", []string{param}) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed: %v", err)), nil - } - return mcp.NewToolResultText(result), nil -} +```bash +go run ./cmd # defaults to stdio +./bin/kagent-tools --stdio # stdio transport +./bin/kagent-tools --http --port 8084 # HTTP transport ``` -### Testing Requirements ⚠️ 80% Coverage Required +Useful flags: `--tools k8s,helm` (limit providers), `--kubeconfig `, +`--read-only` (do not register write tools), `--metrics-port`. -**IMPORTANT**: This project enforces 80% test coverage. This is a hard requirement: -- **Overall threshold**: 80% coverage across entire codebase (CI enforces this) -- **Per-package minimum**: 70% for all packages -- **Critical packages**: 90% for k8s, helm, istio, argo (tool wrapper packages) -- **Unit tests only**: Coverage calculated from unit tests (integration tests supplementary) +## Development Practices -**How to check coverage locally**: -```bash -make test # Runs tests with coverage -make coverage-report # Generates HTML report (open coverage.html) -go test -cover ./pkg/example # Check specific package -``` +- Run the narrowest useful test first, then broaden: `go test -tags=test -v -cover ./pkg/` + before `make test`. +- `make test` = build + lint + all tests. `make test-only` skips build/lint. +- Use the mock shell executor for unit tests; never shell out to real CLIs in unit tests. +- Keep functions focused and testable, and use `context` for cancellation in long-running work. -**How to improve coverage**: -1. Run `make coverage-report` and open `coverage.html` -2. Find red (uncovered) lines in your package -3. Write table-driven tests for uncovered functions -4. Run `make test` again to verify improvement -5. See coverage.md for detailed guidance - -**Testing Patterns** (follow these strictly): -- **Table-driven tests**: Recommended for all scenarios (see examples in pkg/k8s/*_test.go) -- **Mock external dependencies**: Don't test kubectl/helm directly, test our wrappers -- **Test error paths**: Not just happy path (error handling must be covered) -- **Test edge cases**: Boundary conditions, empty inputs, etc. -- **Integration tests** in `test/integration/`: For testing actual tool execution - -**CI Enforcement**: Coverage check is automated in CI pipeline: -- Build fails if overall coverage < 80% -- Build fails if any package < 70% -- Build fails if critical packages < 90% -- Cannot merge without passing coverage check - -**See Also**: coverage.md (detailed coverage guide), quickstart.md (developer quick start) - -### Code Quality -- Run `make lint` before submitting changes -- Use `go fmt ./...` for formatting (also: `make fmt`) -- Keep functions focused and testable -- Use context for cancellation in long-running operations - -## Common Tasks - -### Adding a New Tool -1. Create function in appropriate `pkg/[category]/` file -2. Register with MCP SDK using `mcp.NewTool()` in `RegisterTools()` -3. Parse params with `request.RequireString()`, `request.RequireBool()`, etc. -4. Execute using `runCommand()` utility -5. Return results using `mcp.NewToolResultText()` or `mcp.NewToolResultError()` -6. Add unit tests with 80%+ coverage -7. Update README.md tool list - -### Debugging -```bash -LOG_LEVEL=debug go run ./cmd # Debug logging -go run ./cmd --stdio # Stdio transport (easier to debug) -``` +### Test Coverage -### Docker Testing -```bash -make docker-build # Build Docker image -make run # Run in Docker -``` +- The project targets 80% coverage; every `pkg/` package currently exceeds it (lowest is + `pkg/kubescape` at ~85%, highest ~99%). +- **CI does not enforce a coverage gate.** The `go-unit-tests` job runs `go test -v -cover`, + which reports coverage but does not fail the build on a threshold. Treat 80% as the + repository standard to maintain, not as an automated gate — check it yourself with + `go test -cover ./pkg/...`. +- `internal/commands` and `internal/cmd` are below 80% and predate that standard. -### Integration with External Tools -Most tools depend on these being installed and in PATH: -- `kubectl` - for k8s tools -- `helm` - for helm tools -- `istioctl` - for istio tools -- `cilium` - for cilium tools - -The `KUBECONFIG` environment variable is respected by k8s tools. - -## Important Design Notes - -### Why CLI Wrappers? -This approach allows: -- Minimal dependencies (no large Go SDK libraries) -- Feature parity with latest CLI versions -- Users can test locally without complex setup -- Easy to keep in sync with upstream tools - -### Error Handling -- Always wrap errors with context: `fmt.Errorf("failed to do X: %w", err)` -- Return MCP errors using `mcp.NewToolResultError()` with descriptive messages -- External tool failures are caught and returned as readable errors - -### Logging -Use structured logging via logr (see pkg/logger/): -```go -logger := logr.FromContextOrDiscard(ctx) -logger.Info("executing command", "command", cmd, "args", args) -logger.Error(err, "command failed", "command", cmd) -``` +## Logging -## Contribution Standards +Structured logging lives in `internal/logger` (not `pkg/logger`). Prefer the package-level +logger used by the surrounding code. -From CONTRIBUTION.md - key principles: -- **Principle I**: Use official MCP SDK patterns -- **Principle II**: Type-safe input validation -- **Principle III**: Write tests BEFORE implementation (TDD) -- **Principle IV**: Modular packages under `pkg/` -- **Principle V**: Structured logging and input sanitization +## Commit Messages -Follow Conventional Commits: -- `feat(scope): description` - New feature -- `fix(scope): description` - Bug fix -- `test(scope): description` - Test changes -- `docs(scope): description` - Documentation +Conventional Commits, with a `Signed-off-by` trailer (DCO is enforced on pull requests): +`feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `perf`, `ci`. -## Active Technologies -- Go 1.x (from go.mod and project setup) + Go standard library, testing libraries (built-in), MCP SDK from `github.com/modelcontextprotocol/go-sdk` (002-test-coverage) -- N/A (test coverage is metadata-only) (002-test-coverage) +## Additional Resources -## Recent Changes -- 002-test-coverage: Added Go 1.x (from go.mod and project setup) + Go standard library, testing libraries (built-in), MCP SDK from `github.com/modelcontextprotocol/go-sdk` +- [AGENTS.md](AGENTS.md) — the repository guide (authoritative) +- [DEVELOPMENT.md](DEVELOPMENT.md) — setup and code standards +- [CONTRIBUTION.md](CONTRIBUTION.md) — contribution process and PR guidelines +- [docs/quickstart.md](docs/quickstart.md) — quick start guide From a1cb01939a7c5ddd28cf7934cff9001c29fdc309 Mon Sep 17 00:00:00 2001 From: Dmytro Rashko Date: Tue, 22 Sep 2026 15:09:56 +0200 Subject: [PATCH 15/21] fix(security): redact credentials in mcp_inspect and close two review findings Findings from a codex review of this branch. 1. mcp_inspect disclosed credentials (high). The HTTP transport hands handlers the raw inbound header set, so req.Header contains Authorization, Cookie and anything else the client sent. The tool returned every value to any caller able to invoke it, exposing the caller's own bearer token and session secrets. Header values are now withheld when the canonical name contains a credential-bearing fragment (authorization, cookie, token, secret, api-key, credential, password, authenticat, session, bearer, jwt, signature). An earlier exact-match list was insufficient and a second review pass caught it: it missed Private-Token, X-Amz-Security-Token, X-Access-Token and similar real-world names, so the policy is deny-by-substring. Over-redacting a debugging value costs nothing; leaking a credential does not. Header names are still returned and the received value count is preserved, so the tool remains useful for debugging which headers arrived. 2. Tool-level failures left the span status unset (medium). A handler signalling failure via IsError=true with a nil Go error incremented the Prometheus failure counter but left the OTel span status unset, because both RecordError and SetStatus sat inside `if err != nil`. Traces therefore disagreed with metrics and the span was neither Ok nor Error. The middleware now marks such spans Error using the tool's message, and sets is_error on the span. 3. kubescape_get_vulnerability_details emitted its payload twice (medium). For a non-object (array) Out the SDK appends the serialised value as an extra TextContent block, so returning the matches as Out *and* marshalling them into Content produced the vulnerability list twice for clients reading Content. The handler now returns `nil, matches, nil` and lets the SDK serialise. Tests cover each fix and were checked for vacuity: reverting the span fix makes TestToolMiddleware_MarksSpanForToolLevelError fail with "span status: expected Error, got Unset", and TestIsSensitiveHeader pins both directions of the redaction policy (18 credential names withheld, 8 innocuous headers kept visible). Verified end to end by deploying the image and sending Authorization, Private-Token, X-Amz-Security-Token and X-Access-Token: all returned [REDACTED] while X-Request-Id stayed visible. Verified: go build ./..., go vet ./... and gofmt clean; go test ./pkg/... ./internal/... ./cmd/... passes 19/19 packages; make lint reports 0 issues; the e2e suite passes 26 specs with the read-only sweep still covering 80/80 tools. Signed-off-by: Dmytro Rashko --- internal/mcp/mcp.go | 24 +++++++++-- internal/mcp/mcp_test.go | 73 +++++++++++++++++++++++++++++++++ pkg/kubescape/kubescape.go | 11 +++-- pkg/kubescape/kubescape_test.go | 15 ++++--- pkg/utils/common.go | 53 ++++++++++++++++++++++++ pkg/utils/common_test.go | 72 +++++++++++++++++++++++++++++++- 6 files changed, 230 insertions(+), 18 deletions(-) diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index 6bc0dccc..2d7f372d 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -92,8 +92,14 @@ func TextError(message string) (*sdk.CallToolResult, TextOutput, error) { // handlers that build a *CallToolResult in a helper can still return a typed // output value. A nil result yields an empty TextOutput. func TextOf(res *sdk.CallToolResult) TextOutput { + return TextOutput{Output: toolResultText(res)} +} + +// toolResultText returns the concatenated text content of a result, used to +// report a tool-level failure on a span. A nil result yields "". +func toolResultText(res *sdk.CallToolResult) string { if res == nil { - return TextOutput{} + return "" } var b strings.Builder for _, content := range res.Content { @@ -101,7 +107,7 @@ func TextOf(res *sdk.CallToolResult) TextOutput { b.WriteString(textContent.Text) } } - return TextOutput{Output: b.String()} + return b.String() } // providerByTool maps a registered tool name to its provider for metric labels. @@ -182,14 +188,26 @@ func ToolMiddleware() sdk.Middleware { span.SetAttributes(attribute.Float64("mcp.tool.duration_seconds", time.Since(start).Seconds())) failed := err != nil + var toolErrMessage string if ctres, ok := res.(*sdk.CallToolResult); ok && ctres != nil && ctres.IsError { failed = true + toolErrMessage = toolResultText(ctres) } if failed { metrics.KagentToolsMCPInvocationsFailureTotal.WithLabelValues(toolName, provider).Inc() - if err != nil { + span.SetAttributes(attribute.Bool("mcp.tool.is_error", true)) + // Tool-level failures (IsError=true) arrive with a nil Go error. + // They must still mark the span, otherwise the failure counter + // and the traces disagree and the span stays neither Ok nor + // Error. + switch { + case err != nil: span.RecordError(err) span.SetStatus(codes.Error, err.Error()) + case toolErrMessage != "": + span.SetStatus(codes.Error, toolErrMessage) + default: + span.SetStatus(codes.Error, "tool returned IsError") } } else { span.SetStatus(codes.Ok, "ok") diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go index 8ea5ab1c..4474f0bb 100644 --- a/internal/mcp/mcp_test.go +++ b/internal/mcp/mcp_test.go @@ -10,6 +10,10 @@ import ( "github.com/kagent-dev/tools/internal/metrics" sdk "github.com/modelcontextprotocol/go-sdk/mcp" promtest "github.com/prometheus/client_golang/prometheus/testutil" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/codes" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" ) // invokeMiddleware runs ToolMiddleware around next for a tools/call to toolName @@ -199,3 +203,72 @@ func TestToolMiddleware_GoErrorIncrementsFailureCounter(t *testing.T) { t.Errorf("invocations_failure_total: expected 1, got %v", failures) } } + +// TestToolMiddleware_MarksSpanForToolLevelError is the regression test for the +// tracing gap: a handler signalling a tool-level failure (IsError=true, nil Go +// error) incremented the Prometheus failure counter but left the OTel span +// status unset, so traces disagreed with metrics and the span was neither Ok +// nor Error. +func TestToolMiddleware_MarksSpanForToolLevelError(t *testing.T) { + exp := tracetest.NewInMemoryExporter() + tp := sdktrace.NewTracerProvider(sdktrace.WithSyncer(exp)) + prev := otel.GetTracerProvider() + otel.SetTracerProvider(tp) + t.Cleanup(func() { + otel.SetTracerProvider(prev) + _ = tp.Shutdown(context.Background()) + }) + + result, err := invokeMiddleware("tool_level_failure", "test", + func(_ context.Context, _ string, _ sdk.Request) (sdk.Result, error) { + return NewToolResultError("resource not found"), nil + }, + ) + if err != nil { + t.Fatalf("expected nil Go error, got: %v", err) + } + if ctr, ok := result.(*sdk.CallToolResult); !ok || !ctr.IsError { + t.Fatal("expected result.IsError=true") + } + + spans := exp.GetSpans() + if len(spans) != 1 { + t.Fatalf("expected exactly 1 span, got %d", len(spans)) + } + span := spans[0] + if span.Status.Code != codes.Error { + t.Errorf("span status: expected Error, got %v (status description %q)", + span.Status.Code, span.Status.Description) + } + if span.Status.Description == "" { + t.Error("span status description should carry the tool error message") + } +} + +// TestToolMiddleware_MarksSpanOkOnSuccess guards the success path. +func TestToolMiddleware_MarksSpanOkOnSuccess(t *testing.T) { + exp := tracetest.NewInMemoryExporter() + tp := sdktrace.NewTracerProvider(sdktrace.WithSyncer(exp)) + prev := otel.GetTracerProvider() + otel.SetTracerProvider(tp) + t.Cleanup(func() { + otel.SetTracerProvider(prev) + _ = tp.Shutdown(context.Background()) + }) + + if _, err := invokeMiddleware("ok_tool", "test", + func(_ context.Context, _ string, _ sdk.Request) (sdk.Result, error) { + return NewToolResultText("fine"), nil + }, + ); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + spans := exp.GetSpans() + if len(spans) != 1 { + t.Fatalf("expected exactly 1 span, got %d", len(spans)) + } + if got := spans[0].Status.Code; got != codes.Ok { + t.Errorf("span status: expected Ok, got %v", got) + } +} diff --git a/pkg/kubescape/kubescape.go b/pkg/kubescape/kubescape.go index e3a026b9..d471dcc6 100644 --- a/pkg/kubescape/kubescape.go +++ b/pkg/kubescape/kubescape.go @@ -847,12 +847,11 @@ func (k *KubescapeTool) handleGetVulnerabilityDetails(ctx context.Context, reque return mcp.NewToolResultError(fmt.Sprintf("CVE %s not found in manifest %s", cveID, manifestName)), nil, nil } - content, err := json.MarshalIndent(matches, "", " ") - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil, nil - } - - return mcp.NewToolResultText(string(content)), matches, nil + // Return the matches as the typed Out and leave Content unset. For an array + // (non-object) output the SDK serialises the value into a TextContent block + // itself; setting Content here as well would emit the same list twice for + // clients that read Content. + return nil, matches, nil } // handleListConfigurationScans lists configuration security scan results diff --git a/pkg/kubescape/kubescape_test.go b/pkg/kubescape/kubescape_test.go index 50a6070f..dcb70350 100644 --- a/pkg/kubescape/kubescape_test.go +++ b/pkg/kubescape/kubescape_test.go @@ -559,19 +559,18 @@ func TestHandleGetVulnerabilityDetails_Success(t *testing.T) { tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) - result, _, err := tool.HandleGetVulnerabilityDetails(context.Background(), getVulnerabilityDetailsInput{ + // The handler returns the matches as its typed Out and leaves the tool result + // for the SDK to synthesise. For an array Out the SDK serialises the value + // into a TextContent block itself, so setting Content in the handler as well + // would emit the list twice. + result, matches, err := tool.HandleGetVulnerabilityDetails(context.Background(), getVulnerabilityDetailsInput{ ManifestName: "test-manifest", CveID: "CVE-2021-1234", }) require.NoError(t, err) - require.NotNil(t, result) - assert.False(t, result.IsError) - - var matches []v1beta1.Match - err = json.Unmarshal([]byte(getResultText(result)), &matches) - require.NoError(t, err) + assert.Nil(t, result, "handler should leave result to the SDK for an array Out") - assert.Len(t, matches, 1) + require.Len(t, matches, 1) assert.Equal(t, "CVE-2021-1234", matches[0].Vulnerability.ID) } diff --git a/pkg/utils/common.go b/pkg/utils/common.go index fa3f4e70..cf4c089a 100644 --- a/pkg/utils/common.go +++ b/pkg/utils/common.go @@ -109,6 +109,52 @@ func handleMCPInspectTool(_ context.Context, request *mcp.CallToolRequest, in in return mcp.NewToolResultText(string(payload)), output, nil } +// sensitiveHeaderNames are header names whose values are never returned by +// mcp_inspect. The tool's purpose is to debug which headers reach the server, +// not to disclose their contents: the HTTP transport passes the raw inbound +// header set (including Authorization/Cookie) to handlers, so echoing values +// back would hand any tool caller the caller's own bearer token and session +// secrets. +// +// An exact-match list cannot keep up with the header names clients invent +// (GitLab's Private-Token, AWS's X-Amz-Security-Token, assorted X-*-Token / +// X-*-Secret variants), so the policy is deny-by-substring: a header is +// redacted when its canonical name contains any of these fragments. Preferring +// over-redaction here is deliberate - a redacted debugging value costs nothing, +// a leaked credential does not. +var sensitiveHeaderFragments = []string{ + "authorization", + "authenticat", // Authenticate, Authentication + "cookie", + "credential", + "password", + "passwd", + "secret", + "session", + "token", + "api-key", + "apikey", + "auth", + "bearer", + "jwt", + "signature", +} + +// isSensitiveHeader reports whether a header's value must be withheld. +func isSensitiveHeader(canonicalName string) bool { + lower := strings.ToLower(canonicalName) + for _, fragment := range sensitiveHeaderFragments { + if strings.Contains(lower, fragment) { + return true + } + } + return false +} + +// redactedPlaceholder replaces a withheld header value while still revealing +// that the header was present. +const redactedPlaceholder = "[REDACTED]" + func inspectHeaders(headers http.Header) []inspectHeader { if len(headers) == 0 { return []inspectHeader{} @@ -129,6 +175,13 @@ func inspectHeaders(headers http.Header) []inspectHeader { result := make([]inspectHeader, 0, len(names)) for _, name := range names { values := append([]string(nil), canonicalHeaders[name]...) + if isSensitiveHeader(http.CanonicalHeaderKey(name)) { + // Keep one entry per received value so the count is still visible. + values = make([]string, len(canonicalHeaders[name])) + for i := range values { + values[i] = redactedPlaceholder + } + } result = append(result, inspectHeader{Name: name, Values: values}) } return result diff --git a/pkg/utils/common_test.go b/pkg/utils/common_test.go index 12e8623b..99f7ef96 100644 --- a/pkg/utils/common_test.go +++ b/pkg/utils/common_test.go @@ -125,10 +125,14 @@ func TestHandleMCPInspectTool(t *testing.T) { require.NotNil(t, result) assert.False(t, result.IsError) + // Credential-bearing headers are redacted: the transport hands handlers + // the raw inbound header set, so echoing values would disclose the + // caller's own bearer token. The header name is still reported so the + // tool remains useful for debugging which headers arrived. expected := &inspectOutput{ Echo: "hello", Headers: []inspectHeader{ - {Name: "Authorization", Values: []string{"Bearer test-token"}}, + {Name: "Authorization", Values: []string{redactedPlaceholder}}, {Name: "X-Debug", Values: []string{"one", "two"}}, }, } @@ -137,6 +141,43 @@ func TestHandleMCPInspectTool(t *testing.T) { var rendered inspectOutput require.NoError(t, json.Unmarshal([]byte(getResultText(result)), &rendered)) assert.Equal(t, *expected, rendered) + + // The secret must not appear anywhere in the rendered payload. + assert.NotContains(t, getResultText(result), "test-token") + }) + + t.Run("redacts every sensitive header but keeps the value count", func(t *testing.T) { + req := &mcp.CallToolRequest{ + Extra: &mcp.RequestExtra{ + Header: http.Header{ + "Authorization": []string{"Bearer a", "Bearer b"}, + "Cookie": []string{"session=secret"}, + "X-Api-Key": []string{"key-123"}, + "User-Agent": []string{"probe/1.0"}, + }, + }, + } + + result, output, err := handleMCPInspectTool(ctx, req, inspectInput{}) + require.NoError(t, err) + require.False(t, result.IsError) + + byName := map[string][]string{} + for _, h := range output.Headers { + byName[h.Name] = h.Values + } + + // The count of received values is preserved, only the contents are hidden. + assert.Equal(t, []string{redactedPlaceholder, redactedPlaceholder}, byName["Authorization"]) + assert.Equal(t, []string{redactedPlaceholder}, byName["Cookie"]) + assert.Equal(t, []string{redactedPlaceholder}, byName["X-Api-Key"]) + // Non-sensitive headers keep their values. + assert.Equal(t, []string{"probe/1.0"}, byName["User-Agent"]) + + rendered := getResultText(result) + for _, secret := range []string{"Bearer a", "Bearer b", "session=secret", "key-123"} { + assert.NotContains(t, rendered, secret) + } }) t.Run("works without headers", func(t *testing.T) { @@ -157,3 +198,32 @@ func getResultText(result *mcp.CallToolResult) string { } return "" } + +// TestIsSensitiveHeader pins the redaction policy for mcp_inspect. An +// exact-match list proved insufficient (it missed GitLab's Private-Token, AWS's +// X-Amz-Security-Token and assorted X-*-Token variants), so the policy is +// deny-by-substring. This test guards both directions: known credential header +// names must be withheld, and innocuous headers must stay visible so the tool +// remains useful for debugging. +func TestIsSensitiveHeader(t *testing.T) { + mustRedact := []string{ + "Authorization", "Proxy-Authorization", "Cookie", "Set-Cookie", + "X-Api-Key", "X-Auth-Token", "X-Access-Token", "X-API-Token", + "X-Amz-Security-Token", "Private-Token", "X-Gitlab-Token", + "Authentication", "X-Credential", "X-Secret", "X-Password", + "X-Session-Id", "X-JWT-Token", "X-Signature", + } + for _, name := range mustRedact { + assert.True(t, isSensitiveHeader(http.CanonicalHeaderKey(name)), + "%s carries a credential and must be redacted", name) + } + + keepVisible := []string{ + "Accept", "Content-Type", "Content-Length", "User-Agent", + "Accept-Encoding", "Traceparent", "X-Request-Id", "Cache-Control", + } + for _, name := range keepVisible { + assert.False(t, isSensitiveHeader(http.CanonicalHeaderKey(name)), + "%s is not a credential and should stay visible for debugging", name) + } +} From 6dc138b2cf1e47a70145aefd57ac7ef80d23548c Mon Sep 17 00:00:00 2001 From: Dmytro Rashko Date: Tue, 22 Sep 2026 22:29:39 +0200 Subject: [PATCH 16/21] chore: drop migration spec set and the tool-name golden test Removes material that should not ship in the pull request. specs/ holds the working documents from the SDK migration (requirements, design, plan, research comparison, skill notes). Nothing in the repository depends on them - no CI job, Makefile target, or doc link references the path - so they were pure review noise in the diff. cmd/testdata/tool_names_v0.2.1.txt and cmd/tools_regression_test.go are removed as a pair. They arrived in the same commit (ef66d5a) and are hard-coupled: the test opens the golden file with require.NoError, so deleting the data alone fails the build with "open testdata/tool_names_v0.2.1.txt: no such file or directory". Removing the file alone was therefore not an option. The dropped test asserted that tool names shipped in v0.2.1 are still registered, which is a real check, but the golden baseline is 132 lines of v0.2.1-era data that the reviewer flagged as not belonging in this PR. Tool registration is still covered by TestEveryToolHasValidOutputSchema (every advertised tool must infer a valid output schema) and by the e2e sweep, which asserts the server exposes all 80 read-only tools. The e2e comment that referenced the removed test name is updated accordingly. Verified: go build ./..., go vet ./... and gofmt clean; go test ./pkg/... ./internal/... ./cmd/... passes 19/19 packages; golangci-lint reports 0 issues; the e2e suite still compiles under -tags=test. 3727 lines removed. Signed-off-by: Dmytro Rashko --- cmd/testdata/tool_names_v0.2.1.txt | 132 ---- cmd/tools_regression_test.go | 81 --- .../migrate-mcp-go-to-official-sdk/design.md | 472 -------------- specs/migrate-mcp-go-to-official-sdk/plan.md | 535 ---------------- .../requirements.md | 25 - .../research/sdk-comparison.md | 222 ------- .../rough-idea.md | 20 - specs/migrate-mcp-go-to-official-sdk/skill.md | 442 ------------- .../PROMPT.md | 0 .../design.md | 472 -------------- .../plan.md | 597 ------------------ .../requirements.md | 25 - .../research/sdk-comparison.md | 222 ------- .../rough-idea.md | 20 - .../skill.md | 442 ------------- .../summary.md | 20 - test/e2e/coverage_test.go | 3 +- 17 files changed, 1 insertion(+), 3729 deletions(-) delete mode 100644 cmd/testdata/tool_names_v0.2.1.txt delete mode 100644 cmd/tools_regression_test.go delete mode 100644 specs/migrate-mcp-go-to-official-sdk/design.md delete mode 100644 specs/migrate-mcp-go-to-official-sdk/plan.md delete mode 100644 specs/migrate-mcp-go-to-official-sdk/requirements.md delete mode 100644 specs/migrate-mcp-go-to-official-sdk/research/sdk-comparison.md delete mode 100644 specs/migrate-mcp-go-to-official-sdk/rough-idea.md delete mode 100644 specs/migrate-mcp-go-to-official-sdk/skill.md delete mode 100644 specs/tools/001-migrate-mcp-go-to-official-sdk/PROMPT.md delete mode 100644 specs/tools/001-migrate-mcp-go-to-official-sdk/design.md delete mode 100644 specs/tools/001-migrate-mcp-go-to-official-sdk/plan.md delete mode 100644 specs/tools/001-migrate-mcp-go-to-official-sdk/requirements.md delete mode 100644 specs/tools/001-migrate-mcp-go-to-official-sdk/research/sdk-comparison.md delete mode 100644 specs/tools/001-migrate-mcp-go-to-official-sdk/rough-idea.md delete mode 100644 specs/tools/001-migrate-mcp-go-to-official-sdk/skill.md delete mode 100644 specs/tools/001-migrate-mcp-go-to-official-sdk/summary.md diff --git a/cmd/testdata/tool_names_v0.2.1.txt b/cmd/testdata/tool_names_v0.2.1.txt deleted file mode 100644 index 54a5c769..00000000 --- a/cmd/testdata/tool_names_v0.2.1.txt +++ /dev/null @@ -1,132 +0,0 @@ -# Tool names registered by the v0.2.1 release (pre go-sdk migration). -# Source of truth: `git grep 'mcp.NewTool("...")' v0.2.1 -- pkg/`. -# TestNoToolNameRegressions asserts every name below still exists in the -# current build so the SDK migration never silently renames/drops a tool. -# Add new tools freely; never remove a line without a deliberate, documented -# breaking change. -# Note: kubescape_get_sbom / kubescape_list_sboms were commented out (not -# registered) in v0.2.1, so they are intentionally absent here. -argo_check_plugin_logs -argo_pause_rollout -argo_promote_rollout -argo_rollouts_list -argo_set_rollout_image -argo_verify_argo_rollouts_controller_install -argo_verify_gateway_plugin -argo_verify_kubectl_plugin_install -cilium_connect_to_remote_cluster -cilium_delete_key_from_kv_store -cilium_delete_pcap_recorder -cilium_delete_policy_rules -cilium_delete_service -cilium_delete_xdp_cidr_filters -cilium_disconnect_endpoint -cilium_disconnect_remote_cluster -cilium_display_encryption_state -cilium_display_policy_node_information -cilium_display_selectors -cilium_flush_ipsec_state -cilium_fqdn_cache -cilium_get_bpf_map -cilium_get_daemon_status -cilium_get_endpoint_details -cilium_get_endpoint_health -cilium_get_endpoint_logs -cilium_get_endpoints_list -cilium_get_identity_details -cilium_get_kv_store_key -cilium_get_pcap_recorder -cilium_get_service_information -cilium_install_cilium -cilium_list_bgp_peers -cilium_list_bgp_routes -cilium_list_bpf_map_events -cilium_list_bpf_maps -cilium_list_cluster_nodes -cilium_list_envoy_config -cilium_list_identities -cilium_list_ip_addresses -cilium_list_local_redirect_policies -cilium_list_metrics -cilium_list_node_ids -cilium_list_pcap_recorders -cilium_list_services -cilium_list_xdp_cidr_filters -cilium_manage_endpoint_config -cilium_manage_endpoint_labels -cilium_request_debugging_information -cilium_set_kv_store_key -cilium_show_cluster_mesh_status -cilium_show_configuration_options -cilium_show_dns_names -cilium_show_features_status -cilium_show_ip_cache_information -cilium_show_load_information -cilium_status_and_version -cilium_toggle_cluster_mesh -cilium_toggle_configuration_option -cilium_toggle_hubble -cilium_uninstall_cilium -cilium_update_pcap_recorder -cilium_update_service -cilium_update_xdp_cidr_filters -cilium_upgrade_cilium -cilium_validate_cilium_network_policies -datetime_get_current_time -helm_get_release -helm_list_releases -helm_repo_add -helm_repo_update -helm_uninstall -helm_upgrade -istio_analyze_cluster_configuration -istio_apply_waypoint -istio_delete_waypoint -istio_generate_manifest -istio_generate_waypoint -istio_install_istio -istio_list_waypoints -istio_proxy_config -istio_proxy_status -istio_remote_clusters -istio_version -istio_waypoint_status -istio_ztunnel_config -k8s_annotate_resource -k8s_apply_manifest -k8s_check_service_connectivity -k8s_create_resource -k8s_create_resource_from_url -k8s_delete_resource -k8s_describe_resource -k8s_execute_command -k8s_generate_resource -k8s_get_available_api_resources -k8s_get_cluster_configuration -k8s_get_events -k8s_get_pod_logs -k8s_get_resource_yaml -k8s_get_resources -k8s_label_resource -k8s_patch_resource -k8s_patch_status -k8s_remove_annotation -k8s_remove_label -k8s_rollout -k8s_scale -kubescape_check_health -kubescape_get_application_profile -kubescape_get_configuration_scan -kubescape_get_network_neighborhood -kubescape_get_vulnerability_details -kubescape_list_application_profiles -kubescape_list_configuration_scans -kubescape_list_network_neighborhoods -kubescape_list_vulnerabilities -kubescape_list_vulnerability_manifests -prometheus_label_names_tool -prometheus_promql_tool -prometheus_query_range_tool -prometheus_query_tool -prometheus_targets_tool -shell diff --git a/cmd/tools_regression_test.go b/cmd/tools_regression_test.go deleted file mode 100644 index 83ae9b33..00000000 --- a/cmd/tools_regression_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package main - -import ( - "bufio" - "context" - "os" - "sort" - "strings" - "testing" - - sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// registeredToolNames spins up an in-process MCP server with every provider -// registered (readOnly=false so mutating tools are included too), connects an -// in-memory client, and returns the set of advertised tool names — the same -// list a real MCP client would see over the wire. -func registeredToolNames(t *testing.T) map[string]bool { - t.Helper() - ctx := context.Background() - - srv := sdkmcp.NewServer(&sdkmcp.Implementation{Name: "regression", Version: "test"}, nil) - registerMCP(srv, nil, "", false) // nil providers => register them all - - serverT, clientT := sdkmcp.NewInMemoryTransports() - go func() { _ = srv.Run(ctx, serverT) }() - - client := sdkmcp.NewClient(&sdkmcp.Implementation{Name: "regression-client", Version: "test"}, nil) - session, err := client.Connect(ctx, clientT, nil) - require.NoError(t, err) - defer func() { _ = session.Close() }() - - names := make(map[string]bool) - for tool, err := range session.Tools(ctx, nil) { - require.NoError(t, err) - names[tool.Name] = true - } - require.NotEmpty(t, names, "expected the server to advertise tools") - return names -} - -// readGoldenToolNames loads the committed list of tool names, ignoring blank -// lines and '#' comments. -func readGoldenToolNames(t *testing.T, path string) []string { - t.Helper() - f, err := os.Open(path) - require.NoError(t, err) - defer func() { _ = f.Close() }() - - var names []string - sc := bufio.NewScanner(f) - for sc.Scan() { - line := strings.TrimSpace(sc.Text()) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - names = append(names, line) - } - require.NoError(t, sc.Err()) - return names -} - -// TestNoToolNameRegressions guards the go-sdk migration: every tool name shipped -// in the v0.2.1 release must still be registered under the same name. New tools -// are allowed; renames or removals are caught here. -func TestNoToolNameRegressions(t *testing.T) { - current := registeredToolNames(t) - old := readGoldenToolNames(t, "testdata/tool_names_v0.2.1.txt") - - var missing []string - for _, name := range old { - if !current[name] { - missing = append(missing, name) - } - } - sort.Strings(missing) - - assert.Emptyf(t, missing, "%d tool(s) from v0.2.1 are missing/renamed in the current build: %v", len(missing), missing) -} diff --git a/specs/migrate-mcp-go-to-official-sdk/design.md b/specs/migrate-mcp-go-to-official-sdk/design.md deleted file mode 100644 index 0addfe8d..00000000 --- a/specs/migrate-mcp-go-to-official-sdk/design.md +++ /dev/null @@ -1,472 +0,0 @@ -# Design: Migrate mark3labs/mcp-go → modelcontextprotocol/go-sdk - -## Overview - -This document describes the design for replacing the community MCP Go SDK -(`github.com/mark3labs/mcp-go v0.43.2`) with the official MCP Go SDK -(`github.com/modelcontextprotocol/go-sdk`) across the `kagent-tools` server. - -The migration is a **drop-in SDK swap with a type-safety uplift**: all externally -visible behaviour (tool names, parameter names, transport protocols) is preserved, -while the internal implementation switches from dynamic map-based parameter parsing -to concrete Go struct types. - -No new tools are added. No tools are removed. No CLI flags change. - ---- - -## Detailed Requirements - -### R1 — Concrete Go struct types (no `map[any]any`) - -Every tool handler MUST receive its parameters as a named, exported Go struct. -Dynamic maps (`map[string]any`, `map[string]interface{}`, `map[any]any`) are -forbidden as tool parameter containers. Existing uses of `map[string]interface{}` -in `ToolError.Context` must also be replaced with a concrete type. - -### R2 — Full feature parity - -All 40+ tools across eight packages (k8s, helm, istio, argo, cilium, prometheus, -kubescape, utils) must be registered and functional after migration. Tool names, -parameter names, and descriptions must match the current implementation exactly. - -### R3 — Both transports preserved - -The server must continue to support: -- **stdio** (`--stdio` flag): communicates over stdin/stdout -- **HTTP Streamable** (default): listens on `--port` (default 8084) - -### R4 — Telemetry / OpenTelemetry tracing preserved - -The OTel tracing middleware that records tool name, arguments, duration, and -error state on every `tools/call` invocation must be rewritten using the -official SDK's `AddReceivingMiddleware` API. No tracing spans may be lost. - -### R5 — 80 % overall / 70 % per-package / 90 % critical-package coverage - -Test coverage thresholds defined in CLAUDE.md are unchanged. All updated -packages must pass `make test` after migration. - -### R6 — No breaking changes to the public `RegisterTools` interface - -Each `pkg/*/` package exposes `RegisterTools(s *mcp.Server, ...)`. The function -signature changes only the type of the first argument (from `*server.MCPServer` -to `*mcp.Server`). Callers in `cmd/main.go` are updated accordingly. - ---- - -## Architecture Overview - -```mermaid -graph TD - subgraph cmd - main["cmd/main.go
cobra CLI"] - end - - subgraph internal - tel["internal/telemetry
OTel middleware"] - errs["internal/errors
ToolError → MCP result"] - mcputil["internal/mcputil ← NEW
TextResult / ErrorResult helpers"] - end - - subgraph sdk ["github.com/modelcontextprotocol/go-sdk/mcp"] - Server["mcp.Server"] - AddTool["mcp.AddTool[In,Out]"] - Transports["StdioTransport
StreamableHTTPHandler"] - Middleware["AddReceivingMiddleware"] - end - - subgraph tools ["pkg/*"] - k8s; helm; istio; argo; cilium; prometheus; kubescape; utils - end - - main -->|"NewServer + transports"| sdk - main -->|"registerMCP"| tools - main -->|"AddReceivingMiddleware"| tel - tools -->|"mcp.AddTool + *Params structs"| AddTool - tools -->|"mcputil.TextResult / ErrorResult"| mcputil - errs -->|"&mcp.CallToolResult{IsError:true}"| sdk - mcputil -->|"&mcp.CallToolResult{Content:[...]}"| sdk - tel -->|"mcp.Middleware"| Middleware -``` - -### Key Architectural Decisions - -| Decision | Rationale | -|----------|-----------| -| Use generic `mcp.AddTool[In, Out]` (not low-level `server.AddTool`) | Auto-derives JSON schema from struct tags; eliminates manual `mcp.WithString/Bool/Number` option calls | -| Introduce `internal/mcputil` package | Single source for `TextResult`/`ErrorResult` helpers; avoids duplicating `&mcp.CallToolResult{...}` literals across 40+ handlers | -| Replace per-handler `WithTracing` wrapper with server-level middleware | Cleaner separation; one middleware intercepts all tool calls; no adapter boilerplate per handler | -| Replace `ToolError.Context map[string]interface{}` with `map[string]string` | Satisfies R1; `interface{}` was only ever used with string values | - ---- - -## Components and Interfaces - -### `internal/mcputil` (new package) - -```go -package mcputil - -import "github.com/modelcontextprotocol/go-sdk/mcp" - -// TextResult wraps a plain text string in a successful CallToolResult. -func TextResult(text string) *mcp.CallToolResult - -// ErrorResult wraps an error message in a tool-error CallToolResult (IsError=true). -func ErrorResult(msg string) *mcp.CallToolResult -``` - -### `internal/telemetry/middleware.go` (rewritten) - -```go -// Before -type ToolHandler func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) -func WithTracing(toolName string, handler ToolHandler) ToolHandler -func AdaptToolHandler(th ToolHandler) server.ToolHandlerFunc - -// After -// NewTracingMiddleware returns a mcp.Middleware that records OTel spans for -// every tools/call invocation. The tool name is read from req.(*mcp.CallToolRequest).Params.Name. -func NewTracingMiddleware() mcp.Middleware -``` - -All other telemetry helpers (`HTTPMiddleware`, `ExtractHTTPHeaders`, `StartSpan`, -`RecordError`, `RecordSuccess`, `AddEvent`) are unchanged. - -### `internal/errors/tool_errors.go` - -```go -// Context field type change -type ToolError struct { - // ... - Context map[string]string `json:"context,omitempty"` // was map[string]interface{} -} - -// ToMCPResult — result type changes; import changes from mark3labs to go-sdk -func (e *ToolError) ToMCPResult() *mcp.CallToolResult { - return mcputil.ErrorResult(message.String()) -} - -// WithContext parameter type change -func (e *ToolError) WithContext(key string, value string) *ToolError -``` - -### `pkg/*/` — Tool handler pattern - -Every handler is converted to the typed `ToolHandlerFor` pattern: - -```go -// Params struct — one per tool -type Params struct { - Field string `json:"field_name" jsonschema:"description[,required][,default=val]"` - // ... -} - -// Handler -func handle( - ctx context.Context, - req *mcp.CallToolRequest, - args Params, -) (*mcp.CallToolResult, any, error) { - // args.Field is already populated - return mcputil.TextResult(result), nil, nil -} - -// Registration -func RegisterTools(s *mcp.Server, readOnly bool) { - mcp.AddTool(s, &mcp.Tool{ - Name: "tool_name", - Description: "...", - }, handle) -} -``` - -### `cmd/main.go` - -```go -// Server creation -mcpServer := mcp.NewServer(&mcp.Implementation{Name: Name, Version: Version}, nil) - -// Middleware -mcpServer.AddReceivingMiddleware(telemetry.NewTracingMiddleware()) - -// Tool registration map -toolProviderMap := map[string]func(*mcp.Server){ - "k8s": func(s *mcp.Server) { k8s.RegisterTools(s, nil, kubeconfig, readOnly) }, - // ... -} - -// Stdio transport -func runStdioServer(ctx context.Context, s *mcp.Server) { - if err := s.Run(ctx, &mcp.StdioTransport{}); err != nil { ... } -} - -// HTTP transport -handler := mcp.NewStreamableHTTPHandler( - func(r *http.Request) *mcp.Server { return mcpServer }, - nil, -) -mux.Handle("/", telemetry.HTTPMiddleware(handler)) -``` - ---- - -## Data Models - -### Params Structs per Package - -All structs use `json` tags for field names and `jsonschema` tags for descriptions -and constraints. Required fields have `,required` appended to the jsonschema tag. - -#### `pkg/k8s` — KubectlGetParams (representative) - -```go -type KubectlGetParams struct { - ResourceType string `json:"resource_type" jsonschema:"K8s resource type (pod/deploy/svc),required"` - ResourceName string `json:"resource_name" jsonschema:"name of the specific resource"` - Namespace string `json:"namespace" jsonschema:"namespace to query"` - AllNamespaces bool `json:"all_namespaces" jsonschema:"query across all namespaces"` - Output string `json:"output" jsonschema:"output format (wide/json/yaml),default=wide"` -} -type KubectlLogsParams struct { - PodName string `json:"pod_name" jsonschema:"pod name,required"` - Namespace string `json:"namespace" jsonschema:"namespace,default=default"` - Container string `json:"container" jsonschema:"container name"` - TailLines int `json:"tail_lines" jsonschema:"number of log lines,default=50"` -} -type ScaleDeploymentParams struct { - Name string `json:"name" jsonschema:"deployment name,required"` - Namespace string `json:"namespace" jsonschema:"namespace,default=default"` - Replicas int `json:"replicas" jsonschema:"desired replica count,default=1"` -} -// ... one struct per handler, following the same pattern -``` - -#### `pkg/helm` (representative) - -```go -type HelmListParams struct { - Namespace string `json:"namespace" jsonschema:"filter by namespace"` - AllNamespaces bool `json:"all_namespaces" jsonschema:"list across all namespaces"` - All bool `json:"all" jsonschema:"show all releases including non-deployed"` - Uninstalled bool `json:"uninstalled" jsonschema:"show uninstalled releases"` - Failed bool `json:"failed" jsonschema:"show failed releases"` - Deployed bool `json:"deployed" jsonschema:"show deployed releases"` - Pending bool `json:"pending" jsonschema:"show pending releases"` - Filter string `json:"filter" jsonschema:"regex filter for release names"` - Output string `json:"output" jsonschema:"output format (table/json/yaml)"` -} -type HelmGetReleaseParams struct { - Name string `json:"name" jsonschema:"release name,required"` - Namespace string `json:"namespace" jsonschema:"namespace,required"` - Output string `json:"output" jsonschema:"output format (all/hooks/manifest/notes/values)"` -} -// ... one struct per handler -``` - -#### `pkg/argo` (representative) - -```go -type VerifyArgoControllerParams struct { - Namespace string `json:"namespace" jsonschema:"namespace to check,default=argo-rollouts"` - Label string `json:"label" jsonschema:"pod label selector,default=app.kubernetes.io/component=rollouts-controller"` -} -type PromoteRolloutParams struct { - RolloutName string `json:"rollout_name" jsonschema:"name of the rollout,required"` - Namespace string `json:"namespace" jsonschema:"namespace"` - Full bool `json:"full" jsonschema:"fully promote skipping all pauses"` -} -// ... -``` - -#### `pkg/cilium` (representative) - -```go -type UpgradeCiliumParams struct { - ClusterName string `json:"cluster_name" jsonschema:"cluster name"` - DatapathMode string `json:"datapath_mode" jsonschema:"datapath mode (tunnel/native-routing)"` -} -type InstallCiliumParams struct { - ClusterName string `json:"cluster_name" jsonschema:"cluster name"` - ClusterID string `json:"cluster_id" jsonschema:"unique cluster ID for cluster mesh"` - DatapathMode string `json:"datapath_mode" jsonschema:"datapath mode"` -} -type ConnectRemoteClusterParams struct { - ClusterName string `json:"cluster_name" jsonschema:"remote cluster name,required"` - Context string `json:"context" jsonschema:"kubeconfig context for remote cluster"` -} -type ToggleHubbleParams struct { - Enable bool `json:"enable" jsonschema:"true to enable Hubble,default=true"` -} -// ... -``` - ---- - -## Error Handling - -### Tool-level errors (visible to the LLM) - -Returned as `*mcp.CallToolResult` with `IsError: true`. The LLM sees the error -text as tool output and can reason about it. - -```go -// All paths that previously called mcp.NewToolResultError(msg): -return mcputil.ErrorResult(msg), nil, nil - -// ToolError.ToMCPResult(): -return mcputil.ErrorResult(message.String()) -``` - -### Protocol-level errors (terminates the JSON-RPC call) - -Returned as the `error` return value. Reserved for unexpected internal failures -that the LLM cannot meaningfully recover from. - -```go -return nil, nil, fmt.Errorf("internal error: %w", err) -``` - -### Validation - -Required fields in param structs are validated automatically by the SDK before -the handler is called. Manual `if param == "" { return error }` guards in -handlers are removed where the field is declared `required` in the jsonschema tag. -Optional guards for business logic remain. - ---- - -## Acceptance Criteria - -### AC-1: Dependency update - -**Given** `go.mod` is updated to remove `github.com/mark3labs/mcp-go` -**When** `go mod tidy` is run -**Then** no references to `mark3labs/mcp-go` remain in `go.mod` or `go.sum` - -### AC-2: No dynamic maps in tool params - -**Given** the migrated codebase -**When** `grep -r "map\[string\]any\|map\[string\]interface{}" pkg/ internal/` is run -**Then** zero matches are found inside tool handler functions or param types - -### AC-3: All tools register and are discoverable - -**Given** the server is started in stdio mode -**When** a `tools/list` request is sent -**Then** all tool names present before migration are returned in the response - -### AC-4: Stdio transport works - -**Given** the server binary is run with `--stdio` -**When** a `tools/call` JSON-RPC request is piped to stdin -**Then** a valid JSON-RPC response with tool result is written to stdout - -### AC-5: HTTP/Streamable transport works - -**Given** the server is started without `--stdio` on port 8084 -**When** an HTTP MCP client connects and calls a tool -**Then** the response is returned with correct content - -### AC-6: Telemetry traces recorded - -**Given** an OTel exporter is configured -**When** a tool call is made -**Then** a span named `mcp.tool.` is recorded with `mcp.tool.name` and duration attributes - -### AC-7: Test coverage thresholds pass - -**Given** `make test` is run -**Then** overall coverage ≥ 80%, per-package ≥ 70%, critical packages ≥ 90% - -### AC-8: Linter passes - -**Given** `make lint` is run -**Then** zero linting errors are reported - ---- - -## Testing Strategy - -### Unit tests (primary) - -Each `pkg/*/` package uses the table-driven pattern from CLAUDE.md. After migration, -tests call the typed handler directly: - -```go -func TestHandleKubectlGet(t *testing.T) { - cases := []struct { - name string - args KubectlGetParams - wantErr bool - }{ - {name: "missing resource_type", args: KubectlGetParams{}, wantErr: true}, - {name: "valid get pods", args: KubectlGetParams{ResourceType: "pod", Namespace: "default"}, wantErr: false}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - result, _, err := handleKubectlGet(context.Background(), &mcp.CallToolRequest{}, tc.args) - // assert - }) - } -} -``` - -No need to construct `mcp.CallToolRequest.Params.Arguments` maps in unit tests — -args are passed directly to the handler function. - -### Middleware tests - -`internal/telemetry/middleware_test.go` uses an in-memory transport pair -(`mcp.NewInMemoryTransports()`) to exercise the full request-response cycle -including middleware. - -### Integration / E2E tests - -`test/e2e/helpers_test.go` starts the full server binary and exercises both -transports. These tests are unchanged in scope; only the client-side MCP -type imports are updated. - ---- - -## Appendices - -### A. Technology Choices - -| Component | Choice | Reason | -|-----------|--------|--------| -| MCP SDK | `github.com/modelcontextprotocol/go-sdk` | Official Anthropic/MCP Foundation SDK; long-term support; supports MCP spec 2025-06-18 | -| HTTP transport | `mcp.NewStreamableHTTPHandler` | Implements MCP spec 2025-03-26 streamable HTTP; supersedes legacy SSE | -| Schema generation | `mcp.AddTool[In, Out]` generics | Auto-derives JSON schema from struct tags; eliminates boilerplate | -| Result helpers | `internal/mcputil.TextResult/ErrorResult` | Single source of truth; go-sdk has no built-in equivalents | - -### B. API Mapping Summary - -| mark3labs | go-sdk | -|-----------|--------| -| `server.NewMCPServer(n,v)` | `mcp.NewServer(&mcp.Implementation{Name:n,Version:v}, nil)` | -| `server.NewStdioServer(s).Listen(ctx,in,out)` | `s.Run(ctx, &mcp.StdioTransport{})` | -| `server.NewStreamableHTTPServer(s,opts)` | `mcp.NewStreamableHTTPHandler(func(r)*mcp.Server{return s}, nil)` | -| `mcp.ParseString(req,k,d)` | Struct field with `json` tag | -| `mcp.ParseInt(req,k,d)` | Struct field with `json` tag | -| `mcp.NewTool(name, opts...)` | `&mcp.Tool{Name:"...",Description:"..."}` | -| `s.AddTool(tool, handler)` | `mcp.AddTool(s, tool, typedHandler)` | -| `mcp.NewToolResultText(t)` | `mcputil.TextResult(t)` | -| `mcp.NewToolResultError(t)` | `mcputil.ErrorResult(t)` | -| handler `(req, err)` 2-return | handler `(req, any, err)` 3-return | -| `server.ToolHandlerFunc` | `mcp.ToolHandlerFor[In, Out]` | -| `server.AdaptToolHandler` | `mcp.Middleware` via `AddReceivingMiddleware` | - -### C. Alternative Approaches Considered - -**Keep low-level `server.AddTool` with manual schemas** — rejected. This would -require replicating the existing `mcp.WithString/Bool/Number` boilerplate in a -new form and would not achieve R1 (typed structs). - -**Use `map[string]any` args in handlers** — rejected. Explicitly forbidden by R1 -and is a regression in type safety compared to even the mark3labs API. - -**Introduce a compatibility shim layer** — rejected. A thin adapter keeping the -old signatures would prevent tests from using the cleaner direct-invocation -pattern and would accumulate technical debt. diff --git a/specs/migrate-mcp-go-to-official-sdk/plan.md b/specs/migrate-mcp-go-to-official-sdk/plan.md deleted file mode 100644 index 6f44577c..00000000 --- a/specs/migrate-mcp-go-to-official-sdk/plan.md +++ /dev/null @@ -1,535 +0,0 @@ -# Implementation Plan: mark3labs/mcp-go → modelcontextprotocol/go-sdk - -## Checklist - -- [ ] Step 1: Swap dependency and establish build baseline -- [ ] Step 2: Create `internal/mcputil` helpers -- [ ] Step 3: Migrate `internal/errors` — fix `ToolError` -- [ ] Step 4: Migrate `internal/telemetry` — rewrite to `mcp.Middleware` -- [ ] Step 5: Migrate `pkg/utils` -- [ ] Step 6: Migrate `pkg/prometheus` -- [ ] Step 7: Migrate `pkg/argo` -- [ ] Step 8: Migrate `pkg/cilium` -- [ ] Step 9: Migrate `pkg/helm` -- [ ] Step 10: Migrate `pkg/istio` -- [ ] Step 11: Migrate `pkg/k8s` -- [ ] Step 12: Migrate `pkg/kubescape` -- [ ] Step 13: Migrate `cmd/main.go` — wire everything together -- [ ] Step 14: Update E2E test helpers -- [ ] Step 15: Final validation - ---- - -## Step 1: Swap dependency and establish build baseline - -**Objective:** Replace the mark3labs dependency with the official SDK so every -subsequent step compiles against the new API from the start. - -**Implementation guidance:** -1. In `go.mod`, remove the `github.com/mark3labs/mcp-go` line. -2. Run `go get github.com/modelcontextprotocol/go-sdk@latest`. -3. Run `go mod tidy`. -4. The project will NOT compile at this point — that is expected. Every file - that imports `mark3labs` will report errors. -5. Do NOT fix any files yet — just verify that `go mod` resolves the new SDK. - -**Test requirements:** -- `go mod verify` passes (module graph is consistent). -- `go list -m github.com/modelcontextprotocol/go-sdk` prints the resolved version. - -**Integration notes:** -- No code changes outside `go.mod`/`go.sum` in this step. -- Commit the `go.mod`/`go.sum` change independently for easy bisect. - -**Demo:** `go list -m github.com/modelcontextprotocol/go-sdk` outputs the new version. - ---- - -## Step 2: Create `internal/mcputil` helpers - -**Objective:** Provide `TextResult` and `ErrorResult` helper functions that all -tool packages will use. Having these in place before migrating any package avoids -writing raw `&mcp.CallToolResult{Content: ...}` literals 40+ times. - -**Implementation guidance:** -1. Create `internal/mcputil/mcputil.go`: -```go -package mcputil - -import "github.com/modelcontextprotocol/go-sdk/mcp" - -func TextResult(text string) *mcp.CallToolResult { - return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{Text: text}}, - } -} - -func ErrorResult(msg string) *mcp.CallToolResult { - return &mcp.CallToolResult{ - IsError: true, - Content: []mcp.Content{&mcp.TextContent{Text: msg}}, - } -} -``` -2. Create `internal/mcputil/mcputil_test.go` with table-driven tests covering - both helpers (verify `IsError`, `Content[0].(*mcp.TextContent).Text`). - -**Test requirements:** -- `go test ./internal/mcputil/...` passes with 100% coverage. - -**Integration notes:** -- This package has no dependency on any `pkg/*` or other `internal` packages — - it can be compiled independently even while the rest of the codebase has errors. - -**Demo:** `go test ./internal/mcputil/...` reports PASS. - ---- - -## Step 3: Migrate `internal/errors` — fix `ToolError` - -**Objective:** Fix `ToMCPResult()` which calls `mcp.NewToolResultError` (does not -exist in go-sdk), and replace `map[string]interface{}` with `map[string]string` -in `ToolError.Context`. - -**Implementation guidance:** -1. Update import: remove `mark3labs/mcp-go/mcp`, add `kagent-dev/tools/internal/mcputil`. -2. Change `ToolError.Context` field type: `map[string]interface{}` → `map[string]string`. -3. Update `WithContext(key string, value interface{})` → `WithContext(key, value string)`. -4. Update `NewToolError` constructor: `Context: make(map[string]string)`. -5. Replace `ToMCPResult()` body: `return mcp.NewToolResultError(message.String())` → - `return mcputil.ErrorResult(message.String())`. -6. Update `WithContext` call sites in the same file (all callers pass string values). - -**Test requirements:** -- `go test ./internal/errors/...` passes. -- Existing tests updated to pass string values to `WithContext`. -- Coverage ≥ 70%. - -**Integration notes:** -- `internal/errors` depends only on `internal/mcputil` (already done in Step 2). -- `pkg/*` packages that call `WithContext` will need their call sites updated when - each package is migrated (Steps 5–12) — not required here. - -**Demo:** `go test ./internal/errors/... ./internal/mcputil/...` reports PASS. - ---- - -## Step 4: Migrate `internal/telemetry` — rewrite to `mcp.Middleware` - -**Objective:** Remove the per-handler `WithTracing` wrapper and the `AdaptToolHandler` -adapter. Replace with a single server-level `NewTracingMiddleware()` factory that -returns an `mcp.Middleware` and intercepts all tool calls. - -**Implementation guidance:** -1. In `middleware.go`: - - Remove `import "github.com/mark3labs/mcp-go/server"`. - - Change import to `"github.com/modelcontextprotocol/go-sdk/mcp"`. - - Delete type `ToolHandler`. - - Delete functions `WithTracing` and `AdaptToolHandler`. - - Add: -```go -// NewTracingMiddleware returns an mcp.Middleware that records an OTel span -// for every MCP method call, with richer attributes for tools/call. -func NewTracingMiddleware() mcp.Middleware { - return func(next mcp.MethodHandler) mcp.MethodHandler { - return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { - tracer := otel.Tracer("kagent-tools/mcp") - spanName := fmt.Sprintf("mcp.method.%s", method) - - // Enrich span name and attributes for tool calls - toolName := "" - if ctr, ok := req.(*mcp.CallToolRequest); ok { - toolName = ctr.Params.Name - spanName = fmt.Sprintf("mcp.tool.%s", toolName) - } - - ctx, span := tracer.Start(ctx, spanName) - defer span.End() - - headers := ExtractHTTPHeaders(ctx) - for k, v := range headers { - span.SetAttributes(attribute.String(fmt.Sprintf("http.header.%s", k), v)) - } - if toolName != "" { - span.SetAttributes(attribute.String("mcp.tool.name", toolName)) - } - span.AddEvent("mcp.method.start") - start := time.Now() - - result, err := next(ctx, method, req) - - span.SetAttributes(attribute.Float64("mcp.tool.duration_seconds", time.Since(start).Seconds())) - if err != nil { - span.RecordError(err) - span.SetStatus(codes.Error, err.Error()) - } else { - span.SetStatus(codes.Ok, "completed") - if ctr, ok := result.(*mcp.CallToolResult); ok { - span.SetAttributes(attribute.Bool("mcp.result.is_error", ctr.IsError)) - span.SetAttributes(attribute.Int("mcp.result.content_count", len(ctr.Content))) - } - } - return result, err - } - } -} -``` - -2. Update `middleware_test.go`: - - Remove test for `WithTracing` and `AdaptToolHandler`. - - Add test for `NewTracingMiddleware` using `mcp.NewInMemoryTransports()` to - create a real client-server pair with middleware applied; verify span is recorded. - -**Test requirements:** -- `go test ./internal/telemetry/...` passes. -- Coverage ≥ 70%. -- `WithTracing` and `AdaptToolHandler` are not referenced anywhere. - -**Integration notes:** -- `cmd/main.go` will call `mcpServer.AddReceivingMiddleware(telemetry.NewTracingMiddleware())` - in Step 13. -- Until Step 13, `NewTracingMiddleware` is defined but not yet wired. - -**Demo:** `go test ./internal/telemetry/...` reports PASS. - ---- - -## Step 5: Migrate `pkg/utils` - -**Objective:** Update `pkg/utils/common.go` (and any related files) to use -go-sdk types. `pkg/utils` is a leaf package with no dependencies on other `pkg/*` -packages, making it the safest starting point. - -**Implementation guidance:** -1. Replace imports: remove `mark3labs/mcp-go/mcp` and `mark3labs/mcp-go/server`, - add `modelcontextprotocol/go-sdk/mcp` and `kagent-dev/tools/internal/mcputil`. -2. For each tool handler: - - Define a `Params` struct with `json` and `jsonschema` tags. - - Change handler signature to `func(ctx, *mcp.CallToolRequest, Params) (*mcp.CallToolResult, any, error)`. - - Replace `mcp.ParseString(request, ...)` with struct field access. - - Replace `mcp.NewToolResultText(...)` with `mcputil.TextResult(...)`. - - Replace `mcp.NewToolResultError(...)` with `mcputil.ErrorResult(...)`. -3. Update `RegisterTools` signature: `func RegisterTools(s *mcp.Server, readOnly bool)`. -4. Replace `s.AddTool(mcp.NewTool(...), handler)` with `mcp.AddTool(s, &mcp.Tool{...}, handler)`. -5. Update `*_test.go`: call handlers directly with typed args structs. - -**Test requirements:** -- `go test ./pkg/utils/...` passes. -- Coverage ≥ 70%. -- No references to `mark3labs` in package. - -**Demo:** `go test ./pkg/utils/...` PASS. - ---- - -## Step 6: Migrate `pkg/prometheus` - -**Objective:** Migrate the Prometheus query tools. This package also includes -`promql.go` which uses MCP types for result construction. - -**Implementation guidance:** -1. Same handler migration pattern as Step 5. -2. Key params structs to define: - - `PrometheusQueryParams` (query string, time range, step) - - `PrometheusQueryRangeParams` - - `PrometheusInstantQueryParams` -3. Update `promql.go` if it constructs `mcp.CallToolResult` directly — replace - with `mcputil.TextResult` / `mcputil.ErrorResult`. -4. Update `prometheus_test.go` to use typed args. - -**Test requirements:** -- `go test ./pkg/prometheus/...` passes. -- Coverage ≥ 70%. - -**Demo:** `go test ./pkg/prometheus/...` PASS. - ---- - -## Step 7: Migrate `pkg/argo` - -**Objective:** Migrate the 8 Argo Rollouts tool handlers. - -**Implementation guidance:** -1. Define params structs: - - `VerifyArgoControllerParams` (namespace, label) - - `VerifyKubectlPluginParams` (no params — empty struct `struct{}`) - - `ListRolloutsParams` (namespace, type) - - `CheckPluginLogsParams` (namespace, timeout) - - `PromoteRolloutParams` (rollout_name, namespace, full bool) - - `PauseRolloutParams` (rollout_name, namespace) - - `SetRolloutImageParams` (rollout_name, container_image, namespace) - - `VerifyGatewayPluginParams` (version, namespace, should_install bool) -2. For handlers with no parameters (e.g., `handleVerifyKubectlPluginInstall`), - use an empty struct: `type VerifyKubectlPluginParams struct{}`. -3. Remove `WithTracing` wrapping from `RegisterTools` — tracing is now server-wide. -4. Update `argo_test.go`. - -**Test requirements:** -- `go test ./pkg/argo/...` passes. -- Coverage ≥ 90% (critical package per CLAUDE.md). - -**Demo:** `go test ./pkg/argo/...` PASS with ≥ 90% coverage shown. - ---- - -## Step 8: Migrate `pkg/cilium` - -**Objective:** Migrate the 12 Cilium tool handlers. - -**Implementation guidance:** -1. Define params structs for each handler: - - `CiliumStatusParams` — empty struct - - `UpgradeCiliumParams` (cluster_name, datapath_mode) - - `InstallCiliumParams` (cluster_name, cluster_id, datapath_mode) - - `UninstallCiliumParams` — empty struct - - `ConnectRemoteClusterParams` (cluster_name required, context) - - `DisconnectRemoteClusterParams` (cluster_name required) - - `ListBGPPeersParams` — empty struct - - `ListBGPRoutesParams` — empty struct - - `ClusterMeshStatusParams` — empty struct - - `FeaturesStatusParams` — empty struct - - `ToggleHubbleParams` (enable bool, default=true) - - `ToggleClusterMeshParams` (enable bool, default=true) -2. For boolean-toggle handlers, note that bool default in jsonschema tag must be - specified: `jsonschema:"enable Hubble,default=true"`. -3. Update `cilium_test.go`. - -**Test requirements:** -- `go test ./pkg/cilium/...` passes. -- Coverage ≥ 90%. - -**Demo:** `go test ./pkg/cilium/...` PASS. - ---- - -## Step 9: Migrate `pkg/helm` - -**Objective:** Migrate the 6 Helm tool handlers. - -**Implementation guidance:** -1. Define params structs: - - `HelmListParams` (namespace, all_namespaces, all, uninstalled, failed, deployed, pending, filter, output) - - `HelmGetReleaseParams` (name required, namespace required, output) - - `HelmUpgradeParams` (name required, chart required, namespace, version, values_file, set, wait bool, timeout, create_namespace bool, install bool) - - `HelmUninstallParams` (name required, namespace required, keep_history bool) - - `HelmRepoAddParams` (name required, url required, username, password, force_update bool) - - `HelmRepoUpdateParams` — empty struct -2. Update `helm_test.go`. -3. Verify security validation calls (`security.ValidateName`, etc.) still occur - after struct population — these are business-logic checks that remain. - -**Test requirements:** -- `go test ./pkg/helm/...` passes. -- Coverage ≥ 90%. - -**Demo:** `go test ./pkg/helm/...` PASS. - ---- - -## Step 10: Migrate `pkg/istio` - -**Objective:** Migrate all Istio tool handlers. - -**Implementation guidance:** -1. Define params structs for each istio handler (proxy-status, analyze, install, - upgrade, verify-install, etc.) — follow the same struct pattern. -2. Update `istio_test.go`. - -**Test requirements:** -- `go test ./pkg/istio/...` passes. -- Coverage ≥ 90%. - -**Demo:** `go test ./pkg/istio/...` PASS. - ---- - -## Step 11: Migrate `pkg/k8s` - -**Objective:** Migrate the largest and most critical package — all kubectl-based -Kubernetes tool handlers. - -**Implementation guidance:** -1. Define params structs for all handlers: - - `KubectlGetParams`, `KubectlLogsParams`, `ScaleDeploymentParams`, - `PatchResourceParams`, `ApplyManifestParams`, `DeleteResourceParams`, - `CheckServiceConnectivityParams`, `GetEventsParams`, `ExecCommandParams`, - `GetAvailableAPIResourcesParams`, `DescribeResourceParams`, - `ManageAnnotationParams`, `ManageLabelParams`, `SetAnnotationsParams`, - and any others present. -2. Required fields identified from current `if param == "" { return error }` guards: - use `jsonschema:"...,required"` for these, then remove the redundant guard. -3. Keep non-trivial business-logic guards (e.g., security validation). -4. Update `k8s_test.go` — this is the largest test file; use table-driven tests - for all params variations. - -**Test requirements:** -- `go test ./pkg/k8s/...` passes. -- Coverage ≥ 90%. - -**Demo:** `go test ./pkg/k8s/...` PASS with ≥ 90% coverage. - ---- - -## Step 12: Migrate `pkg/kubescape` - -**Objective:** Migrate all Kubescape scan and report tool handlers. - -**Implementation guidance:** -1. Define params structs for each handler (scan, get vulnerability manifests, - get configuration scans, get application profiles, etc.). -2. Update `kubescape_test.go`. - -**Test requirements:** -- `go test ./pkg/kubescape/...` passes. -- Coverage ≥ 90%. - -**Demo:** `go test ./pkg/kubescape/...` PASS. - ---- - -## Step 13: Migrate `cmd/main.go` — wire everything together - -**Objective:** Update the entry point to use the new SDK server, transports, -and middleware. This is the integration step that makes the full binary compile -and run end-to-end. - -**Implementation guidance:** -1. Remove `import "github.com/mark3labs/mcp-go/server"`. -2. Add `import "github.com/modelcontextprotocol/go-sdk/mcp"`. -3. Replace server creation: -```go -mcpServer := mcp.NewServer(&mcp.Implementation{ - Name: Name, - Version: Version, -}, nil) -``` -4. Add telemetry middleware: -```go -mcpServer.AddReceivingMiddleware(telemetry.NewTracingMiddleware()) -``` -5. Update `toolProviderMap` type: `map[string]func(*mcp.Server)`. -6. Replace `runStdioServer`: -```go -func runStdioServer(ctx context.Context, s *mcp.Server) { - logger.Get().Info("Running KAgent Tools Server STDIO:", "tools", strings.Join(tools, ",")) - if err := s.Run(ctx, &mcp.StdioTransport{}); err != nil { - logger.Get().Info("Stdio server stopped", "error", err) - } -} -``` -7. Replace HTTP server setup: -```go -httpHandler := mcp.NewStreamableHTTPHandler( - func(r *http.Request) *mcp.Server { return mcpServer }, - nil, -) -mux.Handle("/", telemetry.HTTPMiddleware(http.HandlerFunc( - func(w http.ResponseWriter, r *http.Request) { - httpHandler.ServeHTTP(w, r) - }, -))) -``` -8. Remove the `server.WithHeartbeatInterval` option (no equivalent in go-sdk - StreamableHTTPHandler; rely on HTTP keep-alive). -9. Verify `registerMCP(mcpServer, ...)` compiles with `*mcp.Server` argument. - -**Test requirements:** -- `go build ./cmd/...` succeeds with zero errors. -- `go run ./cmd -- --stdio` starts and responds to `tools/list`. -- `make lint` passes. - -**Integration notes:** -- This is the first step where `grep -r "mark3labs" .` should return zero results. - -**Demo:** -```bash -echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | go run ./cmd -- --stdio -``` -Returns a JSON response listing all tools. - ---- - -## Step 14: Update E2E test helpers - -**Objective:** Update `test/e2e/helpers_test.go` to use go-sdk client types for -integration test scaffolding. - -**Implementation guidance:** -1. Replace `mark3labs` client types with go-sdk equivalents: -```go -// Before: mark3labs client construction -// After: -client := mcp.NewClient(&mcp.Implementation{Name: "test-client"}, nil) -transport := &mcp.CommandTransport{Command: exec.Command("./bin/kagent-tools", "--stdio")} -session, err := client.Connect(ctx, transport, nil) -``` -2. Update tool invocations: -```go -res, err := session.CallTool(ctx, &mcp.CallToolParams{ - Name: "kubectl_get", - Arguments: map[string]any{"resource_type": "pod"}, -}) -``` -3. Replace result assertions: -```go -// Check IsError flag -if res.IsError { t.Fatalf(...) } -text := res.Content[0].(*mcp.TextContent).Text -``` - -**Test requirements:** -- `go test ./test/e2e/...` passes (or is skipped gracefully when cluster unavailable). - -**Demo:** `go test ./test/e2e/... -run TestToolsList` PASS. - ---- - -## Step 15: Final validation - -**Objective:** Confirm all quality gates pass, no mark3labs references remain, -and the binary behaves identically to before migration. - -**Implementation guidance:** -1. Run full test suite: -```bash -make test -``` -2. Verify zero mark3labs references: -```bash -grep -r "mark3labs" . --include="*.go" --include="go.mod" -# must return: no output -``` -3. Verify no `map[any]any` or `map[string]interface{}` in tool params: -```bash -grep -r "map\[string\]interface{}\|map\[string\]any\|map\[any\]" pkg/ internal/ --include="*.go" -# must return: no output -``` -4. Run linter: -```bash -make lint -``` -5. Build all platform binaries: -```bash -make build -``` -6. Smoke test both transports: -```bash -# Stdio -echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' \ - | ./bin/kagent-tools --stdio - -# HTTP -./bin/kagent-tools --port 8085 & -sleep 1 -curl -s http://localhost:8085/health -kill %1 -``` - -**Test requirements:** -- `make test` exits 0. -- `make lint` exits 0. -- `make build` exits 0. -- Both smoke tests return expected responses. -- `grep -r "mark3labs" .` returns no matches. - -**Demo:** CI pipeline passes (or equivalent local `make test && make lint && make build`). diff --git a/specs/migrate-mcp-go-to-official-sdk/requirements.md b/specs/migrate-mcp-go-to-official-sdk/requirements.md deleted file mode 100644 index 1d116695..00000000 --- a/specs/migrate-mcp-go-to-official-sdk/requirements.md +++ /dev/null @@ -1,25 +0,0 @@ -# Requirements Q&A - -> This file captures requirements clarification questions and answers gathered during the PDD process. -> Questions and answers are appended in real time. - ---- - -## Q1: What type safety requirements apply to the SDK migration? - -**Q:** Should the migration use any dynamic/generic map types (e.g. `map[string]any`, `map[any]any`) for tool parameters or results, or should concrete Go struct types be used? - -**A:** Use Go struct types throughout. Avoid `map[any]any` and prefer typed structs for all tool parameters, inputs, and outputs. This applies to parameter parsing, result construction, and any intermediate data structures introduced during the migration. - ---- - -## Research findings appended - -See `research/sdk-comparison.md` and `skill.md` for the full API mapping. - -Key confirmed facts from official SDK examples and pkg.go.dev: -- `mcp.AddTool` is a generic function that auto-derives JSON schema from the typed `In` param struct. -- `ToolHandlerFor[In, Out any]` signature returns `(*CallToolResult, any, error)` — three values. -- No `NewToolResultText` / `NewToolResultError` helpers — must construct `CallToolResult` directly or add local helpers. -- Middleware uses `AddReceivingMiddleware` with `mcp.MethodHandler` / `mcp.Middleware` types. -- `ToolError.Context` field (`map[string]interface{}`) violates no-map-any-any rule and must be replaced. diff --git a/specs/migrate-mcp-go-to-official-sdk/research/sdk-comparison.md b/specs/migrate-mcp-go-to-official-sdk/research/sdk-comparison.md deleted file mode 100644 index c45ce756..00000000 --- a/specs/migrate-mcp-go-to-official-sdk/research/sdk-comparison.md +++ /dev/null @@ -1,222 +0,0 @@ -# SDK Comparison: mark3labs/mcp-go vs modelcontextprotocol/go-sdk - -## Sources -- https://github.com/modelcontextprotocol/go-sdk -- https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp -- https://github.com/modelcontextprotocol/go-sdk/tree/main/examples - ---- - -## Current dependency (mark3labs/mcp-go v0.43.2) - -### Imports used in this project -``` -"github.com/mark3labs/mcp-go/mcp" -"github.com/mark3labs/mcp-go/server" -``` - -### Server lifecycle -```go -// Create server -mcpServer := server.NewMCPServer(name, version) - -// Stdio mode -stdioServer := server.NewStdioServer(mcpServer) -stdioServer.Listen(ctx, os.Stdin, os.Stdout) - -// HTTP/SSE mode -sseServer := server.NewStreamableHTTPServer(mcpServer, - server.WithHeartbeatInterval(30*time.Second), -) -sseServer.ServeHTTP(w, r) -``` - -### Tool definition & registration -```go -// Define tool with option-function pattern -tool := mcp.NewTool("tool_name", - mcp.WithDescription("description"), - mcp.WithString("param", - mcp.Required(), - mcp.Description("param description"), - ), - mcp.WithBoolean("flag", - mcp.Description("flag description"), - ), - mcp.WithNumber("count", - mcp.Description("count description"), - ), -) -// Register on server -mcpServer.AddTool(tool, handler) -``` - -### Handler signature -```go -type ToolHandlerFunc func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) -// Note: CallToolRequest is a value type (not pointer) in mark3labs -``` - -### Parameter parsing -```go -// String with default -val := mcp.ParseString(request, "param_name", "default") -// Int with default -count := mcp.ParseInt(request, "count", 50) -// Bool equivalent (parsed as string) -flag := mcp.ParseString(request, "flag", "") == "true" -``` - -### Result construction -```go -// Success -return mcp.NewToolResultText("output text"), nil -// Error (tool-level, not protocol error) -return mcp.NewToolResultError("error message"), nil -``` - -### Middleware / telemetry adapter -```go -// Adapter wraps a typed ToolHandler into server.ToolHandlerFunc -func AdaptToolHandler(th ToolHandler) server.ToolHandlerFunc { - return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return th(ctx, req) - } -} -``` - -### request.Params access (used in telemetry) -```go -request.Params.Name // tool name string -request.Params.Arguments // map[string]interface{} or nil -``` - ---- - -## Target dependency (modelcontextprotocol/go-sdk, latest) - -### Import -```go -"github.com/modelcontextprotocol/go-sdk/mcp" -``` - -### Server lifecycle -```go -// Create server -server := mcp.NewServer(&mcp.Implementation{Name: "name", Version: "v1.0"}, nil) - -// Stdio mode (blocks until client disconnects) -server.Run(ctx, &mcp.StdioTransport{}) - -// HTTP/SSE mode (legacy SSE, spec 2024-11-05) -handler := mcp.NewSSEHandler(func(r *http.Request) *mcp.Server { - return server -}, nil) -http.ListenAndServe(addr, handler) - -// HTTP Streamable mode (spec 2025-03-26+) -handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { - return server -}, nil) -http.ListenAndServe(addr, handler) -``` - -### Tool definition & registration (typed — PREFERRED) -```go -// Define typed params struct -type MyToolParams struct { - Param string `json:"param" jsonschema:"description of param,required"` - Flag bool `json:"flag" jsonschema:"flag description"` - Count int `json:"count" jsonschema:"count description"` -} - -// Register — schema auto-derived from struct tags -mcp.AddTool(server, &mcp.Tool{ - Name: "tool_name", - Description: "description", -}, func(ctx context.Context, req *mcp.CallToolRequest, args MyToolParams) (*mcp.CallToolResult, any, error) { - // args.Param, args.Flag, args.Count are already populated and validated - return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{Text: "output"}}, - }, nil, nil -}) -``` - -### Tool definition & registration (low-level — avoid if possible) -```go -// Low-level: handler receives raw CallToolRequest, no auto-validation -server.AddTool(&mcp.Tool{ - Name: "tool_name", - Description: "description", - InputSchema: &jsonschema.Schema{ /* ... */ }, -}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - // manual parsing required - return &mcp.CallToolResult{...}, nil -}) -``` - -### Handler signatures -```go -// Typed (preferred) — ToolHandlerFor[In, Out any] -func(ctx context.Context, req *mcp.CallToolRequest, args MyParams) (*mcp.CallToolResult, any, error) - -// Low-level — ToolHandler -func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) -``` - -### Result construction -```go -// Success -return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{Text: "output text"}}, -}, nil, nil - -// Tool-level error (IsError=true, not a protocol error) -return &mcp.CallToolResult{ - IsError: true, - Content: []mcp.Content{&mcp.TextContent{Text: "error message"}}, -}, nil, nil - -// Protocol-level error (returns as Go error) -return nil, nil, fmt.Errorf("protocol error: %w", err) -``` - -### Middleware -```go -type MethodHandler func(ctx context.Context, method string, req Request) (Result, error) -type Middleware func(next MethodHandler) MethodHandler - -server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { - return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { - // pre-processing - result, err := next(ctx, method, req) - // post-processing - return result, err - } -}) - -// Access tool info inside middleware: -if ctr, ok := req.(*mcp.CallToolRequest); ok { - _ = ctr.Params.Name // tool name - _ = ctr.Params.Arguments // json.RawMessage -} -// Access tool result in middleware: -if ctr, ok := result.(*mcp.CallToolResult); ok { - _ = ctr.IsError - _ = ctr.StructuredContent -} -``` - -### Key types -```go -mcp.Implementation{Name string; Version string} -mcp.ServerOptions{} -mcp.Tool{Name string; Description string; InputSchema *jsonschema.Schema; OutputSchema *jsonschema.Schema} -mcp.CallToolRequest // = ServerRequest[*CallToolParamsRaw] -mcp.CallToolResult{Content []Content; IsError bool; StructuredContent any} -mcp.Content // interface -mcp.TextContent{Text string; Meta Meta; Annotations *Annotations} -mcp.StdioTransport{} -mcp.SSEHandler // http.Handler for SSE -mcp.StreamableHTTPHandler // http.Handler for streamable HTTP -``` diff --git a/specs/migrate-mcp-go-to-official-sdk/rough-idea.md b/specs/migrate-mcp-go-to-official-sdk/rough-idea.md deleted file mode 100644 index 54d29f75..00000000 --- a/specs/migrate-mcp-go-to-official-sdk/rough-idea.md +++ /dev/null @@ -1,20 +0,0 @@ -# Rough Idea - -## Summary - -Migrate `github.com/mark3labs/mcp-go` to the official MCP Go SDK at `https://github.com/modelcontextprotocol/go-sdk`. - -## Context - -The project currently depends on the community-maintained MCP Go SDK (`github.com/mark3labs/mcp-go v0.43.2`). The official MCP Go SDK has been released at `github.com/modelcontextprotocol/go-sdk`. The migration should ensure all existing functionality is preserved while adopting the officially-supported library. - -## Current State - -- **Dependency**: `github.com/mark3labs/mcp-go v0.43.2` -- **Usage**: Tool registration, MCP server setup, transport handling (stdio, HTTP/SSE), tool result types -- **Files affected**: `cmd/main.go`, all `pkg/*/` tool packages -- **CLAUDE.md** already references `github.com/modelcontextprotocol/go-sdk` as the active technology - -## Goal - -Replace all usage of `github.com/mark3labs/mcp-go` with `github.com/modelcontextprotocol/go-sdk` across the codebase, maintaining full feature parity and test coverage requirements. diff --git a/specs/migrate-mcp-go-to-official-sdk/skill.md b/specs/migrate-mcp-go-to-official-sdk/skill.md deleted file mode 100644 index d5738ffc..00000000 --- a/specs/migrate-mcp-go-to-official-sdk/skill.md +++ /dev/null @@ -1,442 +0,0 @@ -# Migration Skill: mark3labs/mcp-go → modelcontextprotocol/go-sdk - -> Reference document for migrating `github.com/mark3labs/mcp-go` to the official -> `github.com/modelcontextprotocol/go-sdk`. Use this as the authoritative lookup -> during implementation. All patterns use concrete Go struct types — no `map[any]any`. - ---- - -## 1. Dependency Change - -```diff -# go.mod -- github.com/mark3labs/mcp-go v0.43.2 -+ github.com/modelcontextprotocol/go-sdk -``` - -```bash -go get github.com/modelcontextprotocol/go-sdk@latest -go mod tidy -``` - ---- - -## 2. Import Paths - -| mark3labs | go-sdk | -|-----------|--------| -| `"github.com/mark3labs/mcp-go/mcp"` | `"github.com/modelcontextprotocol/go-sdk/mcp"` | -| `"github.com/mark3labs/mcp-go/server"` | _(removed — all under `mcp` package)_ | - ---- - -## 3. Server Creation - -### mark3labs -```go -import "github.com/mark3labs/mcp-go/server" - -mcpServer := server.NewMCPServer(Name, Version) -``` - -### go-sdk -```go -import "github.com/modelcontextprotocol/go-sdk/mcp" - -mcpServer := mcp.NewServer(&mcp.Implementation{ - Name: Name, - Version: Version, -}, nil) -``` - ---- - -## 4. Tool Handler Signature - -This is the most impactful change. Replace dynamic parsing with typed structs. - -### mark3labs -```go -func handleMyTool(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - param := mcp.ParseString(request, "param_name", "") - count := mcp.ParseInt(request, "count", 50) - flag := mcp.ParseString(request, "flag", "") == "true" - // ... -} -``` - -### go-sdk (REQUIRED pattern — typed structs, no map[any]any) -```go -// 1. Define a params struct for every tool -type MyToolParams struct { - ParamName string `json:"param_name" jsonschema:"description of param"` - Count int `json:"count" jsonschema:"number of lines,default=50"` - Flag bool `json:"flag" jsonschema:"enable flag"` -} - -// 2. Handler receives populated, validated struct directly -func handleMyTool(ctx context.Context, req *mcp.CallToolRequest, args MyToolParams) (*mcp.CallToolResult, any, error) { - // args.ParamName, args.Count, args.Flag are already set - // ... -} -``` - -**Key rules:** -- Every tool MUST have a dedicated params struct. -- Fields validated as `required` in jsonschema will return a tool error automatically. -- Handler returns THREE values: `(*mcp.CallToolResult, any, error)` — the middle `any` is the structured output (return `nil` if unused). -- `req` is a pointer (`*mcp.CallToolRequest`), not a value. - ---- - -## 5. Tool Definition & Registration - -### mark3labs -```go -tool := mcp.NewTool("tool_name", - mcp.WithDescription("description"), - mcp.WithString("param", mcp.Required(), mcp.Description("...")), - mcp.WithBoolean("flag", mcp.Description("...")), - mcp.WithNumber("count", mcp.Description("...")), -) -mcpServer.AddTool(tool, handler) -``` - -### go-sdk -```go -// Schema is auto-derived from the params struct — no need to list params manually. -mcp.AddTool(mcpServer, &mcp.Tool{ - Name: "tool_name", - Description: "description", -}, handleMyTool) -``` - -**Struct tags that drive schema generation:** - -| Tag | Purpose | -|-----|---------| -| `json:"field_name"` | JSON key name (required) | -| `jsonschema:"description text"` | Field description shown in schema | -| `jsonschema:"description,required"` | Mark field as required | -| `jsonschema:"description,default=value"` | Provide default value | - ---- - -## 6. Result Construction - -### mark3labs → go-sdk - -| Scenario | mark3labs | go-sdk | -|----------|-----------|--------| -| **Success** | `mcp.NewToolResultText("text")` | `&mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "text"}}}` | -| **Tool error** | `mcp.NewToolResultError("msg")` | `&mcp.CallToolResult{IsError: true, Content: []mcp.Content{&mcp.TextContent{Text: "msg"}}}` | -| **Protocol error** | `return nil, fmt.Errorf("...")` | `return nil, nil, fmt.Errorf("...")` | - -### Helper functions to define (add to `pkg/utils/` or `internal/mcputil/`) - -Since the official SDK has no `NewToolResultText`/`NewToolResultError` helpers, -define these once and reuse: - -```go -package mcputil - -import "github.com/modelcontextprotocol/go-sdk/mcp" - -func TextResult(text string) *mcp.CallToolResult { - return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{Text: text}}, - } -} - -func ErrorResult(msg string) *mcp.CallToolResult { - return &mcp.CallToolResult{ - IsError: true, - Content: []mcp.Content{&mcp.TextContent{Text: msg}}, - } -} -``` - ---- - -## 7. Transport / Server Startup - -### Stdio transport - -#### mark3labs -```go -stdioServer := server.NewStdioServer(mcpServer) -stdioServer.Listen(ctx, os.Stdin, os.Stdout) -``` - -#### go-sdk -```go -// Run blocks until client disconnects or ctx is cancelled -if err := mcpServer.Run(ctx, &mcp.StdioTransport{}); err != nil { - logger.Get().Info("Stdio server stopped", "error", err) -} -``` - -### HTTP/SSE transport - -#### mark3labs -```go -sseServer := server.NewStreamableHTTPServer(mcpServer, - server.WithHeartbeatInterval(30*time.Second), -) -mux.Handle("/", sseServer) -``` - -#### go-sdk -```go -// StreamableHTTPHandler (MCP spec 2025-03-26+) -handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { - return mcpServer -}, nil) -mux.Handle("/", handler) - -// OR legacy SSEHandler (MCP spec 2024-11-05) -handler := mcp.NewSSEHandler(func(r *http.Request) *mcp.Server { - return mcpServer -}, nil) -mux.Handle("/", handler) -``` - -> **Note:** `WithHeartbeatInterval` has no direct equivalent — check -> `StreamableHTTPOptions` for any keepalive options in the installed version. - ---- - -## 8. Middleware / Telemetry - -The telemetry `WithTracing` wrapper currently adapts `ToolHandler` → `server.ToolHandlerFunc`. -With go-sdk, use `AddReceivingMiddleware` instead. - -### go-sdk middleware signature -```go -type MethodHandler func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) -type Middleware func(next mcp.MethodHandler) mcp.MethodHandler - -mcpServer.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { - return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { - // Intercept tool calls - if ctr, ok := req.(*mcp.CallToolRequest); ok { - toolName := ctr.Params.Name - _ = toolName // use for spans - } - result, err := next(ctx, method, req) - // Inspect tool results - if ctr, ok := result.(*mcp.CallToolResult); ok { - _ = ctr.IsError - } - return result, err - } -}) -``` - -### Migrating `internal/telemetry/middleware.go` - -1. Remove `ToolHandler` type alias (no longer needed). -2. Remove `AdaptToolHandler` function. -3. Expose a `NewTracingMiddleware(tracer) mcp.Middleware` function instead. -4. The `WithTracing(toolName, handler)` wrapper pattern is replaced by a single - server-level middleware that extracts tool name from `req.(*mcp.CallToolRequest).Params.Name`. - -### Accessing request context in middleware -```go -// Tool name -ctr.Params.Name - -// Arguments (json.RawMessage, not map — use json.Unmarshal to read) -ctr.Params.Arguments - -// Session ID -req.GetSession().ID() -``` - ---- - -## 9. internal/errors/tool_errors.go - -`ToMCPResult()` calls `mcp.NewToolResultError(...)` which does not exist in go-sdk. - -### Fix -```go -// Before (mark3labs) -return mcp.NewToolResultError(message.String()) - -// After (go-sdk) -return &mcp.CallToolResult{ - IsError: true, - Content: []mcp.Content{&mcp.TextContent{Text: message.String()}}, -} -``` - -Also replace `map[string]interface{}` in `ToolError.Context` with a concrete struct -or `map[string]string` to honour the "no map[any]any" requirement. - ---- - -## 10. RegisterTools Function Signature - -All `pkg/*/` packages export a `RegisterTools` function. Signature changes from: - -```go -// mark3labs -func RegisterTools(s *server.MCPServer, readOnly bool) -``` - -to: - -```go -// go-sdk -func RegisterTools(s *mcp.Server, readOnly bool) -``` - -`cmd/main.go` `registerMCP` function and its `toolProviderMap` closures update accordingly: - -```go -// Before -toolProviderMap := map[string]func(*server.MCPServer){...} - -// After -toolProviderMap := map[string]func(*mcp.Server){...} -``` - ---- - -## 11. Params Struct Reference (per package) - -Define one `*Params` struct per tool handler. Name it `Params`. - -### Example: k8s package - -```go -// kubectl_get -type KubectlGetParams struct { - ResourceType string `json:"resource_type" jsonschema:"type of K8s resource (pod/deploy/svc..),required"` - ResourceName string `json:"resource_name" jsonschema:"name of the resource"` - Namespace string `json:"namespace" jsonschema:"namespace to query"` - AllNamespaces bool `json:"all_namespaces" jsonschema:"query all namespaces"` - Output string `json:"output" jsonschema:"output format (wide/json/yaml),default=wide"` -} - -// kubectl_logs -type KubectlLogsParams struct { - PodName string `json:"pod_name" jsonschema:"name of the pod,required"` - Namespace string `json:"namespace" jsonschema:"namespace,default=default"` - Container string `json:"container" jsonschema:"container name"` - TailLines int `json:"tail_lines" jsonschema:"number of log lines,default=50"` -} - -// scale_deployment -type ScaleDeploymentParams struct { - Name string `json:"name" jsonschema:"deployment name,required"` - Namespace string `json:"namespace" jsonschema:"namespace,default=default"` - Replicas int `json:"replicas" jsonschema:"desired replica count,default=1"` -} -``` - -### Example: helm package - -```go -type HelmListParams struct { - Namespace string `json:"namespace" jsonschema:"filter by namespace"` - AllNamespaces bool `json:"all_namespaces" jsonschema:"list across all namespaces"` - All bool `json:"all" jsonschema:"show all releases"` - Uninstalled bool `json:"uninstalled" jsonschema:"show uninstalled releases"` - Failed bool `json:"failed" jsonschema:"show failed releases"` - Deployed bool `json:"deployed" jsonschema:"show deployed releases"` - Pending bool `json:"pending" jsonschema:"show pending releases"` - Filter string `json:"filter" jsonschema:"regex filter for release names"` - Output string `json:"output" jsonschema:"output format"` -} -``` - ---- - -## 12. Test Migration - -Tests using mark3labs types must be updated: - -```go -// Before (mark3labs) -req := mcp.CallToolRequest{} -req.Params.Arguments = map[string]interface{}{"param": "value"} - -// After (go-sdk — construct the typed params struct directly in tests) -args := MyToolParams{ParamName: "value", Count: 10} -// Call handler directly with args, bypassing request parsing: -result, _, err := handleMyTool(ctx, &mcp.CallToolRequest{}, args) -``` - -For mock-based tests in `pkg/*/`, inject args directly into the typed handler — -no need to construct `CallToolRequest` params at all for unit tests. - ---- - -## 13. Files to Modify (complete list) - -| File | Change | -|------|--------| -| `go.mod` / `go.sum` | Replace dependency | -| `cmd/main.go` | Server creation, transports, `registerMCP` signature | -| `internal/telemetry/middleware.go` | Replace `ToolHandler` type, remove `AdaptToolHandler`, add `mcp.Middleware` factory | -| `internal/telemetry/middleware_test.go` | Update test types | -| `internal/errors/tool_errors.go` | Fix `ToMCPResult()`, fix `Context` map type | -| `pkg/k8s/k8s.go` | Params structs, handler signatures, registration | -| `pkg/k8s/k8s_test.go` | Update test helpers | -| `pkg/helm/helm.go` | Params structs, handler signatures, registration | -| `pkg/helm/helm_test.go` | Update test helpers | -| `pkg/istio/istio.go` | Params structs, handler signatures, registration | -| `pkg/istio/istio_test.go` | Update test helpers | -| `pkg/argo/argo.go` | Params structs, handler signatures, registration | -| `pkg/argo/argo_test.go` | Update test helpers | -| `pkg/cilium/cilium.go` | Params structs, handler signatures, registration | -| `pkg/cilium/cilium_test.go` | Update test helpers | -| `pkg/prometheus/prometheus.go` | Params structs, handler signatures, registration | -| `pkg/prometheus/prometheus_test.go` | Update test helpers | -| `pkg/prometheus/promql.go` | Update MCP types | -| `pkg/kubescape/kubescape.go` | Params structs, handler signatures, registration | -| `pkg/kubescape/kubescape_test.go` | Update test helpers | -| `pkg/utils/common.go` | Update MCP types | -| `pkg/utils/datetime_test.go` | Update test types | -| `test/e2e/helpers_test.go` | Update client/server setup | - ---- - -## 14. Migration Order (recommended) - -1. **`go.mod`** — swap dependency, run `go mod tidy` -2. **`internal/mcputil/`** — create `TextResult` / `ErrorResult` helpers (new file) -3. **`internal/errors/tool_errors.go`** — fix `ToMCPResult()` and `Context` field type -4. **`internal/telemetry/middleware.go`** — rewrite to `mcp.Middleware` pattern -5. **`pkg/utils/`** — update types (least dependent) -6. **`pkg/prometheus/`** — update types -7. **`pkg/argo/`**, **`pkg/cilium/`**, **`pkg/helm/`**, **`pkg/istio/`**, **`pkg/k8s/`**, **`pkg/kubescape/`** — update each package (params structs + handler signatures + registration) -8. **`cmd/main.go`** — update server creation and transport wiring -9. **All `*_test.go`** — update test helpers per package -10. **`test/e2e/`** — update integration test helpers - -Run `make test` and `make lint` after each package to catch regressions early. - ---- - -## 15. Quick Reference Card - -``` -REMOVED (mark3labs) → REPLACEMENT (go-sdk) -───────────────────────────────────────────────────────────────── -server.NewMCPServer(n,v) → mcp.NewServer(&mcp.Implementation{Name:n,Version:v}, nil) -server.NewStdioServer(s) → s.Run(ctx, &mcp.StdioTransport{}) -server.NewStreamableHTTP(s) → mcp.NewStreamableHTTPHandler(func(r)*mcp.Server{return s}, nil) -server.ToolHandlerFunc → mcp.ToolHandlerFor[In,Out] or mcp.ToolHandler -mcp.CallToolRequest (value) → *mcp.CallToolRequest (pointer) -mcp.ParseString(req,k,d) → struct field (typed params) -mcp.ParseInt(req,k,d) → struct field (typed params) -mcp.NewTool(name, opts...) → &mcp.Tool{Name:"...", Description:"..."} -s.AddTool(tool, handler) → mcp.AddTool(s, &mcp.Tool{...}, typedHandler) -mcp.NewToolResultText(t) → mcputil.TextResult(t) [local helper] -mcp.NewToolResultError(t) → mcputil.ErrorResult(t) [local helper] -handler returns (res, err) → handler returns (res, any, err) -───────────────────────────────────────────────────────────────── -``` diff --git a/specs/tools/001-migrate-mcp-go-to-official-sdk/PROMPT.md b/specs/tools/001-migrate-mcp-go-to-official-sdk/PROMPT.md deleted file mode 100644 index e69de29b..00000000 diff --git a/specs/tools/001-migrate-mcp-go-to-official-sdk/design.md b/specs/tools/001-migrate-mcp-go-to-official-sdk/design.md deleted file mode 100644 index 0addfe8d..00000000 --- a/specs/tools/001-migrate-mcp-go-to-official-sdk/design.md +++ /dev/null @@ -1,472 +0,0 @@ -# Design: Migrate mark3labs/mcp-go → modelcontextprotocol/go-sdk - -## Overview - -This document describes the design for replacing the community MCP Go SDK -(`github.com/mark3labs/mcp-go v0.43.2`) with the official MCP Go SDK -(`github.com/modelcontextprotocol/go-sdk`) across the `kagent-tools` server. - -The migration is a **drop-in SDK swap with a type-safety uplift**: all externally -visible behaviour (tool names, parameter names, transport protocols) is preserved, -while the internal implementation switches from dynamic map-based parameter parsing -to concrete Go struct types. - -No new tools are added. No tools are removed. No CLI flags change. - ---- - -## Detailed Requirements - -### R1 — Concrete Go struct types (no `map[any]any`) - -Every tool handler MUST receive its parameters as a named, exported Go struct. -Dynamic maps (`map[string]any`, `map[string]interface{}`, `map[any]any`) are -forbidden as tool parameter containers. Existing uses of `map[string]interface{}` -in `ToolError.Context` must also be replaced with a concrete type. - -### R2 — Full feature parity - -All 40+ tools across eight packages (k8s, helm, istio, argo, cilium, prometheus, -kubescape, utils) must be registered and functional after migration. Tool names, -parameter names, and descriptions must match the current implementation exactly. - -### R3 — Both transports preserved - -The server must continue to support: -- **stdio** (`--stdio` flag): communicates over stdin/stdout -- **HTTP Streamable** (default): listens on `--port` (default 8084) - -### R4 — Telemetry / OpenTelemetry tracing preserved - -The OTel tracing middleware that records tool name, arguments, duration, and -error state on every `tools/call` invocation must be rewritten using the -official SDK's `AddReceivingMiddleware` API. No tracing spans may be lost. - -### R5 — 80 % overall / 70 % per-package / 90 % critical-package coverage - -Test coverage thresholds defined in CLAUDE.md are unchanged. All updated -packages must pass `make test` after migration. - -### R6 — No breaking changes to the public `RegisterTools` interface - -Each `pkg/*/` package exposes `RegisterTools(s *mcp.Server, ...)`. The function -signature changes only the type of the first argument (from `*server.MCPServer` -to `*mcp.Server`). Callers in `cmd/main.go` are updated accordingly. - ---- - -## Architecture Overview - -```mermaid -graph TD - subgraph cmd - main["cmd/main.go
cobra CLI"] - end - - subgraph internal - tel["internal/telemetry
OTel middleware"] - errs["internal/errors
ToolError → MCP result"] - mcputil["internal/mcputil ← NEW
TextResult / ErrorResult helpers"] - end - - subgraph sdk ["github.com/modelcontextprotocol/go-sdk/mcp"] - Server["mcp.Server"] - AddTool["mcp.AddTool[In,Out]"] - Transports["StdioTransport
StreamableHTTPHandler"] - Middleware["AddReceivingMiddleware"] - end - - subgraph tools ["pkg/*"] - k8s; helm; istio; argo; cilium; prometheus; kubescape; utils - end - - main -->|"NewServer + transports"| sdk - main -->|"registerMCP"| tools - main -->|"AddReceivingMiddleware"| tel - tools -->|"mcp.AddTool + *Params structs"| AddTool - tools -->|"mcputil.TextResult / ErrorResult"| mcputil - errs -->|"&mcp.CallToolResult{IsError:true}"| sdk - mcputil -->|"&mcp.CallToolResult{Content:[...]}"| sdk - tel -->|"mcp.Middleware"| Middleware -``` - -### Key Architectural Decisions - -| Decision | Rationale | -|----------|-----------| -| Use generic `mcp.AddTool[In, Out]` (not low-level `server.AddTool`) | Auto-derives JSON schema from struct tags; eliminates manual `mcp.WithString/Bool/Number` option calls | -| Introduce `internal/mcputil` package | Single source for `TextResult`/`ErrorResult` helpers; avoids duplicating `&mcp.CallToolResult{...}` literals across 40+ handlers | -| Replace per-handler `WithTracing` wrapper with server-level middleware | Cleaner separation; one middleware intercepts all tool calls; no adapter boilerplate per handler | -| Replace `ToolError.Context map[string]interface{}` with `map[string]string` | Satisfies R1; `interface{}` was only ever used with string values | - ---- - -## Components and Interfaces - -### `internal/mcputil` (new package) - -```go -package mcputil - -import "github.com/modelcontextprotocol/go-sdk/mcp" - -// TextResult wraps a plain text string in a successful CallToolResult. -func TextResult(text string) *mcp.CallToolResult - -// ErrorResult wraps an error message in a tool-error CallToolResult (IsError=true). -func ErrorResult(msg string) *mcp.CallToolResult -``` - -### `internal/telemetry/middleware.go` (rewritten) - -```go -// Before -type ToolHandler func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) -func WithTracing(toolName string, handler ToolHandler) ToolHandler -func AdaptToolHandler(th ToolHandler) server.ToolHandlerFunc - -// After -// NewTracingMiddleware returns a mcp.Middleware that records OTel spans for -// every tools/call invocation. The tool name is read from req.(*mcp.CallToolRequest).Params.Name. -func NewTracingMiddleware() mcp.Middleware -``` - -All other telemetry helpers (`HTTPMiddleware`, `ExtractHTTPHeaders`, `StartSpan`, -`RecordError`, `RecordSuccess`, `AddEvent`) are unchanged. - -### `internal/errors/tool_errors.go` - -```go -// Context field type change -type ToolError struct { - // ... - Context map[string]string `json:"context,omitempty"` // was map[string]interface{} -} - -// ToMCPResult — result type changes; import changes from mark3labs to go-sdk -func (e *ToolError) ToMCPResult() *mcp.CallToolResult { - return mcputil.ErrorResult(message.String()) -} - -// WithContext parameter type change -func (e *ToolError) WithContext(key string, value string) *ToolError -``` - -### `pkg/*/` — Tool handler pattern - -Every handler is converted to the typed `ToolHandlerFor` pattern: - -```go -// Params struct — one per tool -type Params struct { - Field string `json:"field_name" jsonschema:"description[,required][,default=val]"` - // ... -} - -// Handler -func handle( - ctx context.Context, - req *mcp.CallToolRequest, - args Params, -) (*mcp.CallToolResult, any, error) { - // args.Field is already populated - return mcputil.TextResult(result), nil, nil -} - -// Registration -func RegisterTools(s *mcp.Server, readOnly bool) { - mcp.AddTool(s, &mcp.Tool{ - Name: "tool_name", - Description: "...", - }, handle) -} -``` - -### `cmd/main.go` - -```go -// Server creation -mcpServer := mcp.NewServer(&mcp.Implementation{Name: Name, Version: Version}, nil) - -// Middleware -mcpServer.AddReceivingMiddleware(telemetry.NewTracingMiddleware()) - -// Tool registration map -toolProviderMap := map[string]func(*mcp.Server){ - "k8s": func(s *mcp.Server) { k8s.RegisterTools(s, nil, kubeconfig, readOnly) }, - // ... -} - -// Stdio transport -func runStdioServer(ctx context.Context, s *mcp.Server) { - if err := s.Run(ctx, &mcp.StdioTransport{}); err != nil { ... } -} - -// HTTP transport -handler := mcp.NewStreamableHTTPHandler( - func(r *http.Request) *mcp.Server { return mcpServer }, - nil, -) -mux.Handle("/", telemetry.HTTPMiddleware(handler)) -``` - ---- - -## Data Models - -### Params Structs per Package - -All structs use `json` tags for field names and `jsonschema` tags for descriptions -and constraints. Required fields have `,required` appended to the jsonschema tag. - -#### `pkg/k8s` — KubectlGetParams (representative) - -```go -type KubectlGetParams struct { - ResourceType string `json:"resource_type" jsonschema:"K8s resource type (pod/deploy/svc),required"` - ResourceName string `json:"resource_name" jsonschema:"name of the specific resource"` - Namespace string `json:"namespace" jsonschema:"namespace to query"` - AllNamespaces bool `json:"all_namespaces" jsonschema:"query across all namespaces"` - Output string `json:"output" jsonschema:"output format (wide/json/yaml),default=wide"` -} -type KubectlLogsParams struct { - PodName string `json:"pod_name" jsonschema:"pod name,required"` - Namespace string `json:"namespace" jsonschema:"namespace,default=default"` - Container string `json:"container" jsonschema:"container name"` - TailLines int `json:"tail_lines" jsonschema:"number of log lines,default=50"` -} -type ScaleDeploymentParams struct { - Name string `json:"name" jsonschema:"deployment name,required"` - Namespace string `json:"namespace" jsonschema:"namespace,default=default"` - Replicas int `json:"replicas" jsonschema:"desired replica count,default=1"` -} -// ... one struct per handler, following the same pattern -``` - -#### `pkg/helm` (representative) - -```go -type HelmListParams struct { - Namespace string `json:"namespace" jsonschema:"filter by namespace"` - AllNamespaces bool `json:"all_namespaces" jsonschema:"list across all namespaces"` - All bool `json:"all" jsonschema:"show all releases including non-deployed"` - Uninstalled bool `json:"uninstalled" jsonschema:"show uninstalled releases"` - Failed bool `json:"failed" jsonschema:"show failed releases"` - Deployed bool `json:"deployed" jsonschema:"show deployed releases"` - Pending bool `json:"pending" jsonschema:"show pending releases"` - Filter string `json:"filter" jsonschema:"regex filter for release names"` - Output string `json:"output" jsonschema:"output format (table/json/yaml)"` -} -type HelmGetReleaseParams struct { - Name string `json:"name" jsonschema:"release name,required"` - Namespace string `json:"namespace" jsonschema:"namespace,required"` - Output string `json:"output" jsonschema:"output format (all/hooks/manifest/notes/values)"` -} -// ... one struct per handler -``` - -#### `pkg/argo` (representative) - -```go -type VerifyArgoControllerParams struct { - Namespace string `json:"namespace" jsonschema:"namespace to check,default=argo-rollouts"` - Label string `json:"label" jsonschema:"pod label selector,default=app.kubernetes.io/component=rollouts-controller"` -} -type PromoteRolloutParams struct { - RolloutName string `json:"rollout_name" jsonschema:"name of the rollout,required"` - Namespace string `json:"namespace" jsonschema:"namespace"` - Full bool `json:"full" jsonschema:"fully promote skipping all pauses"` -} -// ... -``` - -#### `pkg/cilium` (representative) - -```go -type UpgradeCiliumParams struct { - ClusterName string `json:"cluster_name" jsonschema:"cluster name"` - DatapathMode string `json:"datapath_mode" jsonschema:"datapath mode (tunnel/native-routing)"` -} -type InstallCiliumParams struct { - ClusterName string `json:"cluster_name" jsonschema:"cluster name"` - ClusterID string `json:"cluster_id" jsonschema:"unique cluster ID for cluster mesh"` - DatapathMode string `json:"datapath_mode" jsonschema:"datapath mode"` -} -type ConnectRemoteClusterParams struct { - ClusterName string `json:"cluster_name" jsonschema:"remote cluster name,required"` - Context string `json:"context" jsonschema:"kubeconfig context for remote cluster"` -} -type ToggleHubbleParams struct { - Enable bool `json:"enable" jsonschema:"true to enable Hubble,default=true"` -} -// ... -``` - ---- - -## Error Handling - -### Tool-level errors (visible to the LLM) - -Returned as `*mcp.CallToolResult` with `IsError: true`. The LLM sees the error -text as tool output and can reason about it. - -```go -// All paths that previously called mcp.NewToolResultError(msg): -return mcputil.ErrorResult(msg), nil, nil - -// ToolError.ToMCPResult(): -return mcputil.ErrorResult(message.String()) -``` - -### Protocol-level errors (terminates the JSON-RPC call) - -Returned as the `error` return value. Reserved for unexpected internal failures -that the LLM cannot meaningfully recover from. - -```go -return nil, nil, fmt.Errorf("internal error: %w", err) -``` - -### Validation - -Required fields in param structs are validated automatically by the SDK before -the handler is called. Manual `if param == "" { return error }` guards in -handlers are removed where the field is declared `required` in the jsonschema tag. -Optional guards for business logic remain. - ---- - -## Acceptance Criteria - -### AC-1: Dependency update - -**Given** `go.mod` is updated to remove `github.com/mark3labs/mcp-go` -**When** `go mod tidy` is run -**Then** no references to `mark3labs/mcp-go` remain in `go.mod` or `go.sum` - -### AC-2: No dynamic maps in tool params - -**Given** the migrated codebase -**When** `grep -r "map\[string\]any\|map\[string\]interface{}" pkg/ internal/` is run -**Then** zero matches are found inside tool handler functions or param types - -### AC-3: All tools register and are discoverable - -**Given** the server is started in stdio mode -**When** a `tools/list` request is sent -**Then** all tool names present before migration are returned in the response - -### AC-4: Stdio transport works - -**Given** the server binary is run with `--stdio` -**When** a `tools/call` JSON-RPC request is piped to stdin -**Then** a valid JSON-RPC response with tool result is written to stdout - -### AC-5: HTTP/Streamable transport works - -**Given** the server is started without `--stdio` on port 8084 -**When** an HTTP MCP client connects and calls a tool -**Then** the response is returned with correct content - -### AC-6: Telemetry traces recorded - -**Given** an OTel exporter is configured -**When** a tool call is made -**Then** a span named `mcp.tool.` is recorded with `mcp.tool.name` and duration attributes - -### AC-7: Test coverage thresholds pass - -**Given** `make test` is run -**Then** overall coverage ≥ 80%, per-package ≥ 70%, critical packages ≥ 90% - -### AC-8: Linter passes - -**Given** `make lint` is run -**Then** zero linting errors are reported - ---- - -## Testing Strategy - -### Unit tests (primary) - -Each `pkg/*/` package uses the table-driven pattern from CLAUDE.md. After migration, -tests call the typed handler directly: - -```go -func TestHandleKubectlGet(t *testing.T) { - cases := []struct { - name string - args KubectlGetParams - wantErr bool - }{ - {name: "missing resource_type", args: KubectlGetParams{}, wantErr: true}, - {name: "valid get pods", args: KubectlGetParams{ResourceType: "pod", Namespace: "default"}, wantErr: false}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - result, _, err := handleKubectlGet(context.Background(), &mcp.CallToolRequest{}, tc.args) - // assert - }) - } -} -``` - -No need to construct `mcp.CallToolRequest.Params.Arguments` maps in unit tests — -args are passed directly to the handler function. - -### Middleware tests - -`internal/telemetry/middleware_test.go` uses an in-memory transport pair -(`mcp.NewInMemoryTransports()`) to exercise the full request-response cycle -including middleware. - -### Integration / E2E tests - -`test/e2e/helpers_test.go` starts the full server binary and exercises both -transports. These tests are unchanged in scope; only the client-side MCP -type imports are updated. - ---- - -## Appendices - -### A. Technology Choices - -| Component | Choice | Reason | -|-----------|--------|--------| -| MCP SDK | `github.com/modelcontextprotocol/go-sdk` | Official Anthropic/MCP Foundation SDK; long-term support; supports MCP spec 2025-06-18 | -| HTTP transport | `mcp.NewStreamableHTTPHandler` | Implements MCP spec 2025-03-26 streamable HTTP; supersedes legacy SSE | -| Schema generation | `mcp.AddTool[In, Out]` generics | Auto-derives JSON schema from struct tags; eliminates boilerplate | -| Result helpers | `internal/mcputil.TextResult/ErrorResult` | Single source of truth; go-sdk has no built-in equivalents | - -### B. API Mapping Summary - -| mark3labs | go-sdk | -|-----------|--------| -| `server.NewMCPServer(n,v)` | `mcp.NewServer(&mcp.Implementation{Name:n,Version:v}, nil)` | -| `server.NewStdioServer(s).Listen(ctx,in,out)` | `s.Run(ctx, &mcp.StdioTransport{})` | -| `server.NewStreamableHTTPServer(s,opts)` | `mcp.NewStreamableHTTPHandler(func(r)*mcp.Server{return s}, nil)` | -| `mcp.ParseString(req,k,d)` | Struct field with `json` tag | -| `mcp.ParseInt(req,k,d)` | Struct field with `json` tag | -| `mcp.NewTool(name, opts...)` | `&mcp.Tool{Name:"...",Description:"..."}` | -| `s.AddTool(tool, handler)` | `mcp.AddTool(s, tool, typedHandler)` | -| `mcp.NewToolResultText(t)` | `mcputil.TextResult(t)` | -| `mcp.NewToolResultError(t)` | `mcputil.ErrorResult(t)` | -| handler `(req, err)` 2-return | handler `(req, any, err)` 3-return | -| `server.ToolHandlerFunc` | `mcp.ToolHandlerFor[In, Out]` | -| `server.AdaptToolHandler` | `mcp.Middleware` via `AddReceivingMiddleware` | - -### C. Alternative Approaches Considered - -**Keep low-level `server.AddTool` with manual schemas** — rejected. This would -require replicating the existing `mcp.WithString/Bool/Number` boilerplate in a -new form and would not achieve R1 (typed structs). - -**Use `map[string]any` args in handlers** — rejected. Explicitly forbidden by R1 -and is a regression in type safety compared to even the mark3labs API. - -**Introduce a compatibility shim layer** — rejected. A thin adapter keeping the -old signatures would prevent tests from using the cleaner direct-invocation -pattern and would accumulate technical debt. diff --git a/specs/tools/001-migrate-mcp-go-to-official-sdk/plan.md b/specs/tools/001-migrate-mcp-go-to-official-sdk/plan.md deleted file mode 100644 index 23e842ab..00000000 --- a/specs/tools/001-migrate-mcp-go-to-official-sdk/plan.md +++ /dev/null @@ -1,597 +0,0 @@ -# Implementation Plan: mark3labs/mcp-go → modelcontextprotocol/go-sdk - -## Current Code Status - -Last verified: 2026-09-21 from the current repository state. - -This section is the single source of truth for migration progress. The original -step details below remain as historical implementation guidance, but the code no -longer matches the early plan exactly: the repository uses `internal/mcp` as the -SDK adapter/helper package instead of the planned `internal/mcputil` package. - -### Completed - -- [x] Step 1: Dependency is on `github.com/modelcontextprotocol/go-sdk`; no Go - source imports `github.com/mark3labs/mcp-go`. -- [x] Step 2: SDK helper/adaptation layer exists as `internal/mcp`, including - result constructors, typed `AddTool`, schema relaxation, and tool middleware. -- [x] Step 3: `internal/errors.ToolError.Context` is `map[string]string` and - `WithContext` takes `(key, value string)`. Non-string callers converted at the - call site: prometheus `status_code` (int -> decimal string), helm `helm_args` - ([]string -> space-joined). -- [x] Step 4: Per-tool tracing wrappers are gone; server-level MCP middleware is - centralized in `internal/mcp.ToolMiddleware()`. -- [x] Step 5: `pkg/utils` uses the go-sdk path through `internal/mcp`. -- [x] Step 6: `pkg/prometheus` uses the go-sdk path through `internal/mcp`. -- [x] Step 7: `pkg/argo` uses the go-sdk path through `internal/mcp`. -- [x] Step 8: `pkg/cilium` uses the go-sdk path through `internal/mcp`. -- [x] Step 9: `pkg/helm` uses the go-sdk path through `internal/mcp`. -- [x] Step 10: `pkg/istio` uses the go-sdk path through `internal/mcp`. -- [x] Step 11: `pkg/k8s` uses the go-sdk path through `internal/mcp`. -- [x] Step 12: `pkg/kubescape` builds its responses from concrete output structs, - and the tests decode those structs. -- [x] Step 13: `cmd/main.go` creates a go-sdk server, registers provider tools, - attaches MCP receiving middleware, and serves stdio plus Streamable HTTP. -- [x] Step 14: `test/e2e/helpers_test.go` uses the go-sdk client/session APIs. -- [x] Step 15: Final validation passes — see "Latest Verification". -- [x] Step 16: Every handler returns a typed `Out` instead of `any`. Raw CLI text - uses the shared `mcp.TextOutput` wrapper via `mcp.TextResult` / `mcp.TextError` - / `mcp.TextOf`; structured responses return their concrete DTO. `pkg/kubescape` - DTOs gained `omitempty` on map fields, `CheckStatus.Details` is typed as - `[]PodCheckEntry`, and `handleGetConfigurationScan` returns `mcp.TextOutput` - because `v1beta1.WorkloadConfigurationScan` cannot infer an output schema. - `pkg/prometheus` re-indents dynamic JSON with `json.Indent` instead of an - `interface{}` round-trip. No production file registers `Out=any`, and no - production file uses `interface{}` / untyped maps (verified by grep). - -### Still Open - -None. - -### Latest Verification - -Verified 2026-09-21 on `feature/mcp-sdk-migration` (typed-output pass). - -- `go build ./...` and `go vet ./...` pass; `gofmt -l` is clean. -- `make lint` passes with `0 issues` (golangci-lint v2.13.2, pinned in the - Makefile, with `.golangci.yml` for the go 1.27 directive). -- `go test ./pkg/... ./internal/... ./cmd/...` — 19/19 packages PASS, 0 failures. -- Coverage: every `pkg/` package is above the 80% gate (lowest `pkg/kubescape` - at 86.9%). The two internals below 80% (`internal/commands`, `internal/cmd`) - are pre-existing and unchanged by this work. -- `grep -rn "CallToolResult, any, error" pkg/ internal/ cmd/` (excluding tests) - returns nothing — no handler registers `Out=any`. -- `grep -rn "interface{}|map[string]interface{}|[]interface{}|map[string]any|[]any" pkg/ internal/ cmd/` - (excluding tests) returns nothing. -- `grep -rn "interface{}" test/e2e/` returns nothing — the e2e helpers now return - `*mcp.CallToolResult` and `[]*mcp.Tool`. -- `TestEveryToolHasValidOutputSchema` (`cmd/tools_output_schema_test.go`) registers - every provider on an in-memory transport and asserts each advertised tool carries - a JSON-serializable output schema; it passes, which also proves `mcp.AddTool` does - not panic on any `Out` type. -- Tool parity: `TestNoToolNameRegressions` passes, so all 124 names from v0.2.1 - are still advertised. -- e2e: the suite compiles under `-tags=test`; running it needs the Kind cluster - (see the note below), which is not available in this environment. - -Note: the e2e suite needs the repo's kind `extraPortMappings` (30884/30885) and -helm 3. It fails locally on helm 4 (server-side apply rejects the duplicate -`containerPort: 8084` in the chart) and when the host cannot reach the NodePort; -both are environment/chart issues that predate this migration and affect `main` -identically. - ---- - -## Step 1: Swap dependency and establish build baseline - -**Objective:** Replace the mark3labs dependency with the official SDK so every -subsequent step compiles against the new API from the start. - -**Implementation guidance:** -1. In `go.mod`, remove the `github.com/mark3labs/mcp-go` line. -2. Run `go get github.com/modelcontextprotocol/go-sdk@latest`. -3. Run `go mod tidy`. -4. The project will NOT compile at this point — that is expected. Every file - that imports `mark3labs` will report errors. -5. Do NOT fix any files yet — just verify that `go mod` resolves the new SDK. - -**Test requirements:** -- `go mod verify` passes (module graph is consistent). -- `go list -m github.com/modelcontextprotocol/go-sdk` prints the resolved version. - -**Integration notes:** -- No code changes outside `go.mod`/`go.sum` in this step. -- Commit the `go.mod`/`go.sum` change independently for easy bisect. - -**Demo:** `go list -m github.com/modelcontextprotocol/go-sdk` outputs the new version. - ---- - -## Step 2: Create `internal/mcputil` helpers - -**Objective:** Provide `TextResult` and `ErrorResult` helper functions that all -tool packages will use. Having these in place before migrating any package avoids -writing raw `&mcp.CallToolResult{Content: ...}` literals 40+ times. - -**Implementation guidance:** -1. Create `internal/mcputil/mcputil.go`: -```go -package mcputil - -import "github.com/modelcontextprotocol/go-sdk/mcp" - -func TextResult(text string) *mcp.CallToolResult { - return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{Text: text}}, - } -} - -func ErrorResult(msg string) *mcp.CallToolResult { - return &mcp.CallToolResult{ - IsError: true, - Content: []mcp.Content{&mcp.TextContent{Text: msg}}, - } -} -``` -2. Create `internal/mcputil/mcputil_test.go` with table-driven tests covering - both helpers (verify `IsError`, `Content[0].(*mcp.TextContent).Text`). - -**Test requirements:** -- `go test ./internal/mcputil/...` passes with 100% coverage. - -**Integration notes:** -- This package has no dependency on any `pkg/*` or other `internal` packages — - it can be compiled independently even while the rest of the codebase has errors. - -**Demo:** `go test ./internal/mcputil/...` reports PASS. - ---- - -## Step 3: Migrate `internal/errors` — fix `ToolError` - -**Objective:** Fix `ToMCPResult()` which calls `mcp.NewToolResultError` (does not -exist in go-sdk), and replace `map[string]interface{}` with `map[string]string` -in `ToolError.Context`. - -**Implementation guidance:** -1. Update import: remove `mark3labs/mcp-go/mcp`, add `kagent-dev/tools/internal/mcputil`. -2. Change `ToolError.Context` field type: `map[string]interface{}` → `map[string]string`. -3. Update `WithContext(key string, value interface{})` → `WithContext(key, value string)`. -4. Update `NewToolError` constructor: `Context: make(map[string]string)`. -5. Replace `ToMCPResult()` body: `return mcp.NewToolResultError(message.String())` → - `return mcputil.ErrorResult(message.String())`. -6. Update `WithContext` call sites in the same file (all callers pass string values). - -**Test requirements:** -- `go test ./internal/errors/...` passes. -- Existing tests updated to pass string values to `WithContext`. -- Coverage ≥ 70%. - -**Integration notes:** -- `internal/errors` depends only on `internal/mcputil` (already done in Step 2). -- `pkg/*` packages that call `WithContext` will need their call sites updated when - each package is migrated (Steps 5–12) — not required here. - -**Demo:** `go test ./internal/errors/... ./internal/mcputil/...` reports PASS. - ---- - -## Step 4: Migrate `internal/telemetry` — rewrite to `mcp.Middleware` - -**Objective:** Remove the per-handler `WithTracing` wrapper and the `AdaptToolHandler` -adapter. Replace with a single server-level `NewTracingMiddleware()` factory that -returns an `mcp.Middleware` and intercepts all tool calls. - -**Implementation guidance:** -1. In `middleware.go`: - - Remove `import "github.com/mark3labs/mcp-go/server"`. - - Change import to `"github.com/modelcontextprotocol/go-sdk/mcp"`. - - Delete type `ToolHandler`. - - Delete functions `WithTracing` and `AdaptToolHandler`. - - Add: -```go -// NewTracingMiddleware returns an mcp.Middleware that records an OTel span -// for every MCP method call, with richer attributes for tools/call. -func NewTracingMiddleware() mcp.Middleware { - return func(next mcp.MethodHandler) mcp.MethodHandler { - return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { - tracer := otel.Tracer("kagent-tools/mcp") - spanName := fmt.Sprintf("mcp.method.%s", method) - - // Enrich span name and attributes for tool calls - toolName := "" - if ctr, ok := req.(*mcp.CallToolRequest); ok { - toolName = ctr.Params.Name - spanName = fmt.Sprintf("mcp.tool.%s", toolName) - } - - ctx, span := tracer.Start(ctx, spanName) - defer span.End() - - headers := ExtractHTTPHeaders(ctx) - for k, v := range headers { - span.SetAttributes(attribute.String(fmt.Sprintf("http.header.%s", k), v)) - } - if toolName != "" { - span.SetAttributes(attribute.String("mcp.tool.name", toolName)) - } - span.AddEvent("mcp.method.start") - start := time.Now() - - result, err := next(ctx, method, req) - - span.SetAttributes(attribute.Float64("mcp.tool.duration_seconds", time.Since(start).Seconds())) - if err != nil { - span.RecordError(err) - span.SetStatus(codes.Error, err.Error()) - } else { - span.SetStatus(codes.Ok, "completed") - if ctr, ok := result.(*mcp.CallToolResult); ok { - span.SetAttributes(attribute.Bool("mcp.result.is_error", ctr.IsError)) - span.SetAttributes(attribute.Int("mcp.result.content_count", len(ctr.Content))) - } - } - return result, err - } - } -} -``` - -2. Update `middleware_test.go`: - - Remove test for `WithTracing` and `AdaptToolHandler`. - - Add test for `NewTracingMiddleware` using `mcp.NewInMemoryTransports()` to - create a real client-server pair with middleware applied; verify span is recorded. - -**Test requirements:** -- `go test ./internal/telemetry/...` passes. -- Coverage ≥ 70%. -- `WithTracing` and `AdaptToolHandler` are not referenced anywhere. - -**Integration notes:** -- `cmd/main.go` will call `mcpServer.AddReceivingMiddleware(telemetry.NewTracingMiddleware())` - in Step 13. -- Until Step 13, `NewTracingMiddleware` is defined but not yet wired. - -**Demo:** `go test ./internal/telemetry/...` reports PASS. - ---- - -## Step 5: Migrate `pkg/utils` - -**Objective:** Update `pkg/utils/common.go` (and any related files) to use -go-sdk types. `pkg/utils` is a leaf package with no dependencies on other `pkg/*` -packages, making it the safest starting point. - -**Implementation guidance:** -1. Replace imports: remove `mark3labs/mcp-go/mcp` and `mark3labs/mcp-go/server`, - add `modelcontextprotocol/go-sdk/mcp` and `kagent-dev/tools/internal/mcputil`. -2. For each tool handler: - - Define a `Params` struct with `json` and `jsonschema` tags. - - Change handler signature to `func(ctx, *mcp.CallToolRequest, Params) (*mcp.CallToolResult, any, error)`. - - Replace `mcp.ParseString(request, ...)` with struct field access. - - Replace `mcp.NewToolResultText(...)` with `mcputil.TextResult(...)`. - - Replace `mcp.NewToolResultError(...)` with `mcputil.ErrorResult(...)`. -3. Update `RegisterTools` signature: `func RegisterTools(s *mcp.Server, readOnly bool)`. -4. Replace `s.AddTool(mcp.NewTool(...), handler)` with `mcp.AddTool(s, &mcp.Tool{...}, handler)`. -5. Update `*_test.go`: call handlers directly with typed args structs. - -**Test requirements:** -- `go test ./pkg/utils/...` passes. -- Coverage ≥ 70%. -- No references to `mark3labs` in package. - -**Demo:** `go test ./pkg/utils/...` PASS. - ---- - -## Step 6: Migrate `pkg/prometheus` - -**Objective:** Migrate the Prometheus query tools. This package also includes -`promql.go` which uses MCP types for result construction. - -**Implementation guidance:** -1. Same handler migration pattern as Step 5. -2. Key params structs to define: - - `PrometheusQueryParams` (query string, time range, step) - - `PrometheusQueryRangeParams` - - `PrometheusInstantQueryParams` -3. Update `promql.go` if it constructs `mcp.CallToolResult` directly — replace - with `mcputil.TextResult` / `mcputil.ErrorResult`. -4. Update `prometheus_test.go` to use typed args. - -**Test requirements:** -- `go test ./pkg/prometheus/...` passes. -- Coverage ≥ 70%. - -**Demo:** `go test ./pkg/prometheus/...` PASS. - ---- - -## Step 7: Migrate `pkg/argo` - -**Objective:** Migrate the 8 Argo Rollouts tool handlers. - -**Implementation guidance:** -1. Define params structs: - - `VerifyArgoControllerParams` (namespace, label) - - `VerifyKubectlPluginParams` (no params — empty struct `struct{}`) - - `ListRolloutsParams` (namespace, type) - - `CheckPluginLogsParams` (namespace, timeout) - - `PromoteRolloutParams` (rollout_name, namespace, full bool) - - `PauseRolloutParams` (rollout_name, namespace) - - `SetRolloutImageParams` (rollout_name, container_image, namespace) - - `VerifyGatewayPluginParams` (version, namespace, should_install bool) -2. For handlers with no parameters (e.g., `handleVerifyKubectlPluginInstall`), - use an empty struct: `type VerifyKubectlPluginParams struct{}`. -3. Remove `WithTracing` wrapping from `RegisterTools` — tracing is now server-wide. -4. Update `argo_test.go`. - -**Test requirements:** -- `go test ./pkg/argo/...` passes. -- Coverage ≥ 90% (critical package per CLAUDE.md). - -**Demo:** `go test ./pkg/argo/...` PASS with ≥ 90% coverage shown. - ---- - -## Step 8: Migrate `pkg/cilium` - -**Objective:** Migrate the 12 Cilium tool handlers. - -**Implementation guidance:** -1. Define params structs for each handler: - - `CiliumStatusParams` — empty struct - - `UpgradeCiliumParams` (cluster_name, datapath_mode) - - `InstallCiliumParams` (cluster_name, cluster_id, datapath_mode) - - `UninstallCiliumParams` — empty struct - - `ConnectRemoteClusterParams` (cluster_name required, context) - - `DisconnectRemoteClusterParams` (cluster_name required) - - `ListBGPPeersParams` — empty struct - - `ListBGPRoutesParams` — empty struct - - `ClusterMeshStatusParams` — empty struct - - `FeaturesStatusParams` — empty struct - - `ToggleHubbleParams` (enable bool, default=true) - - `ToggleClusterMeshParams` (enable bool, default=true) -2. For boolean-toggle handlers, note that bool default in jsonschema tag must be - specified: `jsonschema:"enable Hubble,default=true"`. -3. Update `cilium_test.go`. - -**Test requirements:** -- `go test ./pkg/cilium/...` passes. -- Coverage ≥ 90%. - -**Demo:** `go test ./pkg/cilium/...` PASS. - ---- - -## Step 9: Migrate `pkg/helm` - -**Objective:** Migrate the 6 Helm tool handlers. - -**Implementation guidance:** -1. Define params structs: - - `HelmListParams` (namespace, all_namespaces, all, uninstalled, failed, deployed, pending, filter, output) - - `HelmGetReleaseParams` (name required, namespace required, output) - - `HelmUpgradeParams` (name required, chart required, namespace, version, values_file, set, wait bool, timeout, create_namespace bool, install bool) - - `HelmUninstallParams` (name required, namespace required, keep_history bool) - - `HelmRepoAddParams` (name required, url required, username, password, force_update bool) - - `HelmRepoUpdateParams` — empty struct -2. Update `helm_test.go`. -3. Verify security validation calls (`security.ValidateName`, etc.) still occur - after struct population — these are business-logic checks that remain. - -**Test requirements:** -- `go test ./pkg/helm/...` passes. -- Coverage ≥ 90%. - -**Demo:** `go test ./pkg/helm/...` PASS. - ---- - -## Step 10: Migrate `pkg/istio` - -**Objective:** Migrate all Istio tool handlers. - -**Implementation guidance:** -1. Define params structs for each istio handler (proxy-status, analyze, install, - upgrade, verify-install, etc.) — follow the same struct pattern. -2. Update `istio_test.go`. - -**Test requirements:** -- `go test ./pkg/istio/...` passes. -- Coverage ≥ 90%. - -**Demo:** `go test ./pkg/istio/...` PASS. - ---- - -## Step 11: Migrate `pkg/k8s` - -**Objective:** Migrate the largest and most critical package — all kubectl-based -Kubernetes tool handlers. - -**Implementation guidance:** -1. Define params structs for all handlers: - - `KubectlGetParams`, `KubectlLogsParams`, `ScaleDeploymentParams`, - `PatchResourceParams`, `ApplyManifestParams`, `DeleteResourceParams`, - `CheckServiceConnectivityParams`, `GetEventsParams`, `ExecCommandParams`, - `GetAvailableAPIResourcesParams`, `DescribeResourceParams`, - `ManageAnnotationParams`, `ManageLabelParams`, `SetAnnotationsParams`, - and any others present. -2. Required fields identified from current `if param == "" { return error }` guards: - use `jsonschema:"...,required"` for these, then remove the redundant guard. -3. Keep non-trivial business-logic guards (e.g., security validation). -4. Update `k8s_test.go` — this is the largest test file; use table-driven tests - for all params variations. - -**Test requirements:** -- `go test ./pkg/k8s/...` passes. -- Coverage ≥ 90%. - -**Demo:** `go test ./pkg/k8s/...` PASS with ≥ 90% coverage. - ---- - -## Step 12: Migrate `pkg/kubescape` - -**Objective:** Migrate all Kubescape scan and report tool handlers. - -**Implementation guidance:** -1. Define params structs for each handler (scan, get vulnerability manifests, - get configuration scans, get application profiles, etc.). -2. Update `kubescape_test.go`. - -**Test requirements:** -- `go test ./pkg/kubescape/...` passes. -- Coverage ≥ 90%. - -**Demo:** `go test ./pkg/kubescape/...` PASS. - ---- - -## Step 13: Migrate `cmd/main.go` — wire everything together - -**Objective:** Update the entry point to use the new SDK server, transports, -and middleware. This is the integration step that makes the full binary compile -and run end-to-end. - -**Implementation guidance:** -1. Remove `import "github.com/mark3labs/mcp-go/server"`. -2. Add `import "github.com/modelcontextprotocol/go-sdk/mcp"`. -3. Replace server creation: -```go -mcpServer := mcp.NewServer(&mcp.Implementation{ - Name: Name, - Version: Version, -}, nil) -``` -4. Add telemetry middleware: -```go -mcpServer.AddReceivingMiddleware(telemetry.NewTracingMiddleware()) -``` -5. Update `toolProviderMap` type: `map[string]func(*mcp.Server)`. -6. Replace `runStdioServer`: -```go -func runStdioServer(ctx context.Context, s *mcp.Server) { - logger.Get().Info("Running KAgent Tools Server STDIO:", "tools", strings.Join(tools, ",")) - if err := s.Run(ctx, &mcp.StdioTransport{}); err != nil { - logger.Get().Info("Stdio server stopped", "error", err) - } -} -``` -7. Replace HTTP server setup: -```go -httpHandler := mcp.NewStreamableHTTPHandler( - func(r *http.Request) *mcp.Server { return mcpServer }, - nil, -) -mux.Handle("/", telemetry.HTTPMiddleware(http.HandlerFunc( - func(w http.ResponseWriter, r *http.Request) { - httpHandler.ServeHTTP(w, r) - }, -))) -``` -8. Remove the `server.WithHeartbeatInterval` option (no equivalent in go-sdk - StreamableHTTPHandler; rely on HTTP keep-alive). -9. Verify `registerMCP(mcpServer, ...)` compiles with `*mcp.Server` argument. - -**Test requirements:** -- `go build ./cmd/...` succeeds with zero errors. -- `go run ./cmd -- --stdio` starts and responds to `tools/list`. -- `make lint` passes. - -**Integration notes:** -- This is the first step where `grep -r "mark3labs" .` should return zero results. - -**Demo:** -```bash -echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | go run ./cmd -- --stdio -``` -Returns a JSON response listing all tools. - ---- - -## Step 14: Update E2E test helpers - -**Objective:** Update `test/e2e/helpers_test.go` to use go-sdk client types for -integration test scaffolding. - -**Implementation guidance:** -1. Replace `mark3labs` client types with go-sdk equivalents: -```go -// Before: mark3labs client construction -// After: -client := mcp.NewClient(&mcp.Implementation{Name: "test-client"}, nil) -transport := &mcp.CommandTransport{Command: exec.Command("./bin/kagent-tools", "--stdio")} -session, err := client.Connect(ctx, transport, nil) -``` -2. Update tool invocations: -```go -res, err := session.CallTool(ctx, &mcp.CallToolParams{ - Name: "kubectl_get", - Arguments: map[string]any{"resource_type": "pod"}, -}) -``` -3. Replace result assertions: -```go -// Check IsError flag -if res.IsError { t.Fatalf(...) } -text := res.Content[0].(*mcp.TextContent).Text -``` - -**Test requirements:** -- `go test ./test/e2e/...` passes (or is skipped gracefully when cluster unavailable). - -**Demo:** `go test ./test/e2e/... -run TestToolsList` PASS. - ---- - -## Step 15: Final validation - -**Objective:** Confirm all quality gates pass, no mark3labs references remain, -and the binary behaves identically to before migration. - -**Implementation guidance:** -1. Run full test suite: -```bash -make test -``` -2. Verify zero mark3labs references: -```bash -grep -r "mark3labs" . --include="*.go" --include="go.mod" -# must return: no output -``` -3. Verify no `map[any]any` or `map[string]interface{}` in tool params: -```bash -grep -r "map\[string\]interface{}\|map\[string\]any\|map\[any\]" pkg/ internal/ --include="*.go" -# must return: no output -``` -4. Run linter: -```bash -make lint -``` -5. Build all platform binaries: -```bash -make build -``` -6. Smoke test both transports: -```bash -# Stdio -echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' \ - | ./bin/kagent-tools --stdio - -# HTTP -./bin/kagent-tools --port 8085 & -sleep 1 -curl -s http://localhost:8085/health -kill %1 -``` - -**Test requirements:** -- `make test` exits 0. -- `make lint` exits 0. -- `make build` exits 0. -- Both smoke tests return expected responses. -- `grep -r "mark3labs" .` returns no matches. - -**Demo:** CI pipeline passes (or equivalent local `make test && make lint && make build`). diff --git a/specs/tools/001-migrate-mcp-go-to-official-sdk/requirements.md b/specs/tools/001-migrate-mcp-go-to-official-sdk/requirements.md deleted file mode 100644 index 1d116695..00000000 --- a/specs/tools/001-migrate-mcp-go-to-official-sdk/requirements.md +++ /dev/null @@ -1,25 +0,0 @@ -# Requirements Q&A - -> This file captures requirements clarification questions and answers gathered during the PDD process. -> Questions and answers are appended in real time. - ---- - -## Q1: What type safety requirements apply to the SDK migration? - -**Q:** Should the migration use any dynamic/generic map types (e.g. `map[string]any`, `map[any]any`) for tool parameters or results, or should concrete Go struct types be used? - -**A:** Use Go struct types throughout. Avoid `map[any]any` and prefer typed structs for all tool parameters, inputs, and outputs. This applies to parameter parsing, result construction, and any intermediate data structures introduced during the migration. - ---- - -## Research findings appended - -See `research/sdk-comparison.md` and `skill.md` for the full API mapping. - -Key confirmed facts from official SDK examples and pkg.go.dev: -- `mcp.AddTool` is a generic function that auto-derives JSON schema from the typed `In` param struct. -- `ToolHandlerFor[In, Out any]` signature returns `(*CallToolResult, any, error)` — three values. -- No `NewToolResultText` / `NewToolResultError` helpers — must construct `CallToolResult` directly or add local helpers. -- Middleware uses `AddReceivingMiddleware` with `mcp.MethodHandler` / `mcp.Middleware` types. -- `ToolError.Context` field (`map[string]interface{}`) violates no-map-any-any rule and must be replaced. diff --git a/specs/tools/001-migrate-mcp-go-to-official-sdk/research/sdk-comparison.md b/specs/tools/001-migrate-mcp-go-to-official-sdk/research/sdk-comparison.md deleted file mode 100644 index c45ce756..00000000 --- a/specs/tools/001-migrate-mcp-go-to-official-sdk/research/sdk-comparison.md +++ /dev/null @@ -1,222 +0,0 @@ -# SDK Comparison: mark3labs/mcp-go vs modelcontextprotocol/go-sdk - -## Sources -- https://github.com/modelcontextprotocol/go-sdk -- https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp -- https://github.com/modelcontextprotocol/go-sdk/tree/main/examples - ---- - -## Current dependency (mark3labs/mcp-go v0.43.2) - -### Imports used in this project -``` -"github.com/mark3labs/mcp-go/mcp" -"github.com/mark3labs/mcp-go/server" -``` - -### Server lifecycle -```go -// Create server -mcpServer := server.NewMCPServer(name, version) - -// Stdio mode -stdioServer := server.NewStdioServer(mcpServer) -stdioServer.Listen(ctx, os.Stdin, os.Stdout) - -// HTTP/SSE mode -sseServer := server.NewStreamableHTTPServer(mcpServer, - server.WithHeartbeatInterval(30*time.Second), -) -sseServer.ServeHTTP(w, r) -``` - -### Tool definition & registration -```go -// Define tool with option-function pattern -tool := mcp.NewTool("tool_name", - mcp.WithDescription("description"), - mcp.WithString("param", - mcp.Required(), - mcp.Description("param description"), - ), - mcp.WithBoolean("flag", - mcp.Description("flag description"), - ), - mcp.WithNumber("count", - mcp.Description("count description"), - ), -) -// Register on server -mcpServer.AddTool(tool, handler) -``` - -### Handler signature -```go -type ToolHandlerFunc func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) -// Note: CallToolRequest is a value type (not pointer) in mark3labs -``` - -### Parameter parsing -```go -// String with default -val := mcp.ParseString(request, "param_name", "default") -// Int with default -count := mcp.ParseInt(request, "count", 50) -// Bool equivalent (parsed as string) -flag := mcp.ParseString(request, "flag", "") == "true" -``` - -### Result construction -```go -// Success -return mcp.NewToolResultText("output text"), nil -// Error (tool-level, not protocol error) -return mcp.NewToolResultError("error message"), nil -``` - -### Middleware / telemetry adapter -```go -// Adapter wraps a typed ToolHandler into server.ToolHandlerFunc -func AdaptToolHandler(th ToolHandler) server.ToolHandlerFunc { - return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return th(ctx, req) - } -} -``` - -### request.Params access (used in telemetry) -```go -request.Params.Name // tool name string -request.Params.Arguments // map[string]interface{} or nil -``` - ---- - -## Target dependency (modelcontextprotocol/go-sdk, latest) - -### Import -```go -"github.com/modelcontextprotocol/go-sdk/mcp" -``` - -### Server lifecycle -```go -// Create server -server := mcp.NewServer(&mcp.Implementation{Name: "name", Version: "v1.0"}, nil) - -// Stdio mode (blocks until client disconnects) -server.Run(ctx, &mcp.StdioTransport{}) - -// HTTP/SSE mode (legacy SSE, spec 2024-11-05) -handler := mcp.NewSSEHandler(func(r *http.Request) *mcp.Server { - return server -}, nil) -http.ListenAndServe(addr, handler) - -// HTTP Streamable mode (spec 2025-03-26+) -handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { - return server -}, nil) -http.ListenAndServe(addr, handler) -``` - -### Tool definition & registration (typed — PREFERRED) -```go -// Define typed params struct -type MyToolParams struct { - Param string `json:"param" jsonschema:"description of param,required"` - Flag bool `json:"flag" jsonschema:"flag description"` - Count int `json:"count" jsonschema:"count description"` -} - -// Register — schema auto-derived from struct tags -mcp.AddTool(server, &mcp.Tool{ - Name: "tool_name", - Description: "description", -}, func(ctx context.Context, req *mcp.CallToolRequest, args MyToolParams) (*mcp.CallToolResult, any, error) { - // args.Param, args.Flag, args.Count are already populated and validated - return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{Text: "output"}}, - }, nil, nil -}) -``` - -### Tool definition & registration (low-level — avoid if possible) -```go -// Low-level: handler receives raw CallToolRequest, no auto-validation -server.AddTool(&mcp.Tool{ - Name: "tool_name", - Description: "description", - InputSchema: &jsonschema.Schema{ /* ... */ }, -}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - // manual parsing required - return &mcp.CallToolResult{...}, nil -}) -``` - -### Handler signatures -```go -// Typed (preferred) — ToolHandlerFor[In, Out any] -func(ctx context.Context, req *mcp.CallToolRequest, args MyParams) (*mcp.CallToolResult, any, error) - -// Low-level — ToolHandler -func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) -``` - -### Result construction -```go -// Success -return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{Text: "output text"}}, -}, nil, nil - -// Tool-level error (IsError=true, not a protocol error) -return &mcp.CallToolResult{ - IsError: true, - Content: []mcp.Content{&mcp.TextContent{Text: "error message"}}, -}, nil, nil - -// Protocol-level error (returns as Go error) -return nil, nil, fmt.Errorf("protocol error: %w", err) -``` - -### Middleware -```go -type MethodHandler func(ctx context.Context, method string, req Request) (Result, error) -type Middleware func(next MethodHandler) MethodHandler - -server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { - return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { - // pre-processing - result, err := next(ctx, method, req) - // post-processing - return result, err - } -}) - -// Access tool info inside middleware: -if ctr, ok := req.(*mcp.CallToolRequest); ok { - _ = ctr.Params.Name // tool name - _ = ctr.Params.Arguments // json.RawMessage -} -// Access tool result in middleware: -if ctr, ok := result.(*mcp.CallToolResult); ok { - _ = ctr.IsError - _ = ctr.StructuredContent -} -``` - -### Key types -```go -mcp.Implementation{Name string; Version string} -mcp.ServerOptions{} -mcp.Tool{Name string; Description string; InputSchema *jsonschema.Schema; OutputSchema *jsonschema.Schema} -mcp.CallToolRequest // = ServerRequest[*CallToolParamsRaw] -mcp.CallToolResult{Content []Content; IsError bool; StructuredContent any} -mcp.Content // interface -mcp.TextContent{Text string; Meta Meta; Annotations *Annotations} -mcp.StdioTransport{} -mcp.SSEHandler // http.Handler for SSE -mcp.StreamableHTTPHandler // http.Handler for streamable HTTP -``` diff --git a/specs/tools/001-migrate-mcp-go-to-official-sdk/rough-idea.md b/specs/tools/001-migrate-mcp-go-to-official-sdk/rough-idea.md deleted file mode 100644 index 54d29f75..00000000 --- a/specs/tools/001-migrate-mcp-go-to-official-sdk/rough-idea.md +++ /dev/null @@ -1,20 +0,0 @@ -# Rough Idea - -## Summary - -Migrate `github.com/mark3labs/mcp-go` to the official MCP Go SDK at `https://github.com/modelcontextprotocol/go-sdk`. - -## Context - -The project currently depends on the community-maintained MCP Go SDK (`github.com/mark3labs/mcp-go v0.43.2`). The official MCP Go SDK has been released at `github.com/modelcontextprotocol/go-sdk`. The migration should ensure all existing functionality is preserved while adopting the officially-supported library. - -## Current State - -- **Dependency**: `github.com/mark3labs/mcp-go v0.43.2` -- **Usage**: Tool registration, MCP server setup, transport handling (stdio, HTTP/SSE), tool result types -- **Files affected**: `cmd/main.go`, all `pkg/*/` tool packages -- **CLAUDE.md** already references `github.com/modelcontextprotocol/go-sdk` as the active technology - -## Goal - -Replace all usage of `github.com/mark3labs/mcp-go` with `github.com/modelcontextprotocol/go-sdk` across the codebase, maintaining full feature parity and test coverage requirements. diff --git a/specs/tools/001-migrate-mcp-go-to-official-sdk/skill.md b/specs/tools/001-migrate-mcp-go-to-official-sdk/skill.md deleted file mode 100644 index d5738ffc..00000000 --- a/specs/tools/001-migrate-mcp-go-to-official-sdk/skill.md +++ /dev/null @@ -1,442 +0,0 @@ -# Migration Skill: mark3labs/mcp-go → modelcontextprotocol/go-sdk - -> Reference document for migrating `github.com/mark3labs/mcp-go` to the official -> `github.com/modelcontextprotocol/go-sdk`. Use this as the authoritative lookup -> during implementation. All patterns use concrete Go struct types — no `map[any]any`. - ---- - -## 1. Dependency Change - -```diff -# go.mod -- github.com/mark3labs/mcp-go v0.43.2 -+ github.com/modelcontextprotocol/go-sdk -``` - -```bash -go get github.com/modelcontextprotocol/go-sdk@latest -go mod tidy -``` - ---- - -## 2. Import Paths - -| mark3labs | go-sdk | -|-----------|--------| -| `"github.com/mark3labs/mcp-go/mcp"` | `"github.com/modelcontextprotocol/go-sdk/mcp"` | -| `"github.com/mark3labs/mcp-go/server"` | _(removed — all under `mcp` package)_ | - ---- - -## 3. Server Creation - -### mark3labs -```go -import "github.com/mark3labs/mcp-go/server" - -mcpServer := server.NewMCPServer(Name, Version) -``` - -### go-sdk -```go -import "github.com/modelcontextprotocol/go-sdk/mcp" - -mcpServer := mcp.NewServer(&mcp.Implementation{ - Name: Name, - Version: Version, -}, nil) -``` - ---- - -## 4. Tool Handler Signature - -This is the most impactful change. Replace dynamic parsing with typed structs. - -### mark3labs -```go -func handleMyTool(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - param := mcp.ParseString(request, "param_name", "") - count := mcp.ParseInt(request, "count", 50) - flag := mcp.ParseString(request, "flag", "") == "true" - // ... -} -``` - -### go-sdk (REQUIRED pattern — typed structs, no map[any]any) -```go -// 1. Define a params struct for every tool -type MyToolParams struct { - ParamName string `json:"param_name" jsonschema:"description of param"` - Count int `json:"count" jsonschema:"number of lines,default=50"` - Flag bool `json:"flag" jsonschema:"enable flag"` -} - -// 2. Handler receives populated, validated struct directly -func handleMyTool(ctx context.Context, req *mcp.CallToolRequest, args MyToolParams) (*mcp.CallToolResult, any, error) { - // args.ParamName, args.Count, args.Flag are already set - // ... -} -``` - -**Key rules:** -- Every tool MUST have a dedicated params struct. -- Fields validated as `required` in jsonschema will return a tool error automatically. -- Handler returns THREE values: `(*mcp.CallToolResult, any, error)` — the middle `any` is the structured output (return `nil` if unused). -- `req` is a pointer (`*mcp.CallToolRequest`), not a value. - ---- - -## 5. Tool Definition & Registration - -### mark3labs -```go -tool := mcp.NewTool("tool_name", - mcp.WithDescription("description"), - mcp.WithString("param", mcp.Required(), mcp.Description("...")), - mcp.WithBoolean("flag", mcp.Description("...")), - mcp.WithNumber("count", mcp.Description("...")), -) -mcpServer.AddTool(tool, handler) -``` - -### go-sdk -```go -// Schema is auto-derived from the params struct — no need to list params manually. -mcp.AddTool(mcpServer, &mcp.Tool{ - Name: "tool_name", - Description: "description", -}, handleMyTool) -``` - -**Struct tags that drive schema generation:** - -| Tag | Purpose | -|-----|---------| -| `json:"field_name"` | JSON key name (required) | -| `jsonschema:"description text"` | Field description shown in schema | -| `jsonschema:"description,required"` | Mark field as required | -| `jsonschema:"description,default=value"` | Provide default value | - ---- - -## 6. Result Construction - -### mark3labs → go-sdk - -| Scenario | mark3labs | go-sdk | -|----------|-----------|--------| -| **Success** | `mcp.NewToolResultText("text")` | `&mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "text"}}}` | -| **Tool error** | `mcp.NewToolResultError("msg")` | `&mcp.CallToolResult{IsError: true, Content: []mcp.Content{&mcp.TextContent{Text: "msg"}}}` | -| **Protocol error** | `return nil, fmt.Errorf("...")` | `return nil, nil, fmt.Errorf("...")` | - -### Helper functions to define (add to `pkg/utils/` or `internal/mcputil/`) - -Since the official SDK has no `NewToolResultText`/`NewToolResultError` helpers, -define these once and reuse: - -```go -package mcputil - -import "github.com/modelcontextprotocol/go-sdk/mcp" - -func TextResult(text string) *mcp.CallToolResult { - return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{Text: text}}, - } -} - -func ErrorResult(msg string) *mcp.CallToolResult { - return &mcp.CallToolResult{ - IsError: true, - Content: []mcp.Content{&mcp.TextContent{Text: msg}}, - } -} -``` - ---- - -## 7. Transport / Server Startup - -### Stdio transport - -#### mark3labs -```go -stdioServer := server.NewStdioServer(mcpServer) -stdioServer.Listen(ctx, os.Stdin, os.Stdout) -``` - -#### go-sdk -```go -// Run blocks until client disconnects or ctx is cancelled -if err := mcpServer.Run(ctx, &mcp.StdioTransport{}); err != nil { - logger.Get().Info("Stdio server stopped", "error", err) -} -``` - -### HTTP/SSE transport - -#### mark3labs -```go -sseServer := server.NewStreamableHTTPServer(mcpServer, - server.WithHeartbeatInterval(30*time.Second), -) -mux.Handle("/", sseServer) -``` - -#### go-sdk -```go -// StreamableHTTPHandler (MCP spec 2025-03-26+) -handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { - return mcpServer -}, nil) -mux.Handle("/", handler) - -// OR legacy SSEHandler (MCP spec 2024-11-05) -handler := mcp.NewSSEHandler(func(r *http.Request) *mcp.Server { - return mcpServer -}, nil) -mux.Handle("/", handler) -``` - -> **Note:** `WithHeartbeatInterval` has no direct equivalent — check -> `StreamableHTTPOptions` for any keepalive options in the installed version. - ---- - -## 8. Middleware / Telemetry - -The telemetry `WithTracing` wrapper currently adapts `ToolHandler` → `server.ToolHandlerFunc`. -With go-sdk, use `AddReceivingMiddleware` instead. - -### go-sdk middleware signature -```go -type MethodHandler func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) -type Middleware func(next mcp.MethodHandler) mcp.MethodHandler - -mcpServer.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { - return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { - // Intercept tool calls - if ctr, ok := req.(*mcp.CallToolRequest); ok { - toolName := ctr.Params.Name - _ = toolName // use for spans - } - result, err := next(ctx, method, req) - // Inspect tool results - if ctr, ok := result.(*mcp.CallToolResult); ok { - _ = ctr.IsError - } - return result, err - } -}) -``` - -### Migrating `internal/telemetry/middleware.go` - -1. Remove `ToolHandler` type alias (no longer needed). -2. Remove `AdaptToolHandler` function. -3. Expose a `NewTracingMiddleware(tracer) mcp.Middleware` function instead. -4. The `WithTracing(toolName, handler)` wrapper pattern is replaced by a single - server-level middleware that extracts tool name from `req.(*mcp.CallToolRequest).Params.Name`. - -### Accessing request context in middleware -```go -// Tool name -ctr.Params.Name - -// Arguments (json.RawMessage, not map — use json.Unmarshal to read) -ctr.Params.Arguments - -// Session ID -req.GetSession().ID() -``` - ---- - -## 9. internal/errors/tool_errors.go - -`ToMCPResult()` calls `mcp.NewToolResultError(...)` which does not exist in go-sdk. - -### Fix -```go -// Before (mark3labs) -return mcp.NewToolResultError(message.String()) - -// After (go-sdk) -return &mcp.CallToolResult{ - IsError: true, - Content: []mcp.Content{&mcp.TextContent{Text: message.String()}}, -} -``` - -Also replace `map[string]interface{}` in `ToolError.Context` with a concrete struct -or `map[string]string` to honour the "no map[any]any" requirement. - ---- - -## 10. RegisterTools Function Signature - -All `pkg/*/` packages export a `RegisterTools` function. Signature changes from: - -```go -// mark3labs -func RegisterTools(s *server.MCPServer, readOnly bool) -``` - -to: - -```go -// go-sdk -func RegisterTools(s *mcp.Server, readOnly bool) -``` - -`cmd/main.go` `registerMCP` function and its `toolProviderMap` closures update accordingly: - -```go -// Before -toolProviderMap := map[string]func(*server.MCPServer){...} - -// After -toolProviderMap := map[string]func(*mcp.Server){...} -``` - ---- - -## 11. Params Struct Reference (per package) - -Define one `*Params` struct per tool handler. Name it `Params`. - -### Example: k8s package - -```go -// kubectl_get -type KubectlGetParams struct { - ResourceType string `json:"resource_type" jsonschema:"type of K8s resource (pod/deploy/svc..),required"` - ResourceName string `json:"resource_name" jsonschema:"name of the resource"` - Namespace string `json:"namespace" jsonschema:"namespace to query"` - AllNamespaces bool `json:"all_namespaces" jsonschema:"query all namespaces"` - Output string `json:"output" jsonschema:"output format (wide/json/yaml),default=wide"` -} - -// kubectl_logs -type KubectlLogsParams struct { - PodName string `json:"pod_name" jsonschema:"name of the pod,required"` - Namespace string `json:"namespace" jsonschema:"namespace,default=default"` - Container string `json:"container" jsonschema:"container name"` - TailLines int `json:"tail_lines" jsonschema:"number of log lines,default=50"` -} - -// scale_deployment -type ScaleDeploymentParams struct { - Name string `json:"name" jsonschema:"deployment name,required"` - Namespace string `json:"namespace" jsonschema:"namespace,default=default"` - Replicas int `json:"replicas" jsonschema:"desired replica count,default=1"` -} -``` - -### Example: helm package - -```go -type HelmListParams struct { - Namespace string `json:"namespace" jsonschema:"filter by namespace"` - AllNamespaces bool `json:"all_namespaces" jsonschema:"list across all namespaces"` - All bool `json:"all" jsonschema:"show all releases"` - Uninstalled bool `json:"uninstalled" jsonschema:"show uninstalled releases"` - Failed bool `json:"failed" jsonschema:"show failed releases"` - Deployed bool `json:"deployed" jsonschema:"show deployed releases"` - Pending bool `json:"pending" jsonschema:"show pending releases"` - Filter string `json:"filter" jsonschema:"regex filter for release names"` - Output string `json:"output" jsonschema:"output format"` -} -``` - ---- - -## 12. Test Migration - -Tests using mark3labs types must be updated: - -```go -// Before (mark3labs) -req := mcp.CallToolRequest{} -req.Params.Arguments = map[string]interface{}{"param": "value"} - -// After (go-sdk — construct the typed params struct directly in tests) -args := MyToolParams{ParamName: "value", Count: 10} -// Call handler directly with args, bypassing request parsing: -result, _, err := handleMyTool(ctx, &mcp.CallToolRequest{}, args) -``` - -For mock-based tests in `pkg/*/`, inject args directly into the typed handler — -no need to construct `CallToolRequest` params at all for unit tests. - ---- - -## 13. Files to Modify (complete list) - -| File | Change | -|------|--------| -| `go.mod` / `go.sum` | Replace dependency | -| `cmd/main.go` | Server creation, transports, `registerMCP` signature | -| `internal/telemetry/middleware.go` | Replace `ToolHandler` type, remove `AdaptToolHandler`, add `mcp.Middleware` factory | -| `internal/telemetry/middleware_test.go` | Update test types | -| `internal/errors/tool_errors.go` | Fix `ToMCPResult()`, fix `Context` map type | -| `pkg/k8s/k8s.go` | Params structs, handler signatures, registration | -| `pkg/k8s/k8s_test.go` | Update test helpers | -| `pkg/helm/helm.go` | Params structs, handler signatures, registration | -| `pkg/helm/helm_test.go` | Update test helpers | -| `pkg/istio/istio.go` | Params structs, handler signatures, registration | -| `pkg/istio/istio_test.go` | Update test helpers | -| `pkg/argo/argo.go` | Params structs, handler signatures, registration | -| `pkg/argo/argo_test.go` | Update test helpers | -| `pkg/cilium/cilium.go` | Params structs, handler signatures, registration | -| `pkg/cilium/cilium_test.go` | Update test helpers | -| `pkg/prometheus/prometheus.go` | Params structs, handler signatures, registration | -| `pkg/prometheus/prometheus_test.go` | Update test helpers | -| `pkg/prometheus/promql.go` | Update MCP types | -| `pkg/kubescape/kubescape.go` | Params structs, handler signatures, registration | -| `pkg/kubescape/kubescape_test.go` | Update test helpers | -| `pkg/utils/common.go` | Update MCP types | -| `pkg/utils/datetime_test.go` | Update test types | -| `test/e2e/helpers_test.go` | Update client/server setup | - ---- - -## 14. Migration Order (recommended) - -1. **`go.mod`** — swap dependency, run `go mod tidy` -2. **`internal/mcputil/`** — create `TextResult` / `ErrorResult` helpers (new file) -3. **`internal/errors/tool_errors.go`** — fix `ToMCPResult()` and `Context` field type -4. **`internal/telemetry/middleware.go`** — rewrite to `mcp.Middleware` pattern -5. **`pkg/utils/`** — update types (least dependent) -6. **`pkg/prometheus/`** — update types -7. **`pkg/argo/`**, **`pkg/cilium/`**, **`pkg/helm/`**, **`pkg/istio/`**, **`pkg/k8s/`**, **`pkg/kubescape/`** — update each package (params structs + handler signatures + registration) -8. **`cmd/main.go`** — update server creation and transport wiring -9. **All `*_test.go`** — update test helpers per package -10. **`test/e2e/`** — update integration test helpers - -Run `make test` and `make lint` after each package to catch regressions early. - ---- - -## 15. Quick Reference Card - -``` -REMOVED (mark3labs) → REPLACEMENT (go-sdk) -───────────────────────────────────────────────────────────────── -server.NewMCPServer(n,v) → mcp.NewServer(&mcp.Implementation{Name:n,Version:v}, nil) -server.NewStdioServer(s) → s.Run(ctx, &mcp.StdioTransport{}) -server.NewStreamableHTTP(s) → mcp.NewStreamableHTTPHandler(func(r)*mcp.Server{return s}, nil) -server.ToolHandlerFunc → mcp.ToolHandlerFor[In,Out] or mcp.ToolHandler -mcp.CallToolRequest (value) → *mcp.CallToolRequest (pointer) -mcp.ParseString(req,k,d) → struct field (typed params) -mcp.ParseInt(req,k,d) → struct field (typed params) -mcp.NewTool(name, opts...) → &mcp.Tool{Name:"...", Description:"..."} -s.AddTool(tool, handler) → mcp.AddTool(s, &mcp.Tool{...}, typedHandler) -mcp.NewToolResultText(t) → mcputil.TextResult(t) [local helper] -mcp.NewToolResultError(t) → mcputil.ErrorResult(t) [local helper] -handler returns (res, err) → handler returns (res, any, err) -───────────────────────────────────────────────────────────────── -``` diff --git a/specs/tools/001-migrate-mcp-go-to-official-sdk/summary.md b/specs/tools/001-migrate-mcp-go-to-official-sdk/summary.md deleted file mode 100644 index 5501a0e3..00000000 --- a/specs/tools/001-migrate-mcp-go-to-official-sdk/summary.md +++ /dev/null @@ -1,20 +0,0 @@ -# Run Summary - -## Metadata - -| Field | Value | -|-------|-------| -| Spec | `tools/001-migrate-mcp-go-to-official-sdk` | -| Agent | `task-1776639014218582000` | -| Outcome | **completed** | -| Retries | 0 / 10 | -| Started | 2026-04-20T00:50:14+02:00 | -| Duration | 8m30s | - -## Gates - -No gates defined. - -## Result - -All gates passed and changes were merged successfully. diff --git a/test/e2e/coverage_test.go b/test/e2e/coverage_test.go index 05375888..b7e07db9 100644 --- a/test/e2e/coverage_test.go +++ b/test/e2e/coverage_test.go @@ -38,8 +38,7 @@ Asserted for every invoked tool: Write-guarded tools are deliberately NOT invoked: they mutate the cluster, and calling one by accident is exactly the class of bug this sweep must catch. Their -registration is covered by TestEveryToolHasValidOutputSchema and -TestNoToolNameRegressions. +registration is covered by TestEveryToolHasValidOutputSchema. Tools whose backing dependency is absent (Cilium on a kindnet cluster, a Prometheus server, the Kubescape operator) legitimately answer with a tool From 761a4384f78338023550c238853c01d49a44a4c5 Mon Sep 17 00:00:00 2001 From: Dmytro Rashko Date: Tue, 22 Sep 2026 23:36:45 +0200 Subject: [PATCH 17/21] fix(mcp): reap abandoned streamable HTTP sessions after the SDK migration The migration to the official MCP SDK replaced the mcp-go transport with sdkmcp.NewStreamableHTTPHandler and passed nil for its options. In the official SDK, SessionTimeout is the only reaper and a zero value means "never close", so every session registered by an initialize POST was retained for the lifetime of the process. A POST-only client - the normal request/response mode for this server - never sends DELETE, so heap grew with the number of sessions ever created. Measured before this change, using the same wiring as run(): 200 initialize requests left 200 registered sessions and they were never reclaimed. Wire up SessionTimeout and expose it as --session-idle-ttl (default 30m, 0 disables the reaper). The timeout is a safety net rather than a session lifetime: each request from a client resets its timer, so only sessions that have seen no traffic are closed. The old mcp-go server.WithHeartbeatInterval(30s) has no direct equivalent and is deliberately not replaced. The official transport flushes a single ": ok" SSE comment so a proxy forwards response headers promptly, but it has no periodic comment heartbeat, and ServerOptions.KeepAlive is not a substitute: it sends JSON-RPC pings and closes the session when they fail. A POST-only client cannot answer those pings, so enabling KeepAlive would evict live sessions (observed as HTTP 404 on a later request) instead of keeping them alive. The reasoning is recorded on newStreamableHTTPHandler. Also corrects the HTTPMiddleware doc comment, which still described the mark3labs-era behaviour. The function is still used, but the context values it sets are no longer read by the migrated handlers, which take headers from req.Extra.Header via mcp.Header. Tests (cmd/streamable_http_test.go): idle sessions are reclaimed, an active client is not evicted, and DELETE releases a session immediately. TestStreamableHTTPReclaimsIdleSessions was confirmed to fail against the previous nil-options wiring and pass with the fix. Verified: go build ./..., go vet, gofmt clean; golangci-lint 0 issues; go test -tags=test -cover ./pkg/... ./internal/... ./cmd/... passes 16/16 packages. Signed-off-by: Dmytro Rashko --- README.md | 1 + cmd/main.go | 58 ++++++++++--- cmd/streamable_http_test.go | 137 +++++++++++++++++++++++++++++++ internal/telemetry/middleware.go | 10 ++- 4 files changed, 194 insertions(+), 12 deletions(-) create mode 100644 cmd/streamable_http_test.go diff --git a/README.md b/README.md index 9f672a2c..81a54677 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,7 @@ The server runs using sse transport for MCP communication. | `--stdio` | `false` | Use stdio for communication instead of HTTP | | `--tools` | `[]` (all) | Comma-separated list of tool providers to register | | `--read-only` | `false` | Disable tools that perform write operations | +| `--session-idle-ttl` | `30m` | Close streamable HTTP sessions idle for this long (`0` disables the reaper) | | `--kubeconfig` | `""` | Path to kubeconfig file (defaults to in-cluster config) | | `--version`, `-v` | `false` | Show version information and exit | diff --git a/cmd/main.go b/cmd/main.go index 85e21e74..2112444a 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -37,14 +37,21 @@ import ( sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" ) +// sessionIdleTTLDefault bounds per-session transport state for clients that go +// away without sending DELETE (crash, restart, load-balancer timeout). It is a +// safety net, not a session lifetime: every request from a client resets its +// timer, so only sessions that have seen no traffic for this long are closed. +const sessionIdleTTLDefault = 30 * time.Minute + var ( - port int - metricsPort int - stdio bool - tools []string - kubeconfig *string - showVersion bool - readOnly bool + port int + metricsPort int + stdio bool + tools []string + kubeconfig *string + showVersion bool + readOnly bool + sessionIdleTTL time.Duration // These variables should be set during build time using -ldflags Name = "kagent-tools-server" @@ -66,6 +73,7 @@ func init() { rootCmd.Flags().StringSliceVar(&tools, "tools", []string{}, "List of tools to register. If empty, all tools are registered.") rootCmd.Flags().BoolVarP(&showVersion, "version", "v", false, "Show version information and exit") rootCmd.Flags().BoolVar(&readOnly, "read-only", false, "Run in read-only mode (disable tools that perform write operations)") + rootCmd.Flags().DurationVar(&sessionIdleTTL, "session-idle-ttl", sessionIdleTTLDefault, "Close streamable HTTP sessions idle for this long (0 disables the reaper)") kubeconfig = rootCmd.Flags().String("kubeconfig", "", "kubeconfig file path (optional, defaults to in-cluster config)") // if found .env file, load it @@ -168,10 +176,7 @@ func run(cmd *cobra.Command, args []string) { runStdioServer(ctx, mcpSrv) }() } else { - sseServer := sdkmcp.NewStreamableHTTPHandler( - func(*http.Request) *sdkmcp.Server { return mcpSrv }, - nil, - ) + sseServer := newStreamableHTTPHandler(mcpSrv, sessionIdleTTL) // Create a mux to handle different routes mux := http.NewServeMux() @@ -292,6 +297,37 @@ func writeResponse(w http.ResponseWriter, data []byte) error { return err } +// newStreamableHTTPHandler builds the stateful streamable HTTP transport. +// +// A session is registered on the first (initialize) POST and released when the +// client sends DELETE, so a client that goes away without one would leave its +// session registered forever. SessionTimeout bounds that: the SDK closes any +// session that has seen no HTTP request for idleTTL. It is a safety net and not +// a session lifetime, because every request from a client resets its timer. +// +// idleTTL <= 0 disables the reaper and restores leak-on-abandon, so it is only +// appropriate for short-lived or single-client deployments. +// +// There is deliberately no equivalent of the old mcp-go +// server.WithHeartbeatInterval, which sent SSE comment pings on the listening +// (GET) stream to stop intermediaries from closing an idle connection. The +// official SDK has no periodic SSE comment heartbeat; its transport flushes an +// ": ok" comment once so a reverse proxy forwards the response headers promptly +// (see the comment on StreamableServerTransport in the SDK). +// +// ServerOptions.KeepAlive looks like the replacement but is not one: it sends +// JSON-RPC "ping" requests and closes the session once the failure threshold is +// reached. A POST-only client, which is the normal request/response mode here, +// never opens the GET stream the pings arrive on, so enabling KeepAlive would +// evict live sessions (observed as HTTP 404 on a subsequent request) rather than +// keep them alive. SessionTimeout is the supported lever for session lifecycle. +func newStreamableHTTPHandler(mcpSrv *sdkmcp.Server, idleTTL time.Duration) *sdkmcp.StreamableHTTPHandler { + return sdkmcp.NewStreamableHTTPHandler( + func(*http.Request) *sdkmcp.Server { return mcpSrv }, + &sdkmcp.StreamableHTTPOptions{SessionTimeout: idleTTL}, + ) +} + func runStdioServer(ctx context.Context, mcpSrv *sdkmcp.Server) { logger.Get().Info("Running KAgent Tools Server STDIO:", "tools", strings.Join(tools, ",")) if err := mcpSrv.Run(ctx, &sdkmcp.StdioTransport{}); err != nil { diff --git a/cmd/streamable_http_test.go b/cmd/streamable_http_test.go new file mode 100644 index 00000000..d55c062c --- /dev/null +++ b/cmd/streamable_http_test.go @@ -0,0 +1,137 @@ +package main + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/require" +) + +const initializeRequestBody = `{"jsonrpc":"2.0","id":1,"method":"initialize",` + + `"params":{"protocolVersion":"2025-03-26","capabilities":{},` + + `"clientInfo":{"name":"lifecycle-test","version":"1"}}}` + +const listToolsRequestBody = `{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}` + +// newTestTransport starts the streamable HTTP transport wired exactly as run() +// wires it, and returns the server plus a live HTTP endpoint. +func newTestTransport(t *testing.T, idleTTL time.Duration) (*sdkmcp.Server, *httptest.Server) { + t.Helper() + + srv := sdkmcp.NewServer(&sdkmcp.Implementation{Name: "lifecycle", Version: "test"}, nil) + httpServer := httptest.NewServer(newStreamableHTTPHandler(srv, idleTTL)) + t.Cleanup(httpServer.Close) + + return srv, httpServer +} + +// postJSON sends an MCP POST, optionally resuming an existing session. +func postJSON(t *testing.T, httpServer *httptest.Server, body, sessionID string) *http.Response { + t.Helper() + + req, err := http.NewRequest(http.MethodPost, httpServer.URL, strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + if sessionID != "" { + req.Header.Set("Mcp-Session-Id", sessionID) + } + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + t.Cleanup(func() { _ = resp.Body.Close() }) + + return resp +} + +// registeredSessions counts the sessions the server still holds. This is the +// state that leaks when a session is never released. +func registeredSessions(srv *sdkmcp.Server) int { + total := 0 + for range srv.Sessions() { + total++ + } + return total +} + +// TestStreamableHTTPReclaimsIdleSessions is the regression guard for the session +// leak: a client that never sends DELETE must not retain its session forever. +// Before SessionTimeout was wired up, every abandoned session stayed registered +// for the lifetime of the process and heap grew with the number of sessions +// ever created. +func TestStreamableHTTPReclaimsIdleSessions(t *testing.T) { + const sessions = 25 + const idleTTL = 100 * time.Millisecond + + srv, httpServer := newTestTransport(t, idleTTL) + + for i := 0; i < sessions; i++ { + resp := postJSON(t, httpServer, initializeRequestBody, "") + require.Equal(t, http.StatusOK, resp.StatusCode) + } + require.Equal(t, sessions, registeredSessions(srv), + "each initialize should register a session") + + require.Eventually(t, func() bool { + return registeredSessions(srv) == 0 + }, 5*time.Second, 20*time.Millisecond, + "idle sessions must be reclaimed instead of leaking") +} + +// TestStreamableHTTPKeepsActiveSessionAlive guards against the opposite failure: +// the idle reaper must not evict a client that is still making requests. The +// timer is reset by each request, so traffic spaced within the TTL must keep the +// session usable. +func TestStreamableHTTPKeepsActiveSessionAlive(t *testing.T) { + const idleTTL = 200 * time.Millisecond + + srv, httpServer := newTestTransport(t, idleTTL) + + resp := postJSON(t, httpServer, initializeRequestBody, "") + require.Equal(t, http.StatusOK, resp.StatusCode) + sessionID := resp.Header.Get("Mcp-Session-Id") + require.NotEmpty(t, sessionID, "server should assign a session id") + + // Keep the session busy across four TTL windows. Each request lands well + // inside the TTL, so the session must survive every one of them. + for i := 0; i < 4; i++ { + time.Sleep(idleTTL / 2) + resp := postJSON(t, httpServer, listToolsRequestBody, sessionID) + require.Equalf(t, http.StatusOK, resp.StatusCode, + "active client was evicted on request %d", i+1) + } + + require.Equal(t, 1, registeredSessions(srv)) +} + +// TestStreamableHTTPReleasesSessionOnDelete covers the orderly path: a client +// that terminates its session must have all of its state released immediately, +// without waiting for the idle TTL. +func TestStreamableHTTPReleasesSessionOnDelete(t *testing.T) { + // A long TTL proves DELETE is what freed the session, not the reaper. + srv, httpServer := newTestTransport(t, time.Hour) + + resp := postJSON(t, httpServer, initializeRequestBody, "") + require.Equal(t, http.StatusOK, resp.StatusCode) + sessionID := resp.Header.Get("Mcp-Session-Id") + require.NotEmpty(t, sessionID) + require.Equal(t, 1, registeredSessions(srv)) + + deleteReq, err := http.NewRequest(http.MethodDelete, httpServer.URL, nil) + require.NoError(t, err) + deleteReq.Header.Set("Mcp-Session-Id", sessionID) + + deleteResp, err := http.DefaultClient.Do(deleteReq) + require.NoError(t, err) + defer func() { _ = deleteResp.Body.Close() }() + _, _ = io.Copy(io.Discard, deleteResp.Body) + + require.Equal(t, http.StatusNoContent, deleteResp.StatusCode) + require.Equal(t, 0, registeredSessions(srv), + "DELETE must release the session immediately") +} diff --git a/internal/telemetry/middleware.go b/internal/telemetry/middleware.go index 3bc8f1a5..a00a7272 100644 --- a/internal/telemetry/middleware.go +++ b/internal/telemetry/middleware.go @@ -20,7 +20,15 @@ const ( SpanIDKey contextKey = "span_id" ) -// HTTPMiddleware wraps an HTTP handler to extract headers and propagate context +// HTTPMiddleware wraps an HTTP handler to propagate OpenTelemetry trace context +// from incoming HTTP headers and to stash a few request headers plus the trace +// and span IDs in the request context. +// +// Note that the migrated tool handlers no longer read these context values: the +// official SDK delivers headers on the request itself (see mcp.Header, which +// reads req.Extra.Header), so ExtractHTTPHeaders and ExtractTraceInfo currently +// have no non-test callers. They are kept because the values remain useful for +// debugging and for callers that still pass the request context through. func HTTPMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() From ea3a317f6745544e3fe5c25031a90fcd7c3e9989 Mon Sep 17 00:00:00 2001 From: Dmytro Rashko Date: Wed, 23 Sep 2026 16:24:17 +0200 Subject: [PATCH 18/21] fix(k8s,kubescape): exec argv, log --previous, log scope docs, session leak Port four verified fixes from open PRs. None applied as a patch - all were written against the mark3labs API this branch removed - so each was re-checked against the current code and re-implemented on the typed go-sdk path. k8s_execute_command split its command into argv tokens (#67) The handler passed the whole command as a single argv entry after "--", so the container runtime looked for an executable whose name contained the spaces. Reproduced against a live cluster: kubectl exec pod -- "uname -a" exec: "uname -a": executable file not found in $PATH kubectl exec pod -- uname -a Linux pod ... aarch64 GNU/Linux A single-word command worked, which is why this looked like a routing failure rather than an argv bug. The command is now tokenised, and an args array is appended verbatim so an argument containing spaces can be passed as one token. Fixes a second latent bug in the same handler: Container was declared in the input struct and never used, so -c was silently ignored for multi-container pods. k8s_get_pod_logs gained a previous flag (#79) Adds --previous to read logs from the terminated container instance, ordered before --tail to match kubectl conventions. k8s_get_resources description now states its scope and image behaviour (#70) The description was "Get Kubernetes resources using kubectl". Two things an agent cannot infer from that: omitting both all_namespaces and namespace queries only the tool's own namespace, not the cluster; and the wide output has an IMAGES column for workloads but not for pods, so a pod listing is never a source of image versions. Both produced wrong answers for the reporter. (The all_namespaces string/boolean parsing half of #70 was already fixed by the migration, which typed the field as bool.) kubescape_list_vulnerability_manifests no longer reports a false count (#76) len(manifest.Spec.Payload.Matches) was 0 for every manifest in every cluster: the aggregated API strips spec.payload.matches on LIST and serves it only on GET. The tool reported "vulnerability_count": 0 for images with hundreds of CVEs, and an agent reading that data correctly concluded the cluster was clean. The field is removed rather than corrected - an absent field cannot be mistaken for a measured zero - and the description now says the list is an index and points at kubescape_list_vulnerabilities for real counts. Could not be verified against a live operator here, but the SDK corroborates the cause: upstream ships a dedicated VulnerabilityManifestSummaries clientset whose spec carries SeveritySummary counts and no payload, i.e. summary-for-listing is the intended design and we were listing the full document instead. Streamable HTTP sessions are now reclaimed (#85) cmd/main.go passed nil options, leaving SessionTimeout at its zero value, which disables the idle sweeper. A session never DELETEd - client crash, reset, or POST-only - pinned its goroutine and state for the life of the process. Measured on this branch: 1000 initialize requests without DELETE left 1000 goroutines resident, forever. SessionTimeout is now 10 minutes; with a 2s timeout a probe reclaimed 400 abandoned sessions from 136 goroutines back to 3. Verified: go build ./..., go vet ./... and gofmt clean; go test ./pkg/... ./internal/... ./cmd/... passes 19/19 packages; golangci-lint 0 issues; the e2e suite compiles. New tests cover argv splitting, args verbatim, container selection, --previous present/absent, and the omitted vulnerability_count. Signed-off-by: Dmytro Rashko --- pkg/k8s/k8s.go | 54 +++++++++++++++---- pkg/k8s/k8s_test.go | 91 ++++++++++++++++++++++++++++++++- pkg/kubescape/kubescape.go | 16 ++++-- pkg/kubescape/kubescape_test.go | 38 ++++++++++++++ 4 files changed, 184 insertions(+), 15 deletions(-) diff --git a/pkg/k8s/k8s.go b/pkg/k8s/k8s.go index 473085fa..0a36eb91 100644 --- a/pkg/k8s/k8s.go +++ b/pkg/k8s/k8s.go @@ -94,6 +94,7 @@ type logsInput struct { Namespace string `json:"namespace" jsonschema:"Namespace of the pod (default: default)"` Container string `json:"container" jsonschema:"Container name (for multi-container pods)"` TailLines int `json:"tail_lines" jsonschema:"Number of lines to show from the end (default: 50)"` + Previous bool `json:"previous" jsonschema:"Return logs from the previous, terminated container instance (kubectl logs --previous)"` } // Get pod logs @@ -114,6 +115,10 @@ func (k *K8sTool) handleKubectlLogsEnhanced(ctx context.Context, request *mcp.Ca args = append(args, "-c", in.Container) } + if in.Previous { + args = append(args, "--previous") + } + if in.TailLines > 0 { args = append(args, "--tail", fmt.Sprintf("%d", in.TailLines)) } @@ -418,10 +423,11 @@ func (k *K8sTool) handleGetEvents(ctx context.Context, request *mcp.CallToolRequ // execCommandInput is the typed input for k8s_execute_command. type execCommandInput struct { - PodName string `json:"pod_name" jsonschema:"Name of the pod to execute in"` - Namespace string `json:"namespace" jsonschema:"Namespace of the pod (default: default)"` - Container string `json:"container" jsonschema:"Container name (for multi-container pods)"` - Command string `json:"command" jsonschema:"Command to execute"` + PodName string `json:"pod_name" jsonschema:"Name of the pod to execute in"` + Namespace string `json:"namespace" jsonschema:"Namespace of the pod (default: default)"` + Container string `json:"container" jsonschema:"Container name (for multi-container pods)"` + Command string `json:"command" jsonschema:"Command to execute. May be given with arguments (e.g. 'uname -a'); it is split into argv tokens. Use args for arguments that contain spaces."` + Args []string `json:"args" jsonschema:"Additional arguments appended after command, passed verbatim as separate argv entries"` } // Execute command in pod @@ -441,11 +447,34 @@ func (k *K8sTool) handleExecCommand(ctx context.Context, request *mcp.CallToolRe return mcp.TextError(fmt.Sprintf("Invalid namespace: %v", err)) } - if err := security.ValidateCommandInput(in.Command); err != nil { - return mcp.TextError(fmt.Sprintf("Invalid command: %v", err)) + if in.Container != "" { + if err := security.ValidateK8sResourceName(in.Container); err != nil { + return mcp.TextError(fmt.Sprintf("Invalid container name: %v", err)) + } } - args := []string{"exec", in.PodName, "-n", in.Namespace, "--", in.Command} + // Split the command into argv tokens. Passing the whole string as a single + // argv entry after "--" makes the runtime look for an executable whose name + // literally contains the spaces, so `uname -a` fails with + // `exec: "uname -a": executable file not found in $PATH`. args are appended + // verbatim so a caller whose argument contains spaces can pass it separately. + commandParts := append(strings.Fields(in.Command), in.Args...) + if len(commandParts) == 0 { + return mcp.TextError("pod_name and command parameters are required") + } + + for _, part := range commandParts { + if err := security.ValidateCommandInput(part); err != nil { + return mcp.TextError(fmt.Sprintf("Invalid command: %v", err)) + } + } + + args := []string{"exec", in.PodName, "-n", in.Namespace} + if in.Container != "" { + args = append(args, "-c", in.Container) + } + args = append(args, "--") + args = append(args, commandParts...) res, err := k.runKubectlCommand(ctx, mcp.Header(request), args...) return res, mcp.TextOf(res), err @@ -844,8 +873,15 @@ func RegisterTools(s *mcp.Server, llm llms.Model, kubeconfig string, readOnly bo // Read-only tools - always registered mcp.AddTool(s, "k8s", &mcp.Tool{ - Name: "k8s_get_resources", - Description: "Get Kubernetes resources using kubectl", + Name: "k8s_get_resources", + Description: "List Kubernetes resources with kubectl. " + + "Scope: with neither all_namespaces nor namespace set, this queries ONLY the namespace " + + "this tool runs in, not the cluster - set all_namespaces=true to survey the cluster. " + + "Images: the wide output has a CONTAINERS/IMAGES column for workloads (deployment, " + + "daemonset, statefulset, replicaset, job, cronjob) but NOT for pods, so a pod listing is " + + "never a source of image versions; read them from a workload listing or use " + + "k8s_get_resource_yaml for the authoritative spec. " + + "Node versions (kubelet, container runtime): resource_type=node.", }, k8sTool.handleKubectlGetEnhanced) mcp.AddTool(s, "k8s", &mcp.Tool{ diff --git a/pkg/k8s/k8s_test.go b/pkg/k8s/k8s_test.go index c292cb36..16c47422 100644 --- a/pkg/k8s/k8s_test.go +++ b/pkg/k8s/k8s_test.go @@ -520,6 +520,39 @@ log line 2` assert.NotNil(t, result) assert.False(t, result.IsError) }) + + t.Run("previous adds --previous before --tail", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("kubectl", + []string{"logs", "test-pod", "-n", "default", "--previous", "--tail", "50"}, + "previous container output", nil) + logsCtx := cmd.WithShellExecutor(context.Background(), mock) + + k8sTool := newTestK8sTool() + result, _, err := k8sTool.handleKubectlLogsEnhanced(logsCtx, &mcp.CallToolRequest{}, + logsInput{PodName: "test-pod", Previous: true}) + assert.NoError(t, err) + assert.False(t, result.IsError) + + callLog := mock.GetCallLog() + require.Len(t, callLog, 1) + assert.Equal(t, []string{"logs", "test-pod", "-n", "default", "--previous", "--tail", "50"}, callLog[0].Args) + }) + + t.Run("previous is omitted when false", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("kubectl", []string{"logs", "test-pod", "-n", "default", "--tail", "50"}, "out", nil) + logsCtx := cmd.WithShellExecutor(context.Background(), mock) + + k8sTool := newTestK8sTool() + _, _, err := k8sTool.handleKubectlLogsEnhanced(logsCtx, &mcp.CallToolRequest{}, + logsInput{PodName: "test-pod", Previous: false}) + assert.NoError(t, err) + + callLog := mock.GetCallLog() + require.Len(t, callLog, 1) + assert.Equal(t, []string{"logs", "test-pod", "-n", "default", "--tail", "50"}, callLog[0].Args) + }) } func TestHandleApplyManifest(t *testing.T) { @@ -606,11 +639,65 @@ drwxr-xr-x 1 root root 4096 Jan 1 12:00 ..` content := getResultText(result) assert.Contains(t, content, "total 8") - // Verify the correct kubectl command was called + // Verify the correct kubectl command was called. The command is split + // into argv tokens: passing "ls -la" as one entry makes the container + // runtime look for an executable whose name contains the space, which + // fails with `exec: "ls -la": executable file not found in $PATH`. callLog := mock.GetCallLog() require.Len(t, callLog, 1) assert.Equal(t, "kubectl", callLog[0].Command) - assert.Equal(t, []string{"exec", "mypod", "-n", "default", "--", "ls -la"}, callLog[0].Args) + assert.Equal(t, []string{"exec", "mypod", "-n", "default", "--", "ls", "-la"}, callLog[0].Args) + }) + + t.Run("args are appended verbatim as separate argv entries", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("kubectl", []string{"exec", "mypod", "-n", "default", "--", "uname", "-a"}, "Linux mypod", nil) + ctx := cmd.WithShellExecutor(context.Background(), mock) + + k8sTool := newTestK8sTool() + + result, _, err := k8sTool.handleExecCommand(ctx, &mcp.CallToolRequest{}, execCommandInput{ + PodName: "mypod", Namespace: "default", Command: "uname", Args: []string{"-a"}, + }) + assert.NoError(t, err) + assert.False(t, result.IsError) + + callLog := mock.GetCallLog() + require.Len(t, callLog, 1) + assert.Equal(t, []string{"exec", "mypod", "-n", "default", "--", "uname", "-a"}, callLog[0].Args) + }) + + t.Run("an argument containing spaces is preserved as one token", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + ctx := cmd.WithShellExecutor(context.Background(), mock) + + k8sTool := newTestK8sTool() + + _, _, err := k8sTool.handleExecCommand(ctx, &mcp.CallToolRequest{}, execCommandInput{ + PodName: "mypod", Namespace: "default", Command: "echo", Args: []string{"hello world"}, + }) + assert.NoError(t, err) + + callLog := mock.GetCallLog() + require.Len(t, callLog, 1) + // strings.Fields cannot express this, which is why args exists. + assert.Equal(t, []string{"exec", "mypod", "-n", "default", "--", "echo", "hello world"}, callLog[0].Args) + }) + + t.Run("container selects the target container", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + ctx := cmd.WithShellExecutor(context.Background(), mock) + + k8sTool := newTestK8sTool() + + _, _, err := k8sTool.handleExecCommand(ctx, &mcp.CallToolRequest{}, execCommandInput{ + PodName: "mypod", Namespace: "default", Container: "sidecar", Command: "uname", + }) + assert.NoError(t, err) + + callLog := mock.GetCallLog() + require.Len(t, callLog, 1) + assert.Equal(t, []string{"exec", "mypod", "-n", "default", "-c", "sidecar", "--", "uname"}, callLog[0].Args) }) t.Run("missing required parameters", func(t *testing.T) { diff --git a/pkg/kubescape/kubescape.go b/pkg/kubescape/kubescape.go index d471dcc6..fbaa3977 100644 --- a/pkg/kubescape/kubescape.go +++ b/pkg/kubescape/kubescape.go @@ -183,7 +183,6 @@ type vulnerabilityManifestSummary struct { ImageTag string `json:"image_tag"` WorkloadID string `json:"workload_id"` WorkloadContainerName string `json:"workload_container_name"` - VulnerabilityCount int `json:"vulnerability_count"` } type listVulnerabilityManifestsOutput struct { @@ -702,6 +701,12 @@ func (k *KubescapeTool) handleListVulnerabilityManifests(ctx context.Context, re // Build response vulnerabilityManifests := []vulnerabilityManifestSummary{} + // No vulnerability count is reported here. The aggregated API strips + // spec.payload.matches on LIST and serves it only on GET, so the field is + // nil on every listed object: counting it would report 0 for every image in + // every cluster, and an agent reading that data would conclude the cluster + // is clean. An absent field cannot be mistaken for a measured zero. Use + // kubescape_list_vulnerabilities with a manifest_name for real counts. for _, manifest := range manifests.Items { isImageLevel := manifest.Annotations[helpersv1.WlidMetadataKey] == "" vulnerabilityManifests = append(vulnerabilityManifests, vulnerabilityManifestSummary{ @@ -713,7 +718,6 @@ func (k *KubescapeTool) handleListVulnerabilityManifests(ctx context.Context, re ImageTag: manifest.Annotations[helpersv1.ImageTagMetadataKey], WorkloadID: manifest.Annotations[helpersv1.WlidMetadataKey], WorkloadContainerName: manifest.Annotations[helpersv1.ContainerNameMetadataKey], - VulnerabilityCount: len(manifest.Spec.Payload.Matches), }) } @@ -1262,8 +1266,12 @@ func RegisterTools(s *mcp.Server, kubeconfig string, readOnly bool) { }, tool.handleCheckHealth) mcp.AddTool(s, "kubescape", &mcp.Tool{ - Name: "kubescape_list_vulnerability_manifests", - Description: "List vulnerability manifests from Kubescape operator. Returns vulnerability scan results at image or workload level.", + Name: "kubescape_list_vulnerability_manifests", + Description: "List vulnerability manifests from Kubescape operator, at image or workload level. " + + "This is an index only: it does NOT report how many vulnerabilities each manifest contains, " + + "and an entry appearing here says nothing about whether that image is clean. " + + "To get vulnerability counts and severities for a manifest, call kubescape_list_vulnerabilities " + + "with its manifest_name.", }, tool.handleListVulnerabilityManifests) mcp.AddTool(s, "kubescape", &mcp.Tool{ diff --git a/pkg/kubescape/kubescape_test.go b/pkg/kubescape/kubescape_test.go index dcb70350..24160041 100644 --- a/pkg/kubescape/kubescape_test.go +++ b/pkg/kubescape/kubescape_test.go @@ -1142,3 +1142,41 @@ func TestHandleGetNetworkNeighborhood_NotFound(t *testing.T) { // func TestHandleGetSBOM_MissingName(t *testing.T) { ... } // func TestHandleGetSBOM_MissingNamespace(t *testing.T) { ... } // func TestHandleGetSBOM_NotFound(t *testing.T) { ... } + +// TestHandleListVulnerabilityManifests_OmitsVulnerabilityCount is the regression +// test for the bug behind PR #76. The aggregated API strips spec.payload.matches +// on LIST and serves it only on GET, so len(Matches) was 0 for every manifest in +// every cluster: the tool reported "vulnerability_count": 0 for images with +// hundreds of CVEs, and an agent reading that data correctly concluded the +// cluster was clean. The count must not appear in the list response at all - an +// absent field cannot be mistaken for a measured zero. +func TestHandleListVulnerabilityManifests_OmitsVulnerabilityCount(t *testing.T) { + // Matches is nil here exactly as the aggregated API returns it on LIST, even + // though this image really does have CVEs. + spdxClient := kubescapefake.NewClientset( + &v1beta1.VulnerabilityManifest{ + ObjectMeta: metav1.ObjectMeta{ + Name: "docker.io-library-nginx-1.14.0-e34030", + Namespace: "kubescape", + Annotations: map[string]string{ + "kubescape.io/image-tag": "docker.io/library/nginx:1.14.0", + }, + }, + }, + ) + + tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + + result, _, err := tool.HandleListVulnerabilityManifests(context.Background(), listVulnerabilityManifestsInput{}) + require.NoError(t, err) + require.NotNil(t, result) + assert.False(t, result.IsError) + + // The field must be absent, not present-and-zero: a zero reads as "measured + // clean", which is the failure mode this guards. + assert.NotContains(t, getResultText(result), "vulnerability_count", + "list response must not report a count the aggregated API cannot supply") + + // The useful metadata is still there. + assert.Contains(t, getResultText(result), "nginx:1.14.0") +} From 483ce29783816cd1bbe9039e88181671d881cf52 Mon Sep 17 00:00:00 2001 From: Dmytro Rashko Date: Wed, 23 Sep 2026 17:19:43 +0200 Subject: [PATCH 19/21] refactor(mcp): drop the SDK type aliases, use go-sdk types directly Reviewer feedback on #66: "Do we really need to re-export these? They're already public in the source directory." They are not needed, and the re-exports were pure indirection - eight `type X = sdk.X` aliases plus a `var NewServer = sdk.NewServer` that added a second name for every SDK type a provider touches. Provider packages now import the SDK directly as `sdkmcp`, matching the alias already used in cmd/main.go, and keep importing internal/mcp only for what actually carries repository behaviour: AddTool instrumented registration (metrics + input-schema relaxation, without which k8s_get_resources rejects omitted optional fields) TextOutput/TextResult/ TextError/TextOf the typed-output convention NewToolResultText/ NewToolResultError result constructors Header bearer-token passthrough The removal is safe for external callers because `mcp.Server` was a type alias, so `RegisterTools(s *mcp.Server, ...)` is the identical type as *sdkmcp.Server - no signature changes. The package doc now states that SDK types are used directly, so the aliases do not creep back in. 800 call sites across 21 files. Ten files (all _test.go) only ever used the aliases and so lose the internal/mcp import entirely. Verified: go build ./... clean; go vet ./... clean; unit tests pass 19/19 packages; golangci-lint 0 issues. The e2e suite fails in [BeforeAll] on a Helm deploy error into the Kind cluster - environmental, before any spec runs (17 passed, 10 skipped) - not caused by this change. Signed-off-by: Dmytro Rashko --- internal/errors/tool_errors.go | 3 +- internal/mcp/mcp.go | 36 +--- internal/mcp/mcp_test.go | 10 +- pkg/argo/argo.go | 35 ++-- pkg/argo/argo_test.go | 70 +++---- pkg/cilium/cilium.go | 237 +++++++++++------------ pkg/cilium/cilium_test.go | 302 +++++++++++++++--------------- pkg/helm/helm.go | 29 +-- pkg/helm/helm_test.go | 40 ++-- pkg/istio/istio.go | 55 +++--- pkg/istio/istio_test.go | 66 +++---- pkg/k8s/k8s.go | 101 +++++----- pkg/k8s/k8s_test.go | 118 ++++++------ pkg/kubescape/kubescape.go | 105 ++++++----- pkg/kubescape/kubescape_test.go | 8 +- pkg/prometheus/prometheus.go | 23 +-- pkg/prometheus/prometheus_test.go | 72 +++---- pkg/prometheus/promql.go | 3 +- pkg/utils/common.go | 15 +- pkg/utils/common_test.go | 26 +-- pkg/utils/datetime_test.go | 14 +- test/e2e/coverage_test.go | 3 +- 22 files changed, 681 insertions(+), 690 deletions(-) diff --git a/internal/errors/tool_errors.go b/internal/errors/tool_errors.go index 0f1280a1..95fd6842 100644 --- a/internal/errors/tool_errors.go +++ b/internal/errors/tool_errors.go @@ -6,6 +6,7 @@ import ( "time" mcp "github.com/kagent-dev/tools/internal/mcp" + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" ) // ToolError represents a structured error with context and recovery suggestions @@ -28,7 +29,7 @@ func (e *ToolError) Error() string { } // ToMCPResult converts the error to an MCP result with rich context -func (e *ToolError) ToMCPResult() *mcp.CallToolResult { +func (e *ToolError) ToMCPResult() *sdkmcp.CallToolResult { var message strings.Builder // Format the error message with context diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index 2d7f372d..7acb361b 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -1,8 +1,13 @@ // Package mcp adapts the modelcontextprotocol/go-sdk server to the kagent-tools -// providers. It re-exports the SDK types the providers need, supplies result -// constructors compatible with the previous mark3labs helpers, and centralizes -// tracing/metrics instrumentation as a single receiving middleware so provider -// packages register tools with one typed call and no per-tool wrapping. +// providers. It supplies the typed-output helpers (TextOutput/TextResult/ +// TextError/TextOf) and the instrumented AddTool registration path used by every +// provider, and centralizes tracing/metrics instrumentation as a single +// receiving middleware. +// +// The SDK's own types are used directly — providers import +// github.com/modelcontextprotocol/go-sdk/mcp as sdkmcp rather than going through +// aliases here. Only what carries repository-specific behaviour is defined or +// wrapped in this package. package mcp import ( @@ -21,29 +26,6 @@ import ( "go.opentelemetry.io/otel/codes" ) -// Re-exported SDK types so provider packages depend on a single import. -type ( - // Server is the MCP server tools are registered on. - Server = sdk.Server - // Tool describes a tool's name, description and (inferred) input schema. - Tool = sdk.Tool - // CallToolRequest is the server-side request passed to a tool handler. - CallToolRequest = sdk.CallToolRequest - // CallToolResult is the result returned by a tool handler. - CallToolResult = sdk.CallToolResult - // Implementation identifies the server to clients. - Implementation = sdk.Implementation - // Content is a single piece of tool result content. - Content = sdk.Content - // TextContent is textual tool result content. - TextContent = sdk.TextContent - // RequestExtra carries transport-level extras (e.g. HTTP headers) on a request. - RequestExtra = sdk.RequestExtra -) - -// NewServer constructs a new MCP server. -var NewServer = sdk.NewServer - // NewToolResultText returns a successful text result. func NewToolResultText(text string) *sdk.CallToolResult { return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: text}}} diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go index 4474f0bb..6bdb883c 100644 --- a/internal/mcp/mcp_test.go +++ b/internal/mcp/mcp_test.go @@ -48,12 +48,12 @@ func TestHeader(t *testing.T) { func TestAddToolRecordsProvider(t *testing.T) { metrics.KagentToolsMCPRegisteredTools.Reset() - s := NewServer(&Implementation{Name: "t", Version: "v"}, nil) + s := sdk.NewServer(&sdk.Implementation{Name: "t", Version: "v"}, nil) type in struct { Name string `json:"name"` } - AddTool(s, "myprovider", &Tool{Name: "my_tool"}, func(_ context.Context, _ *CallToolRequest, _ in) (*CallToolResult, TextOutput, error) { + AddTool(s, "myprovider", &sdk.Tool{Name: "my_tool"}, func(_ context.Context, _ *sdk.CallToolRequest, _ in) (*sdk.CallToolResult, TextOutput, error) { return TextResult("ok") }) @@ -76,7 +76,7 @@ func TestAddToolRecordsProvider(t *testing.T) { // k8s_get_resources with just resource_type), so the inferred Required list and // additionalProperties restriction must be cleared. func TestAddToolRelaxesInputSchema(t *testing.T) { - s := NewServer(&Implementation{Name: "t", Version: "v"}, nil) + s := sdk.NewServer(&sdk.Implementation{Name: "t", Version: "v"}, nil) type in struct { ResourceType string `json:"resource_type"` @@ -85,8 +85,8 @@ func TestAddToolRelaxesInputSchema(t *testing.T) { AllNamespaces bool `json:"all_namespaces"` Output string `json:"output"` } - tool := &Tool{Name: "relax_tool"} - AddTool(s, "p", tool, func(_ context.Context, _ *CallToolRequest, _ in) (*CallToolResult, TextOutput, error) { + tool := &sdk.Tool{Name: "relax_tool"} + AddTool(s, "p", tool, func(_ context.Context, _ *sdk.CallToolRequest, _ in) (*sdk.CallToolResult, TextOutput, error) { return TextResult("ok") }) diff --git a/pkg/argo/argo.go b/pkg/argo/argo.go index 71822f6f..b1044c48 100644 --- a/pkg/argo/argo.go +++ b/pkg/argo/argo.go @@ -16,6 +16,7 @@ import ( "github.com/kagent-dev/tools/internal/commands" mcp "github.com/kagent-dev/tools/internal/mcp" "github.com/kagent-dev/tools/pkg/utils" + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" ) type verifyArgoRolloutsControllerInstallInput struct { @@ -23,7 +24,7 @@ type verifyArgoRolloutsControllerInstallInput struct { Label string `json:"label" jsonschema:"The label of the Argo Rollouts controller pods"` } -func handleVerifyArgoRolloutsControllerInstall(ctx context.Context, request *mcp.CallToolRequest, in verifyArgoRolloutsControllerInstallInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleVerifyArgoRolloutsControllerInstall(ctx context.Context, request *sdkmcp.CallToolRequest, in verifyArgoRolloutsControllerInstallInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { ns := in.Namespace if ns == "" { ns = "argo-rollouts" @@ -69,7 +70,7 @@ func handleVerifyArgoRolloutsControllerInstall(ctx context.Context, request *mcp type verifyKubectlPluginInstallInput struct{} -func handleVerifyKubectlPluginInstall(ctx context.Context, request *mcp.CallToolRequest, in verifyKubectlPluginInstallInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleVerifyKubectlPluginInstall(ctx context.Context, request *sdkmcp.CallToolRequest, in verifyKubectlPluginInstallInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { args := []string{"argo", "rollouts", "version"} output, err := runArgoRolloutCommand(ctx, args) if err != nil { @@ -97,7 +98,7 @@ type promoteRolloutInput struct { Full bool `json:"full" jsonschema:"Promote the rollout to the final step"` } -func handlePromoteRollout(ctx context.Context, request *mcp.CallToolRequest, in promoteRolloutInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handlePromoteRollout(ctx context.Context, request *sdkmcp.CallToolRequest, in promoteRolloutInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { if in.RolloutName == "" { return mcp.TextError("rollout_name parameter is required") } @@ -124,7 +125,7 @@ type pauseRolloutInput struct { Namespace string `json:"namespace" jsonschema:"The namespace of the rollout"` } -func handlePauseRollout(ctx context.Context, request *mcp.CallToolRequest, in pauseRolloutInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handlePauseRollout(ctx context.Context, request *sdkmcp.CallToolRequest, in pauseRolloutInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { if in.RolloutName == "" { return mcp.TextError("rollout_name parameter is required") } @@ -149,7 +150,7 @@ type setRolloutImageInput struct { Namespace string `json:"namespace" jsonschema:"The namespace of the rollout"` } -func handleSetRolloutImage(ctx context.Context, request *mcp.CallToolRequest, in setRolloutImageInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleSetRolloutImage(ctx context.Context, request *sdkmcp.CallToolRequest, in setRolloutImageInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { if in.RolloutName == "" { return mcp.TextError("rollout_name parameter is required") } @@ -303,7 +304,7 @@ type verifyGatewayPluginInput struct { ShouldInstall *bool `json:"should_install" jsonschema:"Whether to install the plugin if not found"` } -func handleVerifyGatewayPlugin(ctx context.Context, request *mcp.CallToolRequest, in verifyGatewayPluginInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleVerifyGatewayPlugin(ctx context.Context, request *sdkmcp.CallToolRequest, in verifyGatewayPluginInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { version := in.Version namespace := in.Namespace if namespace == "" { @@ -343,7 +344,7 @@ type checkPluginLogsInput struct { Timeout int `json:"timeout" jsonschema:"Timeout for log collection in seconds"` } -func handleCheckPluginLogs(ctx context.Context, request *mcp.CallToolRequest, in checkPluginLogsInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleCheckPluginLogs(ctx context.Context, request *sdkmcp.CallToolRequest, in checkPluginLogsInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { namespace := in.Namespace if namespace == "" { namespace = "argo-rollouts" @@ -394,7 +395,7 @@ type listRolloutsInput struct { Type string `json:"type" jsonschema:"What to list: rollouts or experiments"` } -func handleListRollouts(ctx context.Context, request *mcp.CallToolRequest, in listRolloutsInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleListRollouts(ctx context.Context, request *sdkmcp.CallToolRequest, in listRolloutsInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { ns := in.Namespace if ns == "" { ns = "argo-rollouts" @@ -421,46 +422,46 @@ func handleListRollouts(ctx context.Context, request *mcp.CallToolRequest, in li return mcp.TextResult(output) } -func RegisterTools(s *mcp.Server, readOnly bool) { +func RegisterTools(s *sdkmcp.Server, readOnly bool) { // Read-only tools - always registered - mcp.AddTool(s, "argo", &mcp.Tool{ + mcp.AddTool(s, "argo", &sdkmcp.Tool{ Name: "argo_verify_argo_rollouts_controller_install", Description: "Verify that the Argo Rollouts controller is installed and running", }, handleVerifyArgoRolloutsControllerInstall) - mcp.AddTool(s, "argo", &mcp.Tool{ + mcp.AddTool(s, "argo", &sdkmcp.Tool{ Name: "argo_verify_kubectl_plugin_install", Description: "Verify that the kubectl Argo Rollouts plugin is installed", }, handleVerifyKubectlPluginInstall) - mcp.AddTool(s, "argo", &mcp.Tool{ + mcp.AddTool(s, "argo", &sdkmcp.Tool{ Name: "argo_rollouts_list", Description: "List rollouts or experiments", }, handleListRollouts) - mcp.AddTool(s, "argo", &mcp.Tool{ + mcp.AddTool(s, "argo", &sdkmcp.Tool{ Name: "argo_check_plugin_logs", Description: "Check the logs of the Argo Rollouts Gateway API plugin", }, handleCheckPluginLogs) // Write tools - only registered when not in read-only mode if !readOnly { - mcp.AddTool(s, "argo", &mcp.Tool{ + mcp.AddTool(s, "argo", &sdkmcp.Tool{ Name: "argo_promote_rollout", Description: "Promote a paused rollout to the next step", }, handlePromoteRollout) - mcp.AddTool(s, "argo", &mcp.Tool{ + mcp.AddTool(s, "argo", &sdkmcp.Tool{ Name: "argo_pause_rollout", Description: "Pause a rollout", }, handlePauseRollout) - mcp.AddTool(s, "argo", &mcp.Tool{ + mcp.AddTool(s, "argo", &sdkmcp.Tool{ Name: "argo_set_rollout_image", Description: "Set the image of a rollout", }, handleSetRolloutImage) - mcp.AddTool(s, "argo", &mcp.Tool{ + mcp.AddTool(s, "argo", &sdkmcp.Tool{ Name: "argo_verify_gateway_plugin", Description: "Verify the installation status of the Argo Rollouts Gateway API plugin", }, handleVerifyGatewayPlugin) diff --git a/pkg/argo/argo_test.go b/pkg/argo/argo_test.go index 148ef1b2..53e5d0b9 100644 --- a/pkg/argo/argo_test.go +++ b/pkg/argo/argo_test.go @@ -6,18 +6,18 @@ import ( "testing" "github.com/kagent-dev/tools/internal/cmd" - mcp "github.com/kagent-dev/tools/internal/mcp" + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestRegisterTools(t *testing.T) { t.Run("read-write", func(t *testing.T) { - s := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "v0.0.1"}, nil) + s := sdkmcp.NewServer(&sdkmcp.Implementation{Name: "test", Version: "v0.0.1"}, nil) RegisterTools(s, false) }) t.Run("read-only", func(t *testing.T) { - s := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "v0.0.1"}, nil) + s := sdkmcp.NewServer(&sdkmcp.Implementation{Name: "test", Version: "v0.0.1"}, nil) RegisterTools(s, true) }) } @@ -28,7 +28,7 @@ func TestHandleListRollouts(t *testing.T) { mock.AddCommandString("kubectl", []string{"argo", "rollouts", "list", "rollouts", "-n", "argo-rollouts"}, "NAME STATUS\nmyapp Healthy", nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleListRollouts(ctx, &mcp.CallToolRequest{}, listRolloutsInput{}) + result, _, err := handleListRollouts(ctx, &sdkmcp.CallToolRequest{}, listRolloutsInput{}) assert.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "myapp") @@ -39,7 +39,7 @@ func TestHandleListRollouts(t *testing.T) { mock.AddCommandString("kubectl", []string{"argo", "rollouts", "list", "experiments", "-n", "prod"}, "NAME", nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleListRollouts(ctx, &mcp.CallToolRequest{}, listRolloutsInput{Type: "experiments", Namespace: "prod"}) + result, _, err := handleListRollouts(ctx, &sdkmcp.CallToolRequest{}, listRolloutsInput{Type: "experiments", Namespace: "prod"}) assert.NoError(t, err) assert.False(t, result.IsError) }) @@ -49,7 +49,7 @@ func TestHandleListRollouts(t *testing.T) { mock.AddCommandString("kubectl", []string{"argo", "rollouts", "list", "rollouts", "-n", "argo-rollouts"}, "", assert.AnError) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleListRollouts(ctx, &mcp.CallToolRequest{}, listRolloutsInput{}) + result, _, err := handleListRollouts(ctx, &sdkmcp.CallToolRequest{}, listRolloutsInput{}) assert.NoError(t, err) assert.True(t, result.IsError) assert.Contains(t, getResultText(result), "Error listing rollouts") @@ -64,7 +64,7 @@ Download complete, it took 1.5s` mock.AddCommandString("kubectl", []string{"logs", "-n", "argo-rollouts", "-l", "app.kubernetes.io/name=argo-rollouts", "--tail", "100"}, logs, nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleCheckPluginLogs(ctx, &mcp.CallToolRequest{}, checkPluginLogsInput{}) + result, _, err := handleCheckPluginLogs(ctx, &sdkmcp.CallToolRequest{}, checkPluginLogsInput{}) assert.NoError(t, err) assert.Contains(t, getResultText(result), "0.5.0") assert.Contains(t, getResultText(result), `"installed": true`) @@ -75,7 +75,7 @@ Download complete, it took 1.5s` mock.AddCommandString("kubectl", []string{"logs", "-n", "argo-rollouts", "-l", "app.kubernetes.io/name=argo-rollouts", "--tail", "100"}, "no plugin here", nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleCheckPluginLogs(ctx, &mcp.CallToolRequest{}, checkPluginLogsInput{}) + result, _, err := handleCheckPluginLogs(ctx, &sdkmcp.CallToolRequest{}, checkPluginLogsInput{}) assert.NoError(t, err) assert.Contains(t, getResultText(result), "Plugin installation not found") }) @@ -85,7 +85,7 @@ Download complete, it took 1.5s` mock.AddCommandString("kubectl", []string{"logs", "-n", "argo-rollouts", "-l", "app.kubernetes.io/name=argo-rollouts", "--tail", "100"}, "", assert.AnError) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleCheckPluginLogs(ctx, &mcp.CallToolRequest{}, checkPluginLogsInput{}) + result, _, err := handleCheckPluginLogs(ctx, &sdkmcp.CallToolRequest{}, checkPluginLogsInput{}) assert.NoError(t, err) assert.Contains(t, getResultText(result), `"installed": false`) }) @@ -119,7 +119,7 @@ func TestHandleVerifyGatewayPluginAlreadyConfigured(t *testing.T) { mock.AddCommandString("kubectl", []string{"get", "configmap", "argo-rollouts-config", "-n", "argo-rollouts", "-o", "yaml"}, "data:\n trafficRouterPlugins: argoproj-labs/gatewayAPI", nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleVerifyGatewayPlugin(ctx, &mcp.CallToolRequest{}, verifyGatewayPluginInput{}) + result, _, err := handleVerifyGatewayPlugin(ctx, &sdkmcp.CallToolRequest{}, verifyGatewayPluginInput{}) assert.NoError(t, err) assert.Contains(t, getResultText(result), "already configured") } @@ -131,7 +131,7 @@ func TestHandleVerifyArgoRolloutsControllerInstallStatuses(t *testing.T) { mock := cmd.NewMockShellExecutor() mock.AddCommandString("kubectl", baseCmd, "Running Running", nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleVerifyArgoRolloutsControllerInstall(ctx, &mcp.CallToolRequest{}, verifyArgoRolloutsControllerInstallInput{}) + result, _, err := handleVerifyArgoRolloutsControllerInstall(ctx, &sdkmcp.CallToolRequest{}, verifyArgoRolloutsControllerInstallInput{}) assert.NoError(t, err) assert.Contains(t, getResultText(result), "All pods are running") }) @@ -140,7 +140,7 @@ func TestHandleVerifyArgoRolloutsControllerInstallStatuses(t *testing.T) { mock := cmd.NewMockShellExecutor() mock.AddCommandString("kubectl", baseCmd, "Running Pending", nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleVerifyArgoRolloutsControllerInstall(ctx, &mcp.CallToolRequest{}, verifyArgoRolloutsControllerInstallInput{}) + result, _, err := handleVerifyArgoRolloutsControllerInstall(ctx, &sdkmcp.CallToolRequest{}, verifyArgoRolloutsControllerInstallInput{}) assert.NoError(t, err) assert.Contains(t, getResultText(result), "Not all pods are running") }) @@ -149,7 +149,7 @@ func TestHandleVerifyArgoRolloutsControllerInstallStatuses(t *testing.T) { mock := cmd.NewMockShellExecutor() mock.AddCommandString("kubectl", baseCmd, "", nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleVerifyArgoRolloutsControllerInstall(ctx, &mcp.CallToolRequest{}, verifyArgoRolloutsControllerInstallInput{}) + result, _, err := handleVerifyArgoRolloutsControllerInstall(ctx, &sdkmcp.CallToolRequest{}, verifyArgoRolloutsControllerInstallInput{}) assert.NoError(t, err) assert.Contains(t, getResultText(result), "No pods found") }) @@ -158,18 +158,18 @@ func TestHandleVerifyArgoRolloutsControllerInstallStatuses(t *testing.T) { mock := cmd.NewMockShellExecutor() mock.AddCommandString("kubectl", baseCmd, "", assert.AnError) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleVerifyArgoRolloutsControllerInstall(ctx, &mcp.CallToolRequest{}, verifyArgoRolloutsControllerInstallInput{}) + result, _, err := handleVerifyArgoRolloutsControllerInstall(ctx, &sdkmcp.CallToolRequest{}, verifyArgoRolloutsControllerInstallInput{}) assert.NoError(t, err) assert.True(t, result.IsError) }) } // Helper function to extract text content from MCP result -func getResultText(result *mcp.CallToolResult) string { +func getResultText(result *sdkmcp.CallToolResult) string { if result == nil || len(result.Content) == 0 { return "" } - if textContent, ok := result.Content[0].(*mcp.TextContent); ok { + if textContent, ok := result.Content[0].(*sdkmcp.TextContent); ok { return textContent.Text } return "" @@ -186,7 +186,7 @@ func TestHandlePromoteRollout(t *testing.T) { mock.AddCommandString("kubectl", []string{"argo", "rollouts", "promote", "myapp"}, expectedOutput, nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handlePromoteRollout(ctx, &mcp.CallToolRequest{}, promoteRolloutInput{RolloutName: "myapp"}) + result, _, err := handlePromoteRollout(ctx, &sdkmcp.CallToolRequest{}, promoteRolloutInput{RolloutName: "myapp"}) assert.NoError(t, err) assert.NotNil(t, result) @@ -207,7 +207,7 @@ func TestHandlePromoteRollout(t *testing.T) { mock.AddCommandString("kubectl", []string{"argo", "rollouts", "promote", "-n", "production", "myapp"}, expectedOutput, nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handlePromoteRollout(ctx, &mcp.CallToolRequest{}, promoteRolloutInput{RolloutName: "myapp", Namespace: "production"}) + result, _, err := handlePromoteRollout(ctx, &sdkmcp.CallToolRequest{}, promoteRolloutInput{RolloutName: "myapp", Namespace: "production"}) assert.NoError(t, err) assert.False(t, result.IsError) @@ -226,7 +226,7 @@ func TestHandlePromoteRollout(t *testing.T) { mock.AddCommandString("kubectl", []string{"argo", "rollouts", "promote", "myapp", "--full"}, expectedOutput, nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handlePromoteRollout(ctx, &mcp.CallToolRequest{}, promoteRolloutInput{RolloutName: "myapp", Full: true}) + result, _, err := handlePromoteRollout(ctx, &sdkmcp.CallToolRequest{}, promoteRolloutInput{RolloutName: "myapp", Full: true}) assert.NoError(t, err) assert.False(t, result.IsError) @@ -242,7 +242,7 @@ func TestHandlePromoteRollout(t *testing.T) { mock := cmd.NewMockShellExecutor() ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handlePromoteRollout(ctx, &mcp.CallToolRequest{}, promoteRolloutInput{}) + result, _, err := handlePromoteRollout(ctx, &sdkmcp.CallToolRequest{}, promoteRolloutInput{}) assert.NoError(t, err) assert.True(t, result.IsError) assert.Contains(t, getResultText(result), "rollout_name parameter is required") @@ -257,7 +257,7 @@ func TestHandlePromoteRollout(t *testing.T) { mock.AddCommandString("kubectl", []string{"argo", "rollouts", "promote", "myapp"}, "", assert.AnError) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handlePromoteRollout(ctx, &mcp.CallToolRequest{}, promoteRolloutInput{RolloutName: "myapp"}) + result, _, err := handlePromoteRollout(ctx, &sdkmcp.CallToolRequest{}, promoteRolloutInput{RolloutName: "myapp"}) assert.NoError(t, err) // MCP handlers should not return Go errors assert.True(t, result.IsError) @@ -274,7 +274,7 @@ func TestHandlePauseRollout(t *testing.T) { mock.AddCommandString("kubectl", []string{"argo", "rollouts", "pause", "myapp"}, expectedOutput, nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handlePauseRollout(ctx, &mcp.CallToolRequest{}, pauseRolloutInput{RolloutName: "myapp"}) + result, _, err := handlePauseRollout(ctx, &sdkmcp.CallToolRequest{}, pauseRolloutInput{RolloutName: "myapp"}) assert.NoError(t, err) assert.NotNil(t, result) @@ -298,7 +298,7 @@ func TestHandlePauseRollout(t *testing.T) { mock.AddCommandString("kubectl", []string{"argo", "rollouts", "pause", "-n", "production", "myapp"}, expectedOutput, nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handlePauseRollout(ctx, &mcp.CallToolRequest{}, pauseRolloutInput{RolloutName: "myapp", Namespace: "production"}) + result, _, err := handlePauseRollout(ctx, &sdkmcp.CallToolRequest{}, pauseRolloutInput{RolloutName: "myapp", Namespace: "production"}) assert.NoError(t, err) assert.False(t, result.IsError) @@ -314,7 +314,7 @@ func TestHandlePauseRollout(t *testing.T) { mock := cmd.NewMockShellExecutor() ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handlePauseRollout(ctx, &mcp.CallToolRequest{}, pauseRolloutInput{}) + result, _, err := handlePauseRollout(ctx, &sdkmcp.CallToolRequest{}, pauseRolloutInput{}) assert.NoError(t, err) assert.True(t, result.IsError) assert.Contains(t, getResultText(result), "rollout_name parameter is required") @@ -334,7 +334,7 @@ func TestHandleSetRolloutImage(t *testing.T) { mock.AddCommandString("kubectl", []string{"argo", "rollouts", "set", "image", "myapp", "nginx:latest"}, expectedOutput, nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleSetRolloutImage(ctx, &mcp.CallToolRequest{}, setRolloutImageInput{RolloutName: "myapp", ContainerImage: "nginx:latest"}) + result, _, err := handleSetRolloutImage(ctx, &sdkmcp.CallToolRequest{}, setRolloutImageInput{RolloutName: "myapp", ContainerImage: "nginx:latest"}) assert.NoError(t, err) assert.NotNil(t, result) @@ -358,7 +358,7 @@ func TestHandleSetRolloutImage(t *testing.T) { mock.AddCommandString("kubectl", []string{"argo", "rollouts", "set", "image", "myapp", "nginx:1.20", "-n", "production"}, expectedOutput, nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleSetRolloutImage(ctx, &mcp.CallToolRequest{}, setRolloutImageInput{RolloutName: "myapp", ContainerImage: "nginx:1.20", Namespace: "production"}) + result, _, err := handleSetRolloutImage(ctx, &sdkmcp.CallToolRequest{}, setRolloutImageInput{RolloutName: "myapp", ContainerImage: "nginx:1.20", Namespace: "production"}) assert.NoError(t, err) assert.False(t, result.IsError) @@ -374,7 +374,7 @@ func TestHandleSetRolloutImage(t *testing.T) { mock := cmd.NewMockShellExecutor() ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleSetRolloutImage(ctx, &mcp.CallToolRequest{}, setRolloutImageInput{ContainerImage: "nginx:latest"}) + result, _, err := handleSetRolloutImage(ctx, &sdkmcp.CallToolRequest{}, setRolloutImageInput{ContainerImage: "nginx:latest"}) assert.NoError(t, err) assert.True(t, result.IsError) assert.Contains(t, getResultText(result), "rollout_name parameter is required") @@ -388,7 +388,7 @@ func TestHandleSetRolloutImage(t *testing.T) { mock := cmd.NewMockShellExecutor() ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleSetRolloutImage(ctx, &mcp.CallToolRequest{}, setRolloutImageInput{RolloutName: "myapp"}) + result, _, err := handleSetRolloutImage(ctx, &sdkmcp.CallToolRequest{}, setRolloutImageInput{RolloutName: "myapp"}) assert.NoError(t, err) assert.True(t, result.IsError) assert.Contains(t, getResultText(result), "container_image parameter is required") @@ -456,7 +456,7 @@ func TestHandleVerifyGatewayPlugin(t *testing.T) { ctx := cmd.WithShellExecutor(context.Background(), mock) shouldInstall := false - result, _, err := handleVerifyGatewayPlugin(ctx, &mcp.CallToolRequest{}, verifyGatewayPluginInput{ShouldInstall: &shouldInstall}) + result, _, err := handleVerifyGatewayPlugin(ctx, &sdkmcp.CallToolRequest{}, verifyGatewayPluginInput{ShouldInstall: &shouldInstall}) assert.NoError(t, err) assert.NotNil(t, result) @@ -479,7 +479,7 @@ func TestHandleVerifyGatewayPlugin(t *testing.T) { ctx := cmd.WithShellExecutor(context.Background(), mock) shouldInstall := false - result, _, err := handleVerifyGatewayPlugin(ctx, &mcp.CallToolRequest{}, verifyGatewayPluginInput{ShouldInstall: &shouldInstall, Namespace: "custom-namespace"}) + result, _, err := handleVerifyGatewayPlugin(ctx, &sdkmcp.CallToolRequest{}, verifyGatewayPluginInput{ShouldInstall: &shouldInstall, Namespace: "custom-namespace"}) assert.NoError(t, err) assert.NotNil(t, result) @@ -502,7 +502,7 @@ func TestHandleVerifyArgoRolloutsControllerInstall(t *testing.T) { mock.AddCommandString("kubectl", []string{"get", "pods", "-l", "app.kubernetes.io/name=argo-rollouts", "-n", "argo-rollouts", "-o", "jsonpath={.items[*].metadata.name}"}, expectedOutput, nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleVerifyArgoRolloutsControllerInstall(ctx, &mcp.CallToolRequest{}, verifyArgoRolloutsControllerInstallInput{}) + result, _, err := handleVerifyArgoRolloutsControllerInstall(ctx, &sdkmcp.CallToolRequest{}, verifyArgoRolloutsControllerInstallInput{}) assert.NoError(t, err) assert.NotNil(t, result) @@ -522,7 +522,7 @@ func TestHandleVerifyArgoRolloutsControllerInstall(t *testing.T) { mock.AddCommandString("kubectl", []string{"get", "pods", "-l", "app.kubernetes.io/name=argo-rollouts", "-n", "custom-argo", "-o", "jsonpath={.items[*].metadata.name}"}, expectedOutput, nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleVerifyArgoRolloutsControllerInstall(ctx, &mcp.CallToolRequest{}, verifyArgoRolloutsControllerInstallInput{Namespace: "custom-argo"}) + result, _, err := handleVerifyArgoRolloutsControllerInstall(ctx, &sdkmcp.CallToolRequest{}, verifyArgoRolloutsControllerInstallInput{Namespace: "custom-argo"}) assert.NoError(t, err) assert.NotNil(t, result) @@ -542,7 +542,7 @@ func TestHandleVerifyArgoRolloutsControllerInstall(t *testing.T) { mock.AddCommandString("kubectl", []string{"get", "pods", "-l", "app=custom-rollouts", "-n", "argo-rollouts", "-o", "jsonpath={.items[*].metadata.name}"}, expectedOutput, nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleVerifyArgoRolloutsControllerInstall(ctx, &mcp.CallToolRequest{}, verifyArgoRolloutsControllerInstallInput{Label: "app=custom-rollouts"}) + result, _, err := handleVerifyArgoRolloutsControllerInstall(ctx, &sdkmcp.CallToolRequest{}, verifyArgoRolloutsControllerInstallInput{Label: "app=custom-rollouts"}) assert.NoError(t, err) assert.NotNil(t, result) @@ -565,7 +565,7 @@ func TestHandleVerifyKubectlPluginInstall(t *testing.T) { mock.AddCommandString("kubectl", []string{"argo", "rollouts", "version"}, expectedOutput, nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleVerifyKubectlPluginInstall(ctx, &mcp.CallToolRequest{}, verifyKubectlPluginInstallInput{}) + result, _, err := handleVerifyKubectlPluginInstall(ctx, &sdkmcp.CallToolRequest{}, verifyKubectlPluginInstallInput{}) assert.NoError(t, err) assert.False(t, result.IsError) @@ -582,7 +582,7 @@ func TestHandleVerifyKubectlPluginInstall(t *testing.T) { mock.AddCommandString("kubectl", []string{"plugin", "list"}, "", assert.AnError) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleVerifyKubectlPluginInstall(ctx, &mcp.CallToolRequest{}, verifyKubectlPluginInstallInput{}) + result, _, err := handleVerifyKubectlPluginInstall(ctx, &sdkmcp.CallToolRequest{}, verifyKubectlPluginInstallInput{}) assert.NoError(t, err) // MCP handlers should not return Go errors assert.NotNil(t, result) diff --git a/pkg/cilium/cilium.go b/pkg/cilium/cilium.go index 445b740e..b293e5da 100644 --- a/pkg/cilium/cilium.go +++ b/pkg/cilium/cilium.go @@ -8,6 +8,7 @@ import ( "github.com/kagent-dev/tools/internal/commands" mcp "github.com/kagent-dev/tools/internal/mcp" "github.com/kagent-dev/tools/pkg/utils" + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" ) type noInput struct{} @@ -219,7 +220,7 @@ func runCiliumCliWithContext(ctx context.Context, args ...string) (string, error Execute(ctx) } -func handleCiliumStatusAndVersion(ctx context.Context, request *mcp.CallToolRequest, in noInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleCiliumStatusAndVersion(ctx context.Context, request *sdkmcp.CallToolRequest, in noInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { status, err := runCiliumCliWithContext(ctx, "status") if err != nil { return mcp.TextError("Error getting Cilium status: " + err.Error()) @@ -234,7 +235,7 @@ func handleCiliumStatusAndVersion(ctx context.Context, request *mcp.CallToolRequ return mcp.TextResult(result) } -func handleUpgradeCilium(ctx context.Context, request *mcp.CallToolRequest, in upgradeCiliumInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleUpgradeCilium(ctx context.Context, request *sdkmcp.CallToolRequest, in upgradeCiliumInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { clusterName := in.ClusterName datapathMode := in.DatapathMode @@ -254,7 +255,7 @@ func handleUpgradeCilium(ctx context.Context, request *mcp.CallToolRequest, in u return mcp.TextResult(output) } -func handleInstallCilium(ctx context.Context, request *mcp.CallToolRequest, in installCiliumInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleInstallCilium(ctx context.Context, request *sdkmcp.CallToolRequest, in installCiliumInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { clusterName := in.ClusterName clusterID := in.ClusterID datapathMode := in.DatapathMode @@ -278,7 +279,7 @@ func handleInstallCilium(ctx context.Context, request *mcp.CallToolRequest, in i return mcp.TextResult(output) } -func handleUninstallCilium(ctx context.Context, request *mcp.CallToolRequest, in noInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleUninstallCilium(ctx context.Context, request *sdkmcp.CallToolRequest, in noInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { output, err := runCiliumCliWithContext(ctx, "uninstall") if err != nil { return mcp.TextError("Error uninstalling Cilium: " + err.Error()) @@ -287,7 +288,7 @@ func handleUninstallCilium(ctx context.Context, request *mcp.CallToolRequest, in return mcp.TextResult(output) } -func handleConnectToRemoteCluster(ctx context.Context, request *mcp.CallToolRequest, in connectToRemoteClusterInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleConnectToRemoteCluster(ctx context.Context, request *sdkmcp.CallToolRequest, in connectToRemoteClusterInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { clusterName := in.ClusterName destContext := in.Context @@ -308,7 +309,7 @@ func handleConnectToRemoteCluster(ctx context.Context, request *mcp.CallToolRequ return mcp.TextResult(output) } -func handleDisconnectRemoteCluster(ctx context.Context, request *mcp.CallToolRequest, in disconnectRemoteClusterInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleDisconnectRemoteCluster(ctx context.Context, request *sdkmcp.CallToolRequest, in disconnectRemoteClusterInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { clusterName := in.ClusterName if clusterName == "" { @@ -325,7 +326,7 @@ func handleDisconnectRemoteCluster(ctx context.Context, request *mcp.CallToolReq return mcp.TextResult(output) } -func handleListBGPPeers(ctx context.Context, request *mcp.CallToolRequest, in noInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleListBGPPeers(ctx context.Context, request *sdkmcp.CallToolRequest, in noInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { output, err := runCiliumCliWithContext(ctx, "bgp", "peers") if err != nil { return mcp.TextError("Error listing BGP peers: " + err.Error()) @@ -334,7 +335,7 @@ func handleListBGPPeers(ctx context.Context, request *mcp.CallToolRequest, in no return mcp.TextResult(output) } -func handleListBGPRoutes(ctx context.Context, request *mcp.CallToolRequest, in noInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleListBGPRoutes(ctx context.Context, request *sdkmcp.CallToolRequest, in noInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { output, err := runCiliumCliWithContext(ctx, "bgp", "routes") if err != nil { return mcp.TextError("Error listing BGP routes: " + err.Error()) @@ -343,7 +344,7 @@ func handleListBGPRoutes(ctx context.Context, request *mcp.CallToolRequest, in n return mcp.TextResult(output) } -func handleShowClusterMeshStatus(ctx context.Context, request *mcp.CallToolRequest, in noInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleShowClusterMeshStatus(ctx context.Context, request *sdkmcp.CallToolRequest, in noInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { output, err := runCiliumCliWithContext(ctx, "clustermesh", "status") if err != nil { return mcp.TextError("Error getting cluster mesh status: " + err.Error()) @@ -352,7 +353,7 @@ func handleShowClusterMeshStatus(ctx context.Context, request *mcp.CallToolReque return mcp.TextResult(output) } -func handleShowFeaturesStatus(ctx context.Context, request *mcp.CallToolRequest, in noInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleShowFeaturesStatus(ctx context.Context, request *sdkmcp.CallToolRequest, in noInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { output, err := runCiliumCliWithContext(ctx, "features", "status") if err != nil { return mcp.TextError("Error getting features status: " + err.Error()) @@ -361,7 +362,7 @@ func handleShowFeaturesStatus(ctx context.Context, request *mcp.CallToolRequest, return mcp.TextResult(output) } -func handleToggleHubble(ctx context.Context, request *mcp.CallToolRequest, in enableToggleInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleToggleHubble(ctx context.Context, request *sdkmcp.CallToolRequest, in enableToggleInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { enable := true if in.Enable != nil { enable = *in.Enable @@ -381,7 +382,7 @@ func handleToggleHubble(ctx context.Context, request *mcp.CallToolRequest, in en return mcp.TextResult(output) } -func handleToggleClusterMesh(ctx context.Context, request *mcp.CallToolRequest, in enableToggleInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleToggleClusterMesh(ctx context.Context, request *sdkmcp.CallToolRequest, in enableToggleInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { enable := true if in.Enable != nil { enable = *in.Enable @@ -401,105 +402,105 @@ func handleToggleClusterMesh(ctx context.Context, request *mcp.CallToolRequest, return mcp.TextResult(output) } -func RegisterTools(s *mcp.Server, readOnly bool) { +func RegisterTools(s *sdkmcp.Server, readOnly bool) { // Read-only tools - always registered - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_status_and_version", Description: "Get the status and version of Cilium installation"}, handleCiliumStatusAndVersion) - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_list_bgp_peers", Description: "List BGP peers"}, handleListBGPPeers) - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_list_bgp_routes", Description: "List BGP routes"}, handleListBGPRoutes) - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_show_cluster_mesh_status", Description: "Show cluster mesh status"}, handleShowClusterMeshStatus) - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_show_features_status", Description: "Show Cilium features status"}, handleShowFeaturesStatus) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_status_and_version", Description: "Get the status and version of Cilium installation"}, handleCiliumStatusAndVersion) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_list_bgp_peers", Description: "List BGP peers"}, handleListBGPPeers) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_list_bgp_routes", Description: "List BGP routes"}, handleListBGPRoutes) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_show_cluster_mesh_status", Description: "Show cluster mesh status"}, handleShowClusterMeshStatus) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_show_features_status", Description: "Show Cilium features status"}, handleShowFeaturesStatus) if !readOnly { - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_upgrade_cilium", Description: "Upgrade Cilium on the cluster"}, handleUpgradeCilium) - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_install_cilium", Description: "Install Cilium on the cluster"}, handleInstallCilium) - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_uninstall_cilium", Description: "Uninstall Cilium from the cluster"}, handleUninstallCilium) - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_connect_to_remote_cluster", Description: "Connect to a remote cluster for cluster mesh"}, handleConnectToRemoteCluster) - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_disconnect_remote_cluster", Description: "Disconnect from a remote cluster"}, handleDisconnectRemoteCluster) - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_toggle_hubble", Description: "Enable or disable Hubble"}, handleToggleHubble) - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_toggle_cluster_mesh", Description: "Enable or disable cluster mesh"}, handleToggleClusterMesh) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_upgrade_cilium", Description: "Upgrade Cilium on the cluster"}, handleUpgradeCilium) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_install_cilium", Description: "Install Cilium on the cluster"}, handleInstallCilium) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_uninstall_cilium", Description: "Uninstall Cilium from the cluster"}, handleUninstallCilium) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_connect_to_remote_cluster", Description: "Connect to a remote cluster for cluster mesh"}, handleConnectToRemoteCluster) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_disconnect_remote_cluster", Description: "Disconnect from a remote cluster"}, handleDisconnectRemoteCluster) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_toggle_hubble", Description: "Enable or disable Hubble"}, handleToggleHubble) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_toggle_cluster_mesh", Description: "Enable or disable cluster mesh"}, handleToggleClusterMesh) } - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_get_daemon_status", Description: "Get the status of the Cilium daemon for the cluster"}, handleGetDaemonStatus) - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_get_endpoints_list", Description: "Get the list of all endpoints in the cluster"}, handleGetEndpointsList) - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_get_endpoint_details", Description: "List the details of an endpoint in the cluster"}, handleGetEndpointDetails) - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_show_configuration_options", Description: "Show Cilium configuration options"}, handleShowConfigurationOptions) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_get_daemon_status", Description: "Get the status of the Cilium daemon for the cluster"}, handleGetDaemonStatus) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_get_endpoints_list", Description: "Get the list of all endpoints in the cluster"}, handleGetEndpointsList) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_get_endpoint_details", Description: "List the details of an endpoint in the cluster"}, handleGetEndpointDetails) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_show_configuration_options", Description: "Show Cilium configuration options"}, handleShowConfigurationOptions) if !readOnly { - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_toggle_configuration_option", Description: "Toggle a Cilium configuration option"}, handleToggleConfigurationOption) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_toggle_configuration_option", Description: "Toggle a Cilium configuration option"}, handleToggleConfigurationOption) } - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_list_services", Description: "List services for the cluster"}, handleListServices) - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_get_service_information", Description: "Get information about a service in the cluster"}, handleGetServiceInformation) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_list_services", Description: "List services for the cluster"}, handleListServices) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_get_service_information", Description: "Get information about a service in the cluster"}, handleGetServiceInformation) if !readOnly { - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_update_service", Description: "Update a service in the cluster"}, handleUpdateService) - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_delete_service", Description: "Delete a service from the cluster"}, handleDeleteService) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_update_service", Description: "Update a service in the cluster"}, handleUpdateService) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_delete_service", Description: "Delete a service from the cluster"}, handleDeleteService) } - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_get_endpoint_details", Description: "List the details of an endpoint in the cluster"}, handleGetEndpointDetails) - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_get_endpoint_logs", Description: "Get the logs of an endpoint in the cluster"}, handleGetEndpointLogs) - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_get_endpoint_health", Description: "Get the health of an endpoint in the cluster"}, handleGetEndpointHealth) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_get_endpoint_details", Description: "List the details of an endpoint in the cluster"}, handleGetEndpointDetails) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_get_endpoint_logs", Description: "Get the logs of an endpoint in the cluster"}, handleGetEndpointLogs) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_get_endpoint_health", Description: "Get the health of an endpoint in the cluster"}, handleGetEndpointHealth) if !readOnly { - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_manage_endpoint_labels", Description: "Manage the labels (add or delete) of an endpoint in the cluster"}, handleManageEndpointLabels) - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_manage_endpoint_config", Description: "Manage the configuration of an endpoint in the cluster"}, handleManageEndpointConfiguration) - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_disconnect_endpoint", Description: "Disconnect an endpoint from the network"}, handleDisconnectEndpoint) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_manage_endpoint_labels", Description: "Manage the labels (add or delete) of an endpoint in the cluster"}, handleManageEndpointLabels) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_manage_endpoint_config", Description: "Manage the configuration of an endpoint in the cluster"}, handleManageEndpointConfiguration) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_disconnect_endpoint", Description: "Disconnect an endpoint from the network"}, handleDisconnectEndpoint) } - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_list_identities", Description: "List all identities in the cluster"}, handleListIdentities) - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_get_identity_details", Description: "Get the details of an identity in the cluster"}, handleGetIdentityDetails) - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_request_debugging_information", Description: "Request debugging information for the cluster"}, handleRequestDebuggingInformation) - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_display_encryption_state", Description: "Display the encryption state for the cluster"}, handleDisplayEncryptionState) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_list_identities", Description: "List all identities in the cluster"}, handleListIdentities) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_get_identity_details", Description: "Get the details of an identity in the cluster"}, handleGetIdentityDetails) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_request_debugging_information", Description: "Request debugging information for the cluster"}, handleRequestDebuggingInformation) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_display_encryption_state", Description: "Display the encryption state for the cluster"}, handleDisplayEncryptionState) if !readOnly { - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_flush_ipsec_state", Description: "Flush the IPsec state for the cluster"}, handleFlushIPsecState) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_flush_ipsec_state", Description: "Flush the IPsec state for the cluster"}, handleFlushIPsecState) } - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_list_envoy_config", Description: "List the Envoy configuration for a resource in the cluster"}, handleListEnvoyConfig) - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_fqdn_cache", Description: "Manage the FQDN cache for the cluster"}, handleFQDNCache) - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_show_dns_names", Description: "Show the DNS names for the cluster"}, handleShowDNSNames) - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_list_ip_addresses", Description: "List the IP addresses for the cluster"}, handleListIPAddresses) - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_show_ip_cache_information", Description: "Show the IP cache information for the cluster"}, handleShowIPCacheInformation) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_list_envoy_config", Description: "List the Envoy configuration for a resource in the cluster"}, handleListEnvoyConfig) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_fqdn_cache", Description: "Manage the FQDN cache for the cluster"}, handleFQDNCache) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_show_dns_names", Description: "Show the DNS names for the cluster"}, handleShowDNSNames) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_list_ip_addresses", Description: "List the IP addresses for the cluster"}, handleListIPAddresses) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_show_ip_cache_information", Description: "Show the IP cache information for the cluster"}, handleShowIPCacheInformation) if !readOnly { - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_delete_key_from_kv_store", Description: "Delete a key from the kvstore for the cluster"}, handleDeleteKeyFromKVStore) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_delete_key_from_kv_store", Description: "Delete a key from the kvstore for the cluster"}, handleDeleteKeyFromKVStore) } - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_get_kv_store_key", Description: "Get a key from the kvstore for the cluster"}, handleGetKVStoreKey) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_get_kv_store_key", Description: "Get a key from the kvstore for the cluster"}, handleGetKVStoreKey) if !readOnly { - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_set_kv_store_key", Description: "Set a key in the kvstore for the cluster"}, handleSetKVStoreKey) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_set_kv_store_key", Description: "Set a key in the kvstore for the cluster"}, handleSetKVStoreKey) } - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_show_load_information", Description: "Show load information for the cluster"}, handleShowLoadInformation) - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_list_local_redirect_policies", Description: "List local redirect policies for the cluster"}, handleListLocalRedirectPolicies) - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_list_bpf_map_events", Description: "List BPF map events for the cluster"}, handleListBPFMapEvents) - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_get_bpf_map", Description: "Get BPF map for the cluster"}, handleGetBPFMap) - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_list_bpf_maps", Description: "List BPF maps for the cluster"}, handleListBPFMaps) - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_list_metrics", Description: "List metrics for the cluster"}, handleListMetrics) - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_list_cluster_nodes", Description: "List cluster nodes for the cluster"}, handleListClusterNodes) - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_list_node_ids", Description: "List node IDs for the cluster"}, handleListNodeIds) - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_display_policy_node_information", Description: "Display policy node information for the cluster"}, handleDisplayPolicyNodeInformation) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_show_load_information", Description: "Show load information for the cluster"}, handleShowLoadInformation) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_list_local_redirect_policies", Description: "List local redirect policies for the cluster"}, handleListLocalRedirectPolicies) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_list_bpf_map_events", Description: "List BPF map events for the cluster"}, handleListBPFMapEvents) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_get_bpf_map", Description: "Get BPF map for the cluster"}, handleGetBPFMap) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_list_bpf_maps", Description: "List BPF maps for the cluster"}, handleListBPFMaps) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_list_metrics", Description: "List metrics for the cluster"}, handleListMetrics) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_list_cluster_nodes", Description: "List cluster nodes for the cluster"}, handleListClusterNodes) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_list_node_ids", Description: "List node IDs for the cluster"}, handleListNodeIds) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_display_policy_node_information", Description: "Display policy node information for the cluster"}, handleDisplayPolicyNodeInformation) if !readOnly { - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_delete_policy_rules", Description: "Delete policy rules for the cluster"}, handleDeletePolicyRules) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_delete_policy_rules", Description: "Delete policy rules for the cluster"}, handleDeletePolicyRules) } - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_display_selectors", Description: "Display selectors for the cluster"}, handleDisplaySelectors) - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_list_xdp_cidr_filters", Description: "List XDP CIDR filters for the cluster"}, handleListXDPCIDRFilters) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_display_selectors", Description: "Display selectors for the cluster"}, handleDisplaySelectors) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_list_xdp_cidr_filters", Description: "List XDP CIDR filters for the cluster"}, handleListXDPCIDRFilters) if !readOnly { - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_update_xdp_cidr_filters", Description: "Update XDP CIDR filters for the cluster"}, handleUpdateXDPCIDRFilters) - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_delete_xdp_cidr_filters", Description: "Delete XDP CIDR filters for the cluster"}, handleDeleteXDPCIDRFilters) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_update_xdp_cidr_filters", Description: "Update XDP CIDR filters for the cluster"}, handleUpdateXDPCIDRFilters) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_delete_xdp_cidr_filters", Description: "Delete XDP CIDR filters for the cluster"}, handleDeleteXDPCIDRFilters) } - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_validate_cilium_network_policies", Description: "Validate Cilium network policies for the cluster"}, handleValidateCiliumNetworkPolicies) - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_list_pcap_recorders", Description: "List PCAP recorders for the cluster"}, handleListPCAPRecorders) - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_get_pcap_recorder", Description: "Get a PCAP recorder for the cluster"}, handleGetPCAPRecorder) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_validate_cilium_network_policies", Description: "Validate Cilium network policies for the cluster"}, handleValidateCiliumNetworkPolicies) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_list_pcap_recorders", Description: "List PCAP recorders for the cluster"}, handleListPCAPRecorders) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_get_pcap_recorder", Description: "Get a PCAP recorder for the cluster"}, handleGetPCAPRecorder) if !readOnly { - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_delete_pcap_recorder", Description: "Delete a PCAP recorder for the cluster"}, handleDeletePCAPRecorder) - mcp.AddTool(s, "cilium", &mcp.Tool{Name: "cilium_update_pcap_recorder", Description: "Update a PCAP recorder for the cluster"}, handleUpdatePCAPRecorder) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_delete_pcap_recorder", Description: "Delete a PCAP recorder for the cluster"}, handleDeletePCAPRecorder) + mcp.AddTool(s, "cilium", &sdkmcp.Tool{Name: "cilium_update_pcap_recorder", Description: "Update a PCAP recorder for the cluster"}, handleUpdatePCAPRecorder) } } @@ -532,7 +533,7 @@ func runCiliumDbgCommandWithContext(ctx context.Context, command, nodeName strin Execute(ctx) } -func handleGetEndpointDetails(ctx context.Context, request *mcp.CallToolRequest, in getEndpointDetailsInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleGetEndpointDetails(ctx context.Context, request *sdkmcp.CallToolRequest, in getEndpointDetailsInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { if in.OutputFormat == "" { in.OutputFormat = "json" } @@ -557,7 +558,7 @@ func handleGetEndpointDetails(ctx context.Context, request *mcp.CallToolRequest, return mcp.TextResult(output) } -func handleGetEndpointLogs(ctx context.Context, request *mcp.CallToolRequest, in getEndpointLogsInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleGetEndpointLogs(ctx context.Context, request *sdkmcp.CallToolRequest, in getEndpointLogsInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { endpointID := in.EndpointID nodeName := in.NodeName @@ -573,7 +574,7 @@ func handleGetEndpointLogs(ctx context.Context, request *mcp.CallToolRequest, in return mcp.TextResult(output) } -func handleGetEndpointHealth(ctx context.Context, request *mcp.CallToolRequest, in getEndpointHealthInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleGetEndpointHealth(ctx context.Context, request *sdkmcp.CallToolRequest, in getEndpointHealthInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { endpointID := in.EndpointID nodeName := in.NodeName @@ -589,7 +590,7 @@ func handleGetEndpointHealth(ctx context.Context, request *mcp.CallToolRequest, return mcp.TextResult(output) } -func handleManageEndpointLabels(ctx context.Context, request *mcp.CallToolRequest, in manageEndpointLabelsInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleManageEndpointLabels(ctx context.Context, request *sdkmcp.CallToolRequest, in manageEndpointLabelsInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { if in.Action == "" { in.Action = "add" } @@ -610,7 +611,7 @@ func handleManageEndpointLabels(ctx context.Context, request *mcp.CallToolReques return mcp.TextResult(output) } -func handleManageEndpointConfiguration(ctx context.Context, request *mcp.CallToolRequest, in manageEndpointConfigurationInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleManageEndpointConfiguration(ctx context.Context, request *sdkmcp.CallToolRequest, in manageEndpointConfigurationInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { endpointID := in.EndpointID config := in.Config nodeName := in.NodeName @@ -631,7 +632,7 @@ func handleManageEndpointConfiguration(ctx context.Context, request *mcp.CallToo return mcp.TextResult(output) } -func handleDisconnectEndpoint(ctx context.Context, request *mcp.CallToolRequest, in disconnectEndpointInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleDisconnectEndpoint(ctx context.Context, request *sdkmcp.CallToolRequest, in disconnectEndpointInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { endpointID := in.EndpointID nodeName := in.NodeName @@ -647,7 +648,7 @@ func handleDisconnectEndpoint(ctx context.Context, request *mcp.CallToolRequest, return mcp.TextResult(output) } -func handleGetEndpointsList(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleGetEndpointsList(ctx context.Context, request *sdkmcp.CallToolRequest, in nodeNameInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "endpoint list", nodeName) @@ -657,7 +658,7 @@ func handleGetEndpointsList(ctx context.Context, request *mcp.CallToolRequest, i return mcp.TextResult(output) } -func handleListIdentities(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleListIdentities(ctx context.Context, request *sdkmcp.CallToolRequest, in nodeNameInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "identity list", nodeName) @@ -667,7 +668,7 @@ func handleListIdentities(ctx context.Context, request *mcp.CallToolRequest, in return mcp.TextResult(output) } -func handleGetIdentityDetails(ctx context.Context, request *mcp.CallToolRequest, in getIdentityDetailsInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleGetIdentityDetails(ctx context.Context, request *sdkmcp.CallToolRequest, in getIdentityDetailsInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { identityID := in.IdentityID nodeName := in.NodeName @@ -683,7 +684,7 @@ func handleGetIdentityDetails(ctx context.Context, request *mcp.CallToolRequest, return mcp.TextResult(output) } -func handleShowConfigurationOptions(ctx context.Context, request *mcp.CallToolRequest, in showConfigurationOptionsInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleShowConfigurationOptions(ctx context.Context, request *sdkmcp.CallToolRequest, in showConfigurationOptionsInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { listAll := in.ListAll listReadOnly := in.ListReadOnly listOptions := in.ListOptions @@ -707,7 +708,7 @@ func handleShowConfigurationOptions(ctx context.Context, request *mcp.CallToolRe return mcp.TextResult(output) } -func handleToggleConfigurationOption(ctx context.Context, request *mcp.CallToolRequest, in toggleConfigurationOptionInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleToggleConfigurationOption(ctx context.Context, request *sdkmcp.CallToolRequest, in toggleConfigurationOptionInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { option := in.Option value := true if in.Value != nil { @@ -732,7 +733,7 @@ func handleToggleConfigurationOption(ctx context.Context, request *mcp.CallToolR return mcp.TextResult(output) } -func handleRequestDebuggingInformation(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleRequestDebuggingInformation(ctx context.Context, request *sdkmcp.CallToolRequest, in nodeNameInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "debuginfo", nodeName) @@ -742,7 +743,7 @@ func handleRequestDebuggingInformation(ctx context.Context, request *mcp.CallToo return mcp.TextResult(output) } -func handleDisplayEncryptionState(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleDisplayEncryptionState(ctx context.Context, request *sdkmcp.CallToolRequest, in nodeNameInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "encrypt status", nodeName) @@ -752,7 +753,7 @@ func handleDisplayEncryptionState(ctx context.Context, request *mcp.CallToolRequ return mcp.TextResult(output) } -func handleFlushIPsecState(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleFlushIPsecState(ctx context.Context, request *sdkmcp.CallToolRequest, in nodeNameInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "encrypt flush -f", nodeName) @@ -762,7 +763,7 @@ func handleFlushIPsecState(ctx context.Context, request *mcp.CallToolRequest, in return mcp.TextResult(output) } -func handleListEnvoyConfig(ctx context.Context, request *mcp.CallToolRequest, in listEnvoyConfigInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleListEnvoyConfig(ctx context.Context, request *sdkmcp.CallToolRequest, in listEnvoyConfigInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { resourceName := in.ResourceName nodeName := in.NodeName @@ -778,7 +779,7 @@ func handleListEnvoyConfig(ctx context.Context, request *mcp.CallToolRequest, in return mcp.TextResult(output) } -func handleFQDNCache(ctx context.Context, request *mcp.CallToolRequest, in fqdnCacheInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleFQDNCache(ctx context.Context, request *sdkmcp.CallToolRequest, in fqdnCacheInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { if in.Command == "" { in.Command = "list" } @@ -799,7 +800,7 @@ func handleFQDNCache(ctx context.Context, request *mcp.CallToolRequest, in fqdnC return mcp.TextResult(output) } -func handleShowDNSNames(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleShowDNSNames(ctx context.Context, request *sdkmcp.CallToolRequest, in nodeNameInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "fqdn names", nodeName) @@ -809,7 +810,7 @@ func handleShowDNSNames(ctx context.Context, request *mcp.CallToolRequest, in no return mcp.TextResult(output) } -func handleListIPAddresses(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleListIPAddresses(ctx context.Context, request *sdkmcp.CallToolRequest, in nodeNameInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "ip list", nodeName) @@ -819,7 +820,7 @@ func handleListIPAddresses(ctx context.Context, request *mcp.CallToolRequest, in return mcp.TextResult(output) } -func handleShowIPCacheInformation(ctx context.Context, request *mcp.CallToolRequest, in showIPCacheInformationInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleShowIPCacheInformation(ctx context.Context, request *sdkmcp.CallToolRequest, in showIPCacheInformationInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { cidr := in.CIDR labels := in.Labels nodeName := in.NodeName @@ -840,7 +841,7 @@ func handleShowIPCacheInformation(ctx context.Context, request *mcp.CallToolRequ return mcp.TextResult(output) } -func handleDeleteKeyFromKVStore(ctx context.Context, request *mcp.CallToolRequest, in kvStoreKeyInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleDeleteKeyFromKVStore(ctx context.Context, request *sdkmcp.CallToolRequest, in kvStoreKeyInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { key := in.Key nodeName := in.NodeName @@ -856,7 +857,7 @@ func handleDeleteKeyFromKVStore(ctx context.Context, request *mcp.CallToolReques return mcp.TextResult(output) } -func handleGetKVStoreKey(ctx context.Context, request *mcp.CallToolRequest, in kvStoreKeyInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleGetKVStoreKey(ctx context.Context, request *sdkmcp.CallToolRequest, in kvStoreKeyInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { key := in.Key nodeName := in.NodeName @@ -872,7 +873,7 @@ func handleGetKVStoreKey(ctx context.Context, request *mcp.CallToolRequest, in k return mcp.TextResult(output) } -func handleSetKVStoreKey(ctx context.Context, request *mcp.CallToolRequest, in setKVStoreKeyInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleSetKVStoreKey(ctx context.Context, request *sdkmcp.CallToolRequest, in setKVStoreKeyInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { key := in.Key value := in.Value nodeName := in.NodeName @@ -889,7 +890,7 @@ func handleSetKVStoreKey(ctx context.Context, request *mcp.CallToolRequest, in s return mcp.TextResult(output) } -func handleShowLoadInformation(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleShowLoadInformation(ctx context.Context, request *sdkmcp.CallToolRequest, in nodeNameInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "loadinfo", nodeName) @@ -899,7 +900,7 @@ func handleShowLoadInformation(ctx context.Context, request *mcp.CallToolRequest return mcp.TextResult(output) } -func handleListLocalRedirectPolicies(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleListLocalRedirectPolicies(ctx context.Context, request *sdkmcp.CallToolRequest, in nodeNameInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "lrp list", nodeName) @@ -909,7 +910,7 @@ func handleListLocalRedirectPolicies(ctx context.Context, request *mcp.CallToolR return mcp.TextResult(output) } -func handleListBPFMapEvents(ctx context.Context, request *mcp.CallToolRequest, in bpfMapInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleListBPFMapEvents(ctx context.Context, request *sdkmcp.CallToolRequest, in bpfMapInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { mapName := in.MapName nodeName := in.NodeName @@ -925,7 +926,7 @@ func handleListBPFMapEvents(ctx context.Context, request *mcp.CallToolRequest, i return mcp.TextResult(output) } -func handleGetBPFMap(ctx context.Context, request *mcp.CallToolRequest, in bpfMapInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleGetBPFMap(ctx context.Context, request *sdkmcp.CallToolRequest, in bpfMapInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { mapName := in.MapName nodeName := in.NodeName @@ -941,7 +942,7 @@ func handleGetBPFMap(ctx context.Context, request *mcp.CallToolRequest, in bpfMa return mcp.TextResult(output) } -func handleListBPFMaps(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleListBPFMaps(ctx context.Context, request *sdkmcp.CallToolRequest, in nodeNameInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "map list", nodeName) @@ -951,7 +952,7 @@ func handleListBPFMaps(ctx context.Context, request *mcp.CallToolRequest, in nod return mcp.TextResult(output) } -func handleListMetrics(ctx context.Context, request *mcp.CallToolRequest, in listMetricsInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleListMetrics(ctx context.Context, request *sdkmcp.CallToolRequest, in listMetricsInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { matchPattern := in.MatchPattern nodeName := in.NodeName @@ -969,7 +970,7 @@ func handleListMetrics(ctx context.Context, request *mcp.CallToolRequest, in lis return mcp.TextResult(output) } -func handleListClusterNodes(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleListClusterNodes(ctx context.Context, request *sdkmcp.CallToolRequest, in nodeNameInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "node list", nodeName) @@ -979,7 +980,7 @@ func handleListClusterNodes(ctx context.Context, request *mcp.CallToolRequest, i return mcp.TextResult(output) } -func handleListNodeIds(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleListNodeIds(ctx context.Context, request *sdkmcp.CallToolRequest, in nodeNameInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "nodeid list", nodeName) @@ -989,7 +990,7 @@ func handleListNodeIds(ctx context.Context, request *mcp.CallToolRequest, in nod return mcp.TextResult(output) } -func handleDisplayPolicyNodeInformation(ctx context.Context, request *mcp.CallToolRequest, in displayPolicyNodeInformationInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleDisplayPolicyNodeInformation(ctx context.Context, request *sdkmcp.CallToolRequest, in displayPolicyNodeInformationInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { labels := in.Labels nodeName := in.NodeName @@ -1007,7 +1008,7 @@ func handleDisplayPolicyNodeInformation(ctx context.Context, request *mcp.CallTo return mcp.TextResult(output) } -func handleDeletePolicyRules(ctx context.Context, request *mcp.CallToolRequest, in deletePolicyRulesInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleDeletePolicyRules(ctx context.Context, request *sdkmcp.CallToolRequest, in deletePolicyRulesInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { labels := in.Labels all := in.All nodeName := in.NodeName @@ -1028,7 +1029,7 @@ func handleDeletePolicyRules(ctx context.Context, request *mcp.CallToolRequest, return mcp.TextResult(output) } -func handleDisplaySelectors(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleDisplaySelectors(ctx context.Context, request *sdkmcp.CallToolRequest, in nodeNameInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "policy selectors", nodeName) @@ -1038,7 +1039,7 @@ func handleDisplaySelectors(ctx context.Context, request *mcp.CallToolRequest, i return mcp.TextResult(output) } -func handleListXDPCIDRFilters(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleListXDPCIDRFilters(ctx context.Context, request *sdkmcp.CallToolRequest, in nodeNameInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "prefilter list", nodeName) @@ -1048,7 +1049,7 @@ func handleListXDPCIDRFilters(ctx context.Context, request *mcp.CallToolRequest, return mcp.TextResult(output) } -func handleUpdateXDPCIDRFilters(ctx context.Context, request *mcp.CallToolRequest, in xdpCIDRFiltersInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleUpdateXDPCIDRFilters(ctx context.Context, request *sdkmcp.CallToolRequest, in xdpCIDRFiltersInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { cidrPrefixes := in.CIDRPrefixes revision := in.Revision nodeName := in.NodeName @@ -1071,7 +1072,7 @@ func handleUpdateXDPCIDRFilters(ctx context.Context, request *mcp.CallToolReques return mcp.TextResult(output) } -func handleDeleteXDPCIDRFilters(ctx context.Context, request *mcp.CallToolRequest, in xdpCIDRFiltersInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleDeleteXDPCIDRFilters(ctx context.Context, request *sdkmcp.CallToolRequest, in xdpCIDRFiltersInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { cidrPrefixes := in.CIDRPrefixes revision := in.Revision nodeName := in.NodeName @@ -1094,7 +1095,7 @@ func handleDeleteXDPCIDRFilters(ctx context.Context, request *mcp.CallToolReques return mcp.TextResult(output) } -func handleValidateCiliumNetworkPolicies(ctx context.Context, request *mcp.CallToolRequest, in validateCiliumNetworkPoliciesInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleValidateCiliumNetworkPolicies(ctx context.Context, request *sdkmcp.CallToolRequest, in validateCiliumNetworkPoliciesInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { enableK8s := in.EnableK8s enableK8sAPIDiscovery := in.EnableK8sAPIDiscovery nodeName := in.NodeName @@ -1114,7 +1115,7 @@ func handleValidateCiliumNetworkPolicies(ctx context.Context, request *mcp.CallT return mcp.TextResult(output) } -func handleListPCAPRecorders(ctx context.Context, request *mcp.CallToolRequest, in nodeNameInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleListPCAPRecorders(ctx context.Context, request *sdkmcp.CallToolRequest, in nodeNameInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { nodeName := in.NodeName output, err := runCiliumDbgCommand(ctx, "recorder list", nodeName) @@ -1124,7 +1125,7 @@ func handleListPCAPRecorders(ctx context.Context, request *mcp.CallToolRequest, return mcp.TextResult(output) } -func handleGetPCAPRecorder(ctx context.Context, request *mcp.CallToolRequest, in pcapRecorderIDInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleGetPCAPRecorder(ctx context.Context, request *sdkmcp.CallToolRequest, in pcapRecorderIDInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { recorderID := in.RecorderID nodeName := in.NodeName @@ -1140,7 +1141,7 @@ func handleGetPCAPRecorder(ctx context.Context, request *mcp.CallToolRequest, in return mcp.TextResult(output) } -func handleDeletePCAPRecorder(ctx context.Context, request *mcp.CallToolRequest, in pcapRecorderIDInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleDeletePCAPRecorder(ctx context.Context, request *sdkmcp.CallToolRequest, in pcapRecorderIDInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { recorderID := in.RecorderID nodeName := in.NodeName @@ -1156,7 +1157,7 @@ func handleDeletePCAPRecorder(ctx context.Context, request *mcp.CallToolRequest, return mcp.TextResult(output) } -func handleUpdatePCAPRecorder(ctx context.Context, request *mcp.CallToolRequest, in updatePCAPRecorderInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleUpdatePCAPRecorder(ctx context.Context, request *sdkmcp.CallToolRequest, in updatePCAPRecorderInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { if in.Caplen == "" { in.Caplen = "0" } @@ -1181,7 +1182,7 @@ func handleUpdatePCAPRecorder(ctx context.Context, request *mcp.CallToolRequest, return mcp.TextResult(output) } -func handleListServices(ctx context.Context, request *mcp.CallToolRequest, in listServicesInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleListServices(ctx context.Context, request *sdkmcp.CallToolRequest, in listServicesInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { showClusterMeshAffinity := in.ShowClusterMeshAffinity nodeName := in.NodeName @@ -1199,7 +1200,7 @@ func handleListServices(ctx context.Context, request *mcp.CallToolRequest, in li return mcp.TextResult(output) } -func handleGetServiceInformation(ctx context.Context, request *mcp.CallToolRequest, in getServiceInformationInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleGetServiceInformation(ctx context.Context, request *sdkmcp.CallToolRequest, in getServiceInformationInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { serviceID := in.ServiceID nodeName := in.NodeName @@ -1215,7 +1216,7 @@ func handleGetServiceInformation(ctx context.Context, request *mcp.CallToolReque return mcp.TextResult(output) } -func handleDeleteService(ctx context.Context, request *mcp.CallToolRequest, in deleteServiceInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleDeleteService(ctx context.Context, request *sdkmcp.CallToolRequest, in deleteServiceInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { serviceID := in.ServiceID all := in.All nodeName := in.NodeName @@ -1236,7 +1237,7 @@ func handleDeleteService(ctx context.Context, request *mcp.CallToolRequest, in d return mcp.TextResult(output) } -func handleUpdateService(ctx context.Context, request *mcp.CallToolRequest, in updateServiceInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleUpdateService(ctx context.Context, request *sdkmcp.CallToolRequest, in updateServiceInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { if in.K8sExtTrafficPolicy == "" { in.K8sExtTrafficPolicy = "Cluster" } @@ -1307,7 +1308,7 @@ func handleUpdateService(ctx context.Context, request *mcp.CallToolRequest, in u return mcp.TextResult(output) } -func handleGetDaemonStatus(ctx context.Context, request *mcp.CallToolRequest, in getDaemonStatusInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleGetDaemonStatus(ctx context.Context, request *sdkmcp.CallToolRequest, in getDaemonStatusInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { showAllAddresses := in.ShowAllAddresses showAllClusters := in.ShowAllClusters showAllControllers := in.ShowAllControllers diff --git a/pkg/cilium/cilium_test.go b/pkg/cilium/cilium_test.go index bde7ce68..40a81fa6 100644 --- a/pkg/cilium/cilium_test.go +++ b/pkg/cilium/cilium_test.go @@ -8,7 +8,7 @@ import ( "testing" "github.com/kagent-dev/tools/internal/cmd" - mcp "github.com/kagent-dev/tools/internal/mcp" + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -16,7 +16,7 @@ import ( func boolPtr(b bool) *bool { return &b } func TestRegisterCiliumTools(t *testing.T) { - s := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + s := sdkmcp.NewServer(&sdkmcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) RegisterTools(s, false) // false = enable all tools including write operations // We can't directly check the tools, but we can ensure the call doesn't panic } @@ -29,7 +29,7 @@ func TestHandleCiliumStatusAndVersion(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleCiliumStatusAndVersion(ctx, &mcp.CallToolRequest{}, noInput{}) + result, _, err := handleCiliumStatusAndVersion(ctx, &sdkmcp.CallToolRequest{}, noInput{}) require.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -47,7 +47,7 @@ func TestHandleCiliumStatusAndVersionError(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleCiliumStatusAndVersion(ctx, &mcp.CallToolRequest{}, noInput{}) + result, _, err := handleCiliumStatusAndVersion(ctx, &sdkmcp.CallToolRequest{}, noInput{}) require.NoError(t, err) assert.NotNil(t, result) assert.True(t, result.IsError) @@ -61,7 +61,7 @@ func TestHandleInstallCilium(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleInstallCilium(ctx, &mcp.CallToolRequest{}, installCiliumInput{}) + result, _, err := handleInstallCilium(ctx, &sdkmcp.CallToolRequest{}, installCiliumInput{}) require.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -75,7 +75,7 @@ func TestHandleUninstallCilium(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleUninstallCilium(ctx, &mcp.CallToolRequest{}, noInput{}) + result, _, err := handleUninstallCilium(ctx, &sdkmcp.CallToolRequest{}, noInput{}) require.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -89,7 +89,7 @@ func TestHandleUpgradeCilium(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleUpgradeCilium(ctx, &mcp.CallToolRequest{}, upgradeCiliumInput{}) + result, _, err := handleUpgradeCilium(ctx, &sdkmcp.CallToolRequest{}, upgradeCiliumInput{}) require.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -103,7 +103,7 @@ func TestHandleConnectToRemoteCluster(t *testing.T) { mock := cmd.NewMockShellExecutor() mock.AddCommandString("cilium", []string{"clustermesh", "connect", "--destination-cluster", "my-cluster"}, "✓ Connected to cluster my-cluster!", nil) ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleConnectToRemoteCluster(ctx, &mcp.CallToolRequest{}, connectToRemoteClusterInput{ClusterName: "my-cluster"}) + result, _, err := handleConnectToRemoteCluster(ctx, &sdkmcp.CallToolRequest{}, connectToRemoteClusterInput{ClusterName: "my-cluster"}) require.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -111,7 +111,7 @@ func TestHandleConnectToRemoteCluster(t *testing.T) { }) t.Run("missing cluster_name", func(t *testing.T) { - result, _, err := handleConnectToRemoteCluster(ctx, &mcp.CallToolRequest{}, connectToRemoteClusterInput{}) + result, _, err := handleConnectToRemoteCluster(ctx, &sdkmcp.CallToolRequest{}, connectToRemoteClusterInput{}) require.NoError(t, err) assert.NotNil(t, result) assert.True(t, result.IsError) @@ -126,7 +126,7 @@ func TestHandleDisconnectFromRemoteCluster(t *testing.T) { mock := cmd.NewMockShellExecutor() mock.AddCommandString("cilium", []string{"clustermesh", "disconnect", "--destination-cluster", "my-cluster"}, "✓ Disconnected from cluster my-cluster!", nil) ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleDisconnectRemoteCluster(ctx, &mcp.CallToolRequest{}, disconnectRemoteClusterInput{ClusterName: "my-cluster"}) + result, _, err := handleDisconnectRemoteCluster(ctx, &sdkmcp.CallToolRequest{}, disconnectRemoteClusterInput{ClusterName: "my-cluster"}) require.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -134,7 +134,7 @@ func TestHandleDisconnectFromRemoteCluster(t *testing.T) { }) t.Run("missing cluster_name", func(t *testing.T) { - result, _, err := handleDisconnectRemoteCluster(ctx, &mcp.CallToolRequest{}, disconnectRemoteClusterInput{}) + result, _, err := handleDisconnectRemoteCluster(ctx, &sdkmcp.CallToolRequest{}, disconnectRemoteClusterInput{}) require.NoError(t, err) assert.NotNil(t, result) assert.True(t, result.IsError) @@ -147,7 +147,7 @@ func TestHandleEnableHubble(t *testing.T) { mock := cmd.NewMockShellExecutor() mock.AddCommandString("cilium", []string{"hubble", "enable"}, "✓ Hubble was successfully enabled!", nil) ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleToggleHubble(ctx, &mcp.CallToolRequest{}, enableToggleInput{Enable: boolPtr(true)}) + result, _, err := handleToggleHubble(ctx, &sdkmcp.CallToolRequest{}, enableToggleInput{Enable: boolPtr(true)}) require.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -159,7 +159,7 @@ func TestHandleDisableHubble(t *testing.T) { mock := cmd.NewMockShellExecutor() mock.AddCommandString("cilium", []string{"hubble", "disable"}, "✓ Hubble was successfully disabled!", nil) ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleToggleHubble(ctx, &mcp.CallToolRequest{}, enableToggleInput{Enable: boolPtr(false)}) + result, _, err := handleToggleHubble(ctx, &sdkmcp.CallToolRequest{}, enableToggleInput{Enable: boolPtr(false)}) require.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -171,7 +171,7 @@ func TestHandleListBGPPeers(t *testing.T) { mock := cmd.NewMockShellExecutor() mock.AddCommandString("cilium", []string{"bgp", "peers"}, "listing BGP peers", nil) ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleListBGPPeers(ctx, &mcp.CallToolRequest{}, noInput{}) + result, _, err := handleListBGPPeers(ctx, &sdkmcp.CallToolRequest{}, noInput{}) require.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -183,7 +183,7 @@ func TestHandleListBGPRoutes(t *testing.T) { mock := cmd.NewMockShellExecutor() mock.AddCommandString("cilium", []string{"bgp", "routes"}, "listing BGP routes", nil) ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleListBGPRoutes(ctx, &mcp.CallToolRequest{}, noInput{}) + result, _, err := handleListBGPRoutes(ctx, &sdkmcp.CallToolRequest{}, noInput{}) require.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) @@ -233,7 +233,7 @@ func TestHandleGetEndpointsList(t *testing.T) { mockCiliumDbgCommand(mock, []string{"endpoint", "list"}, "ENDPOINT POLICY\n34 Disabled", nil) ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleGetEndpointsList(ctx, &mcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) + result, _, err := handleGetEndpointsList(ctx, &sdkmcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "ENDPOINT") @@ -245,7 +245,7 @@ func TestHandleGetEndpointDetails(t *testing.T) { mockCiliumDbgCommand(mock, []string{"endpoint", "get", "34", "-o", "json"}, `{"id": 34}`, nil) ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleGetEndpointDetails(ctx, &mcp.CallToolRequest{}, getEndpointDetailsInput{EndpointID: "34", NodeName: "test-node"}) + result, _, err := handleGetEndpointDetails(ctx, &sdkmcp.CallToolRequest{}, getEndpointDetailsInput{EndpointID: "34", NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), `"id": 34`) @@ -257,7 +257,7 @@ func TestHandleGetEndpointLogs(t *testing.T) { mockCiliumDbgCommand(mock, []string{"endpoint", "logs", "34"}, "endpoint log output", nil) ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleGetEndpointLogs(ctx, &mcp.CallToolRequest{}, getEndpointLogsInput{EndpointID: "34", NodeName: "test-node"}) + result, _, err := handleGetEndpointLogs(ctx, &sdkmcp.CallToolRequest{}, getEndpointLogsInput{EndpointID: "34", NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "endpoint log output") @@ -269,7 +269,7 @@ func TestHandleGetEndpointHealth(t *testing.T) { mockCiliumDbgCommand(mock, []string{"endpoint", "health", "34"}, "endpoint health OK", nil) ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleGetEndpointHealth(ctx, &mcp.CallToolRequest{}, getEndpointHealthInput{EndpointID: "34", NodeName: "test-node"}) + result, _, err := handleGetEndpointHealth(ctx, &sdkmcp.CallToolRequest{}, getEndpointHealthInput{EndpointID: "34", NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "endpoint health OK") @@ -282,7 +282,7 @@ func TestHandleShowConfigurationOptions(t *testing.T) { mockCiliumDbgCommand(mock, []string{"config"}, "PolicyEnforcement=default", nil) ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleShowConfigurationOptions(ctx, &mcp.CallToolRequest{}, showConfigurationOptionsInput{NodeName: "test-node"}) + result, _, err := handleShowConfigurationOptions(ctx, &sdkmcp.CallToolRequest{}, showConfigurationOptionsInput{NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "PolicyEnforcement") @@ -294,7 +294,7 @@ func TestHandleShowConfigurationOptions(t *testing.T) { mockCiliumDbgCommand(mock, []string{"config", "--all"}, "all config options", nil) ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleShowConfigurationOptions(ctx, &mcp.CallToolRequest{}, showConfigurationOptionsInput{NodeName: "test-node", ListAll: true}) + result, _, err := handleShowConfigurationOptions(ctx, &sdkmcp.CallToolRequest{}, showConfigurationOptionsInput{NodeName: "test-node", ListAll: true}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "all config options") @@ -306,7 +306,7 @@ func TestHandleShowConfigurationOptions(t *testing.T) { mockCiliumDbgCommand(mock, []string{"config", "-r"}, "read only config", nil) ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleShowConfigurationOptions(ctx, &mcp.CallToolRequest{}, showConfigurationOptionsInput{NodeName: "test-node", ListReadOnly: true}) + result, _, err := handleShowConfigurationOptions(ctx, &sdkmcp.CallToolRequest{}, showConfigurationOptionsInput{NodeName: "test-node", ListReadOnly: true}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "read only config") @@ -319,7 +319,7 @@ func TestHandleToggleConfigurationOption(t *testing.T) { mockCiliumDbgCommand(mock, []string{"config", "PolicyEnforcement=enable"}, "option toggled", nil) ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleToggleConfigurationOption(ctx, &mcp.CallToolRequest{}, toggleConfigurationOptionInput{Option: "PolicyEnforcement", Value: boolPtr(true), NodeName: "test-node"}) + result, _, err := handleToggleConfigurationOption(ctx, &sdkmcp.CallToolRequest{}, toggleConfigurationOptionInput{Option: "PolicyEnforcement", Value: boolPtr(true), NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "option toggled") @@ -331,7 +331,7 @@ func TestHandleListIdentities(t *testing.T) { mockCiliumDbgCommand(mock, []string{"identity", "list"}, "ID LABELS\n1 reserved:host", nil) ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleListIdentities(ctx, &mcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) + result, _, err := handleListIdentities(ctx, &sdkmcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "reserved:host") @@ -343,7 +343,7 @@ func TestHandleGetDaemonStatus(t *testing.T) { mockCiliumDbgCommand(mock, []string{"status"}, "KVStore: Ok\nKubernetes: Ok", nil) ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleGetDaemonStatus(ctx, &mcp.CallToolRequest{}, getDaemonStatusInput{NodeName: "test-node"}) + result, _, err := handleGetDaemonStatus(ctx, &sdkmcp.CallToolRequest{}, getDaemonStatusInput{NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "KVStore: Ok") @@ -355,7 +355,7 @@ func TestHandleDisplayEncryptionState(t *testing.T) { mockCiliumDbgCommand(mock, []string{"encrypt", "status"}, "Encryption: Disabled", nil) ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleDisplayEncryptionState(ctx, &mcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) + result, _, err := handleDisplayEncryptionState(ctx, &sdkmcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "Encryption: Disabled") @@ -367,7 +367,7 @@ func TestHandleShowDNSNames(t *testing.T) { mockCiliumDbgCommand(mock, []string{"fqdn", "names"}, "DNS names output", nil) ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleShowDNSNames(ctx, &mcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) + result, _, err := handleShowDNSNames(ctx, &sdkmcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "DNS names output") @@ -379,7 +379,7 @@ func TestHandleFQDNCache(t *testing.T) { mockCiliumDbgCommand(mock, []string{"fqdn", "cache", "list"}, "FQDN cache entries", nil) ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleFQDNCache(ctx, &mcp.CallToolRequest{}, fqdnCacheInput{NodeName: "test-node"}) + result, _, err := handleFQDNCache(ctx, &sdkmcp.CallToolRequest{}, fqdnCacheInput{NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "FQDN cache entries") @@ -391,7 +391,7 @@ func TestHandleListClusterNodes(t *testing.T) { mockCiliumDbgCommand(mock, []string{"node", "list"}, "Name IPv4 Address\nnode1 10.0.0.1", nil) ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleListClusterNodes(ctx, &mcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) + result, _, err := handleListClusterNodes(ctx, &sdkmcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "node1") @@ -403,7 +403,7 @@ func TestHandleListNodeIds(t *testing.T) { mockCiliumDbgCommand(mock, []string{"nodeid", "list"}, "ID IP\n1 10.0.0.1", nil) ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleListNodeIds(ctx, &mcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) + result, _, err := handleListNodeIds(ctx, &sdkmcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "10.0.0.1") @@ -415,7 +415,7 @@ func TestHandleListBPFMaps(t *testing.T) { mockCiliumDbgCommand(mock, []string{"map", "list"}, "Name Num entries\ncilium_lb4 22", nil) ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleListBPFMaps(ctx, &mcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) + result, _, err := handleListBPFMaps(ctx, &sdkmcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "cilium_lb4") @@ -427,7 +427,7 @@ func TestHandleGetBPFMap(t *testing.T) { mockCiliumDbgCommand(mock, []string{"map", "get", "cilium_lb4"}, "map contents", nil) ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleGetBPFMap(ctx, &mcp.CallToolRequest{}, bpfMapInput{MapName: "cilium_lb4", NodeName: "test-node"}) + result, _, err := handleGetBPFMap(ctx, &sdkmcp.CallToolRequest{}, bpfMapInput{MapName: "cilium_lb4", NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "map contents") @@ -439,7 +439,7 @@ func TestHandleListBPFMapEvents(t *testing.T) { mockCiliumDbgCommand(mock, []string{"map", "events", "cilium_lb4"}, "map events", nil) ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleListBPFMapEvents(ctx, &mcp.CallToolRequest{}, bpfMapInput{MapName: "cilium_lb4", NodeName: "test-node"}) + result, _, err := handleListBPFMapEvents(ctx, &sdkmcp.CallToolRequest{}, bpfMapInput{MapName: "cilium_lb4", NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "map events") @@ -451,7 +451,7 @@ func TestHandleListMetrics(t *testing.T) { mockCiliumDbgCommand(mock, []string{"metrics", "list"}, "Metric Value\ncilium_endpoint_count 4", nil) ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleListMetrics(ctx, &mcp.CallToolRequest{}, listMetricsInput{NodeName: "test-node"}) + result, _, err := handleListMetrics(ctx, &sdkmcp.CallToolRequest{}, listMetricsInput{NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "cilium_endpoint_count") @@ -463,7 +463,7 @@ func TestHandleListServices(t *testing.T) { mockCiliumDbgCommand(mock, []string{"service", "list"}, "ID Frontend\n1 10.96.0.1:443", nil) ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleListServices(ctx, &mcp.CallToolRequest{}, listServicesInput{NodeName: "test-node"}) + result, _, err := handleListServices(ctx, &sdkmcp.CallToolRequest{}, listServicesInput{NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "10.96.0.1") @@ -475,7 +475,7 @@ func TestHandleListIPAddresses(t *testing.T) { mockCiliumDbgCommand(mock, []string{"ip", "list"}, "IP Identity\n10.0.0.1 1", nil) ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleListIPAddresses(ctx, &mcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) + result, _, err := handleListIPAddresses(ctx, &sdkmcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "10.0.0.1") @@ -487,7 +487,7 @@ func TestHandleDisplaySelectors(t *testing.T) { mockCiliumDbgCommand(mock, []string{"policy", "selectors"}, "SELECTOR IDENTITIES", nil) ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleDisplaySelectors(ctx, &mcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) + result, _, err := handleDisplaySelectors(ctx, &sdkmcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "SELECTOR") @@ -499,7 +499,7 @@ func TestHandleListLocalRedirectPolicies(t *testing.T) { mockCiliumDbgCommand(mock, []string{"lrp", "list"}, "No local redirect policies", nil) ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleListLocalRedirectPolicies(ctx, &mcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) + result, _, err := handleListLocalRedirectPolicies(ctx, &sdkmcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "No local redirect policies") @@ -511,7 +511,7 @@ func TestHandleRequestDebuggingInformation(t *testing.T) { mockCiliumDbgCommand(mock, []string{"debuginfo"}, "debug info output", nil) ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleRequestDebuggingInformation(ctx, &mcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) + result, _, err := handleRequestDebuggingInformation(ctx, &sdkmcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "debug info output") @@ -523,17 +523,17 @@ func TestHandleListXDPCIDRFilters(t *testing.T) { mockCiliumDbgCommand(mock, []string{"prefilter", "list"}, "CIDR filters", nil) ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleListXDPCIDRFilters(ctx, &mcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) + result, _, err := handleListXDPCIDRFilters(ctx, &sdkmcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) require.NoError(t, err) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "CIDR filters") } -func getResultText(r *mcp.CallToolResult) string { +func getResultText(r *sdkmcp.CallToolResult) string { if r == nil || len(r.Content) == 0 { return "" } - if textContent, ok := r.Content[0].(*mcp.TextContent); ok { + if textContent, ok := r.Content[0].(*sdkmcp.TextContent); ok { return strings.TrimSpace(textContent.Text) } return "" @@ -545,122 +545,122 @@ func TestCiliumDbgHandlers(t *testing.T) { name string dbgArgs []string expect string - run func(context.Context) (*mcp.CallToolResult, error) + run func(context.Context) (*sdkmcp.CallToolResult, error) }{ - {"manage_endpoint_labels", []string{"endpoint", "labels", "34", "--add", "key=val"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleManageEndpointLabels(ctx, &mcp.CallToolRequest{}, manageEndpointLabelsInput{EndpointID: "34", Labels: "key=val", NodeName: "test-node"}) + {"manage_endpoint_labels", []string{"endpoint", "labels", "34", "--add", "key=val"}, "ok", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleManageEndpointLabels(ctx, &sdkmcp.CallToolRequest{}, manageEndpointLabelsInput{EndpointID: "34", Labels: "key=val", NodeName: "test-node"}) return r, err }}, - {"manage_endpoint_configuration", []string{"endpoint", "config", "34", "Debug=true"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleManageEndpointConfiguration(ctx, &mcp.CallToolRequest{}, manageEndpointConfigurationInput{EndpointID: "34", Config: "Debug=true", NodeName: "test-node"}) + {"manage_endpoint_configuration", []string{"endpoint", "config", "34", "Debug=true"}, "ok", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleManageEndpointConfiguration(ctx, &sdkmcp.CallToolRequest{}, manageEndpointConfigurationInput{EndpointID: "34", Config: "Debug=true", NodeName: "test-node"}) return r, err }}, - {"disconnect_endpoint", []string{"endpoint", "disconnect", "34"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleDisconnectEndpoint(ctx, &mcp.CallToolRequest{}, disconnectEndpointInput{EndpointID: "34", NodeName: "test-node"}) + {"disconnect_endpoint", []string{"endpoint", "disconnect", "34"}, "ok", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleDisconnectEndpoint(ctx, &sdkmcp.CallToolRequest{}, disconnectEndpointInput{EndpointID: "34", NodeName: "test-node"}) return r, err }}, - {"get_identity_details", []string{"identity", "get", "123"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleGetIdentityDetails(ctx, &mcp.CallToolRequest{}, getIdentityDetailsInput{IdentityID: "123", NodeName: "test-node"}) + {"get_identity_details", []string{"identity", "get", "123"}, "ok", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleGetIdentityDetails(ctx, &sdkmcp.CallToolRequest{}, getIdentityDetailsInput{IdentityID: "123", NodeName: "test-node"}) return r, err }}, - {"flush_ipsec_state", []string{"encrypt", "flush", "-f"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleFlushIPsecState(ctx, &mcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) + {"flush_ipsec_state", []string{"encrypt", "flush", "-f"}, "ok", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleFlushIPsecState(ctx, &sdkmcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) return r, err }}, - {"list_envoy_config", []string{"envoy", "admin", "clusters"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleListEnvoyConfig(ctx, &mcp.CallToolRequest{}, listEnvoyConfigInput{ResourceName: "clusters", NodeName: "test-node"}) + {"list_envoy_config", []string{"envoy", "admin", "clusters"}, "ok", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleListEnvoyConfig(ctx, &sdkmcp.CallToolRequest{}, listEnvoyConfigInput{ResourceName: "clusters", NodeName: "test-node"}) return r, err }}, - {"show_ipcache_cidr", []string{"ip", "get", "10.0.0.0/24"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleShowIPCacheInformation(ctx, &mcp.CallToolRequest{}, showIPCacheInformationInput{CIDR: "10.0.0.0/24", NodeName: "test-node"}) + {"show_ipcache_cidr", []string{"ip", "get", "10.0.0.0/24"}, "ok", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleShowIPCacheInformation(ctx, &sdkmcp.CallToolRequest{}, showIPCacheInformationInput{CIDR: "10.0.0.0/24", NodeName: "test-node"}) return r, err }}, - {"show_ipcache_labels", []string{"ip", "get", "--labels", "app=foo"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleShowIPCacheInformation(ctx, &mcp.CallToolRequest{}, showIPCacheInformationInput{Labels: "app=foo", NodeName: "test-node"}) + {"show_ipcache_labels", []string{"ip", "get", "--labels", "app=foo"}, "ok", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleShowIPCacheInformation(ctx, &sdkmcp.CallToolRequest{}, showIPCacheInformationInput{Labels: "app=foo", NodeName: "test-node"}) return r, err }}, - {"delete_kvstore_key", []string{"kvstore", "delete", "foo"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleDeleteKeyFromKVStore(ctx, &mcp.CallToolRequest{}, kvStoreKeyInput{Key: "foo", NodeName: "test-node"}) + {"delete_kvstore_key", []string{"kvstore", "delete", "foo"}, "ok", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleDeleteKeyFromKVStore(ctx, &sdkmcp.CallToolRequest{}, kvStoreKeyInput{Key: "foo", NodeName: "test-node"}) return r, err }}, - {"get_kvstore_key", []string{"kvstore", "get", "foo"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleGetKVStoreKey(ctx, &mcp.CallToolRequest{}, kvStoreKeyInput{Key: "foo", NodeName: "test-node"}) + {"get_kvstore_key", []string{"kvstore", "get", "foo"}, "ok", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleGetKVStoreKey(ctx, &sdkmcp.CallToolRequest{}, kvStoreKeyInput{Key: "foo", NodeName: "test-node"}) return r, err }}, - {"set_kvstore_key", []string{"kvstore", "set", "foo=bar"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleSetKVStoreKey(ctx, &mcp.CallToolRequest{}, setKVStoreKeyInput{Key: "foo", Value: "bar", NodeName: "test-node"}) + {"set_kvstore_key", []string{"kvstore", "set", "foo=bar"}, "ok", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleSetKVStoreKey(ctx, &sdkmcp.CallToolRequest{}, setKVStoreKeyInput{Key: "foo", Value: "bar", NodeName: "test-node"}) return r, err }}, - {"show_load_information", []string{"loadinfo"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleShowLoadInformation(ctx, &mcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) + {"show_load_information", []string{"loadinfo"}, "ok", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleShowLoadInformation(ctx, &sdkmcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) return r, err }}, - {"display_policy_node_info", []string{"policy", "get"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleDisplayPolicyNodeInformation(ctx, &mcp.CallToolRequest{}, displayPolicyNodeInformationInput{NodeName: "test-node"}) + {"display_policy_node_info", []string{"policy", "get"}, "ok", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleDisplayPolicyNodeInformation(ctx, &sdkmcp.CallToolRequest{}, displayPolicyNodeInformationInput{NodeName: "test-node"}) return r, err }}, - {"display_policy_node_info_labels", []string{"policy", "get", "k=v"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleDisplayPolicyNodeInformation(ctx, &mcp.CallToolRequest{}, displayPolicyNodeInformationInput{Labels: "k=v", NodeName: "test-node"}) + {"display_policy_node_info_labels", []string{"policy", "get", "k=v"}, "ok", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleDisplayPolicyNodeInformation(ctx, &sdkmcp.CallToolRequest{}, displayPolicyNodeInformationInput{Labels: "k=v", NodeName: "test-node"}) return r, err }}, - {"delete_policy_rules_all", []string{"policy", "delete", "--all"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleDeletePolicyRules(ctx, &mcp.CallToolRequest{}, deletePolicyRulesInput{All: true, NodeName: "test-node"}) + {"delete_policy_rules_all", []string{"policy", "delete", "--all"}, "ok", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleDeletePolicyRules(ctx, &sdkmcp.CallToolRequest{}, deletePolicyRulesInput{All: true, NodeName: "test-node"}) return r, err }}, - {"delete_policy_rules_labels", []string{"policy", "delete", "k=v"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleDeletePolicyRules(ctx, &mcp.CallToolRequest{}, deletePolicyRulesInput{Labels: "k=v", NodeName: "test-node"}) + {"delete_policy_rules_labels", []string{"policy", "delete", "k=v"}, "ok", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleDeletePolicyRules(ctx, &sdkmcp.CallToolRequest{}, deletePolicyRulesInput{Labels: "k=v", NodeName: "test-node"}) return r, err }}, - {"update_xdp_cidr", []string{"prefilter", "update", "--cidr", "10.0.0.0/8"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleUpdateXDPCIDRFilters(ctx, &mcp.CallToolRequest{}, xdpCIDRFiltersInput{CIDRPrefixes: "10.0.0.0/8", NodeName: "test-node"}) + {"update_xdp_cidr", []string{"prefilter", "update", "--cidr", "10.0.0.0/8"}, "ok", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleUpdateXDPCIDRFilters(ctx, &sdkmcp.CallToolRequest{}, xdpCIDRFiltersInput{CIDRPrefixes: "10.0.0.0/8", NodeName: "test-node"}) return r, err }}, - {"update_xdp_cidr_rev", []string{"prefilter", "update", "--cidr", "10.0.0.0/8", "--revision", "2"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleUpdateXDPCIDRFilters(ctx, &mcp.CallToolRequest{}, xdpCIDRFiltersInput{CIDRPrefixes: "10.0.0.0/8", Revision: "2", NodeName: "test-node"}) + {"update_xdp_cidr_rev", []string{"prefilter", "update", "--cidr", "10.0.0.0/8", "--revision", "2"}, "ok", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleUpdateXDPCIDRFilters(ctx, &sdkmcp.CallToolRequest{}, xdpCIDRFiltersInput{CIDRPrefixes: "10.0.0.0/8", Revision: "2", NodeName: "test-node"}) return r, err }}, - {"delete_xdp_cidr", []string{"prefilter", "delete", "--cidr", "10.0.0.0/8"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleDeleteXDPCIDRFilters(ctx, &mcp.CallToolRequest{}, xdpCIDRFiltersInput{CIDRPrefixes: "10.0.0.0/8", NodeName: "test-node"}) + {"delete_xdp_cidr", []string{"prefilter", "delete", "--cidr", "10.0.0.0/8"}, "ok", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleDeleteXDPCIDRFilters(ctx, &sdkmcp.CallToolRequest{}, xdpCIDRFiltersInput{CIDRPrefixes: "10.0.0.0/8", NodeName: "test-node"}) return r, err }}, - {"delete_xdp_cidr_rev", []string{"prefilter", "delete", "--cidr", "10.0.0.0/8", "--revision", "2"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleDeleteXDPCIDRFilters(ctx, &mcp.CallToolRequest{}, xdpCIDRFiltersInput{CIDRPrefixes: "10.0.0.0/8", Revision: "2", NodeName: "test-node"}) + {"delete_xdp_cidr_rev", []string{"prefilter", "delete", "--cidr", "10.0.0.0/8", "--revision", "2"}, "ok", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleDeleteXDPCIDRFilters(ctx, &sdkmcp.CallToolRequest{}, xdpCIDRFiltersInput{CIDRPrefixes: "10.0.0.0/8", Revision: "2", NodeName: "test-node"}) return r, err }}, - {"validate_cnp", []string{"preflight", "validate-cnp", "--enable-k8s", "--enable-k8s-api-discovery"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleValidateCiliumNetworkPolicies(ctx, &mcp.CallToolRequest{}, validateCiliumNetworkPoliciesInput{EnableK8s: true, EnableK8sAPIDiscovery: true, NodeName: "test-node"}) + {"validate_cnp", []string{"preflight", "validate-cnp", "--enable-k8s", "--enable-k8s-api-discovery"}, "ok", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleValidateCiliumNetworkPolicies(ctx, &sdkmcp.CallToolRequest{}, validateCiliumNetworkPoliciesInput{EnableK8s: true, EnableK8sAPIDiscovery: true, NodeName: "test-node"}) return r, err }}, - {"list_pcap_recorders", []string{"recorder", "list"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleListPCAPRecorders(ctx, &mcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) + {"list_pcap_recorders", []string{"recorder", "list"}, "ok", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleListPCAPRecorders(ctx, &sdkmcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) return r, err }}, - {"get_pcap_recorder", []string{"recorder", "get", "1"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleGetPCAPRecorder(ctx, &mcp.CallToolRequest{}, pcapRecorderIDInput{RecorderID: "1", NodeName: "test-node"}) + {"get_pcap_recorder", []string{"recorder", "get", "1"}, "ok", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleGetPCAPRecorder(ctx, &sdkmcp.CallToolRequest{}, pcapRecorderIDInput{RecorderID: "1", NodeName: "test-node"}) return r, err }}, - {"delete_pcap_recorder", []string{"recorder", "delete", "1"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleDeletePCAPRecorder(ctx, &mcp.CallToolRequest{}, pcapRecorderIDInput{RecorderID: "1", NodeName: "test-node"}) + {"delete_pcap_recorder", []string{"recorder", "delete", "1"}, "ok", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleDeletePCAPRecorder(ctx, &sdkmcp.CallToolRequest{}, pcapRecorderIDInput{RecorderID: "1", NodeName: "test-node"}) return r, err }}, - {"update_pcap_recorder", []string{"recorder", "update", "1", "--filters", "f", "--caplen", "0", "--id", "0"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleUpdatePCAPRecorder(ctx, &mcp.CallToolRequest{}, updatePCAPRecorderInput{RecorderID: "1", Filters: "f", NodeName: "test-node"}) + {"update_pcap_recorder", []string{"recorder", "update", "1", "--filters", "f", "--caplen", "0", "--id", "0"}, "ok", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleUpdatePCAPRecorder(ctx, &sdkmcp.CallToolRequest{}, updatePCAPRecorderInput{RecorderID: "1", Filters: "f", NodeName: "test-node"}) return r, err }}, - {"get_service_information", []string{"service", "get", "5"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleGetServiceInformation(ctx, &mcp.CallToolRequest{}, getServiceInformationInput{ServiceID: "5", NodeName: "test-node"}) + {"get_service_information", []string{"service", "get", "5"}, "ok", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleGetServiceInformation(ctx, &sdkmcp.CallToolRequest{}, getServiceInformationInput{ServiceID: "5", NodeName: "test-node"}) return r, err }}, - {"delete_service_all", []string{"service", "delete", "--all"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleDeleteService(ctx, &mcp.CallToolRequest{}, deleteServiceInput{All: true, NodeName: "test-node"}) + {"delete_service_all", []string{"service", "delete", "--all"}, "ok", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleDeleteService(ctx, &sdkmcp.CallToolRequest{}, deleteServiceInput{All: true, NodeName: "test-node"}) return r, err }}, - {"delete_service_id", []string{"service", "delete", "5"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleDeleteService(ctx, &mcp.CallToolRequest{}, deleteServiceInput{ServiceID: "5", NodeName: "test-node"}) + {"delete_service_id", []string{"service", "delete", "5"}, "ok", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleDeleteService(ctx, &sdkmcp.CallToolRequest{}, deleteServiceInput{ServiceID: "5", NodeName: "test-node"}) return r, err }}, - {"update_service", []string{"service", "update", "1", "--backends", "b", "--frontend", "f", "--protocol", "TCP", "--states", "active"}, "ok", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleUpdateService(ctx, &mcp.CallToolRequest{}, updateServiceInput{Backends: "b", Frontend: "f", ID: "1", NodeName: "test-node"}) + {"update_service", []string{"service", "update", "1", "--backends", "b", "--frontend", "f", "--protocol", "TCP", "--states", "active"}, "ok", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleUpdateService(ctx, &sdkmcp.CallToolRequest{}, updateServiceInput{Backends: "b", Frontend: "f", ID: "1", NodeName: "test-node"}) return r, err }}, } @@ -683,82 +683,82 @@ func TestCiliumDbgHandlers(t *testing.T) { func TestCiliumDbgHandlersMissingParams(t *testing.T) { cases := []struct { name string - run func(context.Context) (*mcp.CallToolResult, error) + run func(context.Context) (*sdkmcp.CallToolResult, error) }{ - {"manage_endpoint_labels", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleManageEndpointLabels(ctx, &mcp.CallToolRequest{}, manageEndpointLabelsInput{}) + {"manage_endpoint_labels", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleManageEndpointLabels(ctx, &sdkmcp.CallToolRequest{}, manageEndpointLabelsInput{}) return r, err }}, - {"manage_endpoint_configuration_no_id", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleManageEndpointConfiguration(ctx, &mcp.CallToolRequest{}, manageEndpointConfigurationInput{}) + {"manage_endpoint_configuration_no_id", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleManageEndpointConfiguration(ctx, &sdkmcp.CallToolRequest{}, manageEndpointConfigurationInput{}) return r, err }}, - {"manage_endpoint_configuration_no_config", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleManageEndpointConfiguration(ctx, &mcp.CallToolRequest{}, manageEndpointConfigurationInput{EndpointID: "34"}) + {"manage_endpoint_configuration_no_config", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleManageEndpointConfiguration(ctx, &sdkmcp.CallToolRequest{}, manageEndpointConfigurationInput{EndpointID: "34"}) return r, err }}, - {"disconnect_endpoint", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleDisconnectEndpoint(ctx, &mcp.CallToolRequest{}, disconnectEndpointInput{}) + {"disconnect_endpoint", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleDisconnectEndpoint(ctx, &sdkmcp.CallToolRequest{}, disconnectEndpointInput{}) return r, err }}, - {"get_identity_details", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleGetIdentityDetails(ctx, &mcp.CallToolRequest{}, getIdentityDetailsInput{}) + {"get_identity_details", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleGetIdentityDetails(ctx, &sdkmcp.CallToolRequest{}, getIdentityDetailsInput{}) return r, err }}, - {"list_envoy_config", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleListEnvoyConfig(ctx, &mcp.CallToolRequest{}, listEnvoyConfigInput{}) + {"list_envoy_config", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleListEnvoyConfig(ctx, &sdkmcp.CallToolRequest{}, listEnvoyConfigInput{}) return r, err }}, - {"show_ipcache_none", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleShowIPCacheInformation(ctx, &mcp.CallToolRequest{}, showIPCacheInformationInput{}) + {"show_ipcache_none", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleShowIPCacheInformation(ctx, &sdkmcp.CallToolRequest{}, showIPCacheInformationInput{}) return r, err }}, - {"delete_kvstore_key", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleDeleteKeyFromKVStore(ctx, &mcp.CallToolRequest{}, kvStoreKeyInput{}) + {"delete_kvstore_key", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleDeleteKeyFromKVStore(ctx, &sdkmcp.CallToolRequest{}, kvStoreKeyInput{}) return r, err }}, - {"get_kvstore_key", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleGetKVStoreKey(ctx, &mcp.CallToolRequest{}, kvStoreKeyInput{}) + {"get_kvstore_key", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleGetKVStoreKey(ctx, &sdkmcp.CallToolRequest{}, kvStoreKeyInput{}) return r, err }}, - {"set_kvstore_key", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleSetKVStoreKey(ctx, &mcp.CallToolRequest{}, setKVStoreKeyInput{Key: "foo"}) + {"set_kvstore_key", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleSetKVStoreKey(ctx, &sdkmcp.CallToolRequest{}, setKVStoreKeyInput{Key: "foo"}) return r, err }}, - {"delete_policy_rules_none", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleDeletePolicyRules(ctx, &mcp.CallToolRequest{}, deletePolicyRulesInput{}) + {"delete_policy_rules_none", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleDeletePolicyRules(ctx, &sdkmcp.CallToolRequest{}, deletePolicyRulesInput{}) return r, err }}, - {"update_xdp_cidr", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleUpdateXDPCIDRFilters(ctx, &mcp.CallToolRequest{}, xdpCIDRFiltersInput{}) + {"update_xdp_cidr", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleUpdateXDPCIDRFilters(ctx, &sdkmcp.CallToolRequest{}, xdpCIDRFiltersInput{}) return r, err }}, - {"delete_xdp_cidr", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleDeleteXDPCIDRFilters(ctx, &mcp.CallToolRequest{}, xdpCIDRFiltersInput{}) + {"delete_xdp_cidr", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleDeleteXDPCIDRFilters(ctx, &sdkmcp.CallToolRequest{}, xdpCIDRFiltersInput{}) return r, err }}, - {"get_pcap_recorder", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleGetPCAPRecorder(ctx, &mcp.CallToolRequest{}, pcapRecorderIDInput{}) + {"get_pcap_recorder", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleGetPCAPRecorder(ctx, &sdkmcp.CallToolRequest{}, pcapRecorderIDInput{}) return r, err }}, - {"delete_pcap_recorder", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleDeletePCAPRecorder(ctx, &mcp.CallToolRequest{}, pcapRecorderIDInput{}) + {"delete_pcap_recorder", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleDeletePCAPRecorder(ctx, &sdkmcp.CallToolRequest{}, pcapRecorderIDInput{}) return r, err }}, - {"update_pcap_recorder", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleUpdatePCAPRecorder(ctx, &mcp.CallToolRequest{}, updatePCAPRecorderInput{RecorderID: "1"}) + {"update_pcap_recorder", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleUpdatePCAPRecorder(ctx, &sdkmcp.CallToolRequest{}, updatePCAPRecorderInput{RecorderID: "1"}) return r, err }}, - {"get_service_information", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleGetServiceInformation(ctx, &mcp.CallToolRequest{}, getServiceInformationInput{}) + {"get_service_information", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleGetServiceInformation(ctx, &sdkmcp.CallToolRequest{}, getServiceInformationInput{}) return r, err }}, - {"delete_service_none", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleDeleteService(ctx, &mcp.CallToolRequest{}, deleteServiceInput{}) + {"delete_service_none", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleDeleteService(ctx, &sdkmcp.CallToolRequest{}, deleteServiceInput{}) return r, err }}, - {"update_service", func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleUpdateService(ctx, &mcp.CallToolRequest{}, updateServiceInput{Backends: "b"}) + {"update_service", func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleUpdateService(ctx, &sdkmcp.CallToolRequest{}, updateServiceInput{Backends: "b"}) return r, err }}, } @@ -780,22 +780,22 @@ func TestCiliumCliHandlers(t *testing.T) { cases := []struct { name string cliArgs []string - run func(context.Context) (*mcp.CallToolResult, error) + run func(context.Context) (*sdkmcp.CallToolResult, error) }{ - {"show_cluster_mesh_status", []string{"clustermesh", "status"}, func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleShowClusterMeshStatus(ctx, &mcp.CallToolRequest{}, noInput{}) + {"show_cluster_mesh_status", []string{"clustermesh", "status"}, func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleShowClusterMeshStatus(ctx, &sdkmcp.CallToolRequest{}, noInput{}) return r, err }}, - {"show_features_status", []string{"features", "status"}, func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleShowFeaturesStatus(ctx, &mcp.CallToolRequest{}, noInput{}) + {"show_features_status", []string{"features", "status"}, func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleShowFeaturesStatus(ctx, &sdkmcp.CallToolRequest{}, noInput{}) return r, err }}, - {"toggle_cluster_mesh_enable", []string{"clustermesh", "enable"}, func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleToggleClusterMesh(ctx, &mcp.CallToolRequest{}, enableToggleInput{Enable: boolPtr(true)}) + {"toggle_cluster_mesh_enable", []string{"clustermesh", "enable"}, func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleToggleClusterMesh(ctx, &sdkmcp.CallToolRequest{}, enableToggleInput{Enable: boolPtr(true)}) return r, err }}, - {"toggle_cluster_mesh_disable", []string{"clustermesh", "disable"}, func(ctx context.Context) (*mcp.CallToolResult, error) { - r, _, err := handleToggleClusterMesh(ctx, &mcp.CallToolRequest{}, enableToggleInput{Enable: boolPtr(false)}) + {"toggle_cluster_mesh_disable", []string{"clustermesh", "disable"}, func(ctx context.Context) (*sdkmcp.CallToolResult, error) { + r, _, err := handleToggleClusterMesh(ctx, &sdkmcp.CallToolRequest{}, enableToggleInput{Enable: boolPtr(false)}) return r, err }}, } @@ -817,7 +817,7 @@ func TestCiliumCliHandlersError(t *testing.T) { mock := cmd.NewMockShellExecutor() mock.AddCommandString("cilium", []string{"clustermesh", "status"}, "", assert.AnError) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleShowClusterMeshStatus(ctx, &mcp.CallToolRequest{}, noInput{}) + result, _, err := handleShowClusterMeshStatus(ctx, &sdkmcp.CallToolRequest{}, noInput{}) require.NoError(t, err) assert.True(t, result.IsError) assert.Contains(t, getResultText(result), "Error getting cluster mesh status") @@ -828,7 +828,7 @@ func TestCiliumDbgHandlerError(t *testing.T) { mock := cmd.NewMockShellExecutor() mockCiliumDbgCommand(mock, []string{"loadinfo"}, "", assert.AnError) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleShowLoadInformation(ctx, &mcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) + result, _, err := handleShowLoadInformation(ctx, &sdkmcp.CallToolRequest{}, nodeNameInput{NodeName: "test-node"}) require.NoError(t, err) assert.True(t, result.IsError) } diff --git a/pkg/helm/helm.go b/pkg/helm/helm.go index 07012844..ff31dc17 100644 --- a/pkg/helm/helm.go +++ b/pkg/helm/helm.go @@ -11,10 +11,11 @@ import ( mcp "github.com/kagent-dev/tools/internal/mcp" "github.com/kagent-dev/tools/internal/security" "github.com/kagent-dev/tools/pkg/utils" + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" ) // toolErrorResult formats a ToolError as an MCP error result. -func toolErrorResult(toolErr *errors.ToolError) *mcp.CallToolResult { +func toolErrorResult(toolErr *errors.ToolError) *sdkmcp.CallToolResult { return toolErr.ToMCPResult() } @@ -32,7 +33,7 @@ type helmListReleasesInput struct { } // Helm list releases -func handleHelmListReleases(ctx context.Context, request *mcp.CallToolRequest, in helmListReleasesInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleHelmListReleases(ctx context.Context, request *sdkmcp.CallToolRequest, in helmListReleasesInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { args := []string{"list"} if in.Namespace != "" { @@ -128,7 +129,7 @@ type helmGetReleaseInput struct { } // Helm get release -func handleHelmGetRelease(ctx context.Context, request *mcp.CallToolRequest, in helmGetReleaseInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleHelmGetRelease(ctx context.Context, request *sdkmcp.CallToolRequest, in helmGetReleaseInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { if in.Resource == "" { in.Resource = "all" } @@ -164,7 +165,7 @@ type helmUpgradeReleaseInput struct { } // Helm upgrade release -func handleHelmUpgradeRelease(ctx context.Context, request *mcp.CallToolRequest, in helmUpgradeReleaseInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleHelmUpgradeRelease(ctx context.Context, request *sdkmcp.CallToolRequest, in helmUpgradeReleaseInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { if in.Name == "" || in.Chart == "" { return mcp.TextError("name and chart parameters are required") } @@ -238,7 +239,7 @@ type helmUninstallInput struct { } // Helm uninstall release -func handleHelmUninstall(ctx context.Context, request *mcp.CallToolRequest, in helmUninstallInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleHelmUninstall(ctx context.Context, request *sdkmcp.CallToolRequest, in helmUninstallInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { if in.Name == "" || in.Namespace == "" { return mcp.TextError("name and namespace parameters are required") } @@ -267,7 +268,7 @@ type helmRepoAddInput struct { } // Helm repo add -func handleHelmRepoAdd(ctx context.Context, request *mcp.CallToolRequest, in helmRepoAddInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleHelmRepoAdd(ctx context.Context, request *sdkmcp.CallToolRequest, in helmRepoAddInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { if in.Name == "" || in.URL == "" { return mcp.TextError("name and url parameters are required") } @@ -295,7 +296,7 @@ func handleHelmRepoAdd(ctx context.Context, request *mcp.CallToolRequest, in hel type helmRepoUpdateInput struct{} // Helm repo update -func handleHelmRepoUpdate(ctx context.Context, request *mcp.CallToolRequest, in helmRepoUpdateInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleHelmRepoUpdate(ctx context.Context, request *sdkmcp.CallToolRequest, in helmRepoUpdateInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { args := []string{"repo", "update"} result, err := runHelmCommand(ctx, args) @@ -307,36 +308,36 @@ func handleHelmRepoUpdate(ctx context.Context, request *mcp.CallToolRequest, in } // Register Helm tools -func RegisterTools(s *mcp.Server, readOnly bool) { +func RegisterTools(s *sdkmcp.Server, readOnly bool) { // Read-only tools - always registered - mcp.AddTool(s, "helm", &mcp.Tool{ + mcp.AddTool(s, "helm", &sdkmcp.Tool{ Name: "helm_list_releases", Description: "List Helm releases in a namespace", }, handleHelmListReleases) - mcp.AddTool(s, "helm", &mcp.Tool{ + mcp.AddTool(s, "helm", &sdkmcp.Tool{ Name: "helm_get_release", Description: "Get extended information about a Helm release", }, handleHelmGetRelease) - mcp.AddTool(s, "helm", &mcp.Tool{ + mcp.AddTool(s, "helm", &sdkmcp.Tool{ Name: "helm_repo_update", Description: "Update information of available charts locally from chart repositories", }, handleHelmRepoUpdate) // Write tools - only registered when not in read-only mode if !readOnly { - mcp.AddTool(s, "helm", &mcp.Tool{ + mcp.AddTool(s, "helm", &sdkmcp.Tool{ Name: "helm_upgrade", Description: "Upgrade or install a Helm release", }, handleHelmUpgradeRelease) - mcp.AddTool(s, "helm", &mcp.Tool{ + mcp.AddTool(s, "helm", &sdkmcp.Tool{ Name: "helm_uninstall", Description: "Uninstall a Helm release", }, handleHelmUninstall) - mcp.AddTool(s, "helm", &mcp.Tool{ + mcp.AddTool(s, "helm", &sdkmcp.Tool{ Name: "helm_repo_add", Description: "Add a Helm repository", }, handleHelmRepoAdd) diff --git a/pkg/helm/helm_test.go b/pkg/helm/helm_test.go index d9665c6f..2c2923c3 100644 --- a/pkg/helm/helm_test.go +++ b/pkg/helm/helm_test.go @@ -5,13 +5,13 @@ import ( "testing" "github.com/kagent-dev/tools/internal/cmd" - mcp "github.com/kagent-dev/tools/internal/mcp" + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestRegisterTools(t *testing.T) { - s := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + s := sdkmcp.NewServer(&sdkmcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) RegisterTools(s, false) // false = enable all tools including write operations } @@ -81,7 +81,7 @@ prod-app production 1 deployed my-chart-1.0.0`, mock.AddCommandString("helm", tt.expectedArgs, tt.expectedOutput, nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleHelmListReleases(ctx, &mcp.CallToolRequest{}, tt.input) + result, _, err := handleHelmListReleases(ctx, &sdkmcp.CallToolRequest{}, tt.input) assert.NoError(t, err) assert.False(t, result.IsError) @@ -115,7 +115,7 @@ prod-app production 1 deployed my-chart-1.0.0`, mock.AddCommandString("helm", []string{"list"}, "", assert.AnError) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleHelmListReleases(ctx, &mcp.CallToolRequest{}, helmListReleasesInput{}) + result, _, err := handleHelmListReleases(ctx, &sdkmcp.CallToolRequest{}, helmListReleasesInput{}) assert.NoError(t, err) // MCP handlers should not return Go errors assert.True(t, result.IsError) @@ -136,7 +136,7 @@ replicaCount: 3` mock.AddCommandString("helm", []string{"get", "all", "myapp", "-n", "default"}, expectedOutput, nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleHelmGetRelease(ctx, &mcp.CallToolRequest{}, helmGetReleaseInput{ + result, _, err := handleHelmGetRelease(ctx, &sdkmcp.CallToolRequest{}, helmGetReleaseInput{ Name: "myapp", Namespace: "default", }) @@ -157,7 +157,7 @@ replicaCount: 3` mock.AddCommandString("helm", []string{"get", "values", "myapp", "-n", "default"}, "replicaCount: 3", nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleHelmGetRelease(ctx, &mcp.CallToolRequest{}, helmGetReleaseInput{ + result, _, err := handleHelmGetRelease(ctx, &sdkmcp.CallToolRequest{}, helmGetReleaseInput{ Name: "myapp", Namespace: "default", Resource: "values", @@ -178,7 +178,7 @@ replicaCount: 3` ctx := cmd.WithShellExecutor(context.Background(), mock) // Test missing name - result, _, err := handleHelmGetRelease(ctx, &mcp.CallToolRequest{}, helmGetReleaseInput{ + result, _, err := handleHelmGetRelease(ctx, &sdkmcp.CallToolRequest{}, helmGetReleaseInput{ Namespace: "default", }) assert.NoError(t, err) @@ -186,7 +186,7 @@ replicaCount: 3` assert.Contains(t, getResultText(result), "name parameter is required") // Test missing namespace - result, _, err = handleHelmGetRelease(ctx, &mcp.CallToolRequest{}, helmGetReleaseInput{ + result, _, err = handleHelmGetRelease(ctx, &sdkmcp.CallToolRequest{}, helmGetReleaseInput{ Name: "myapp", }) assert.NoError(t, err) @@ -213,7 +213,7 @@ REVISION: 2` mock.AddCommandString("helm", []string{"upgrade", "myapp", "stable/myapp", "--timeout", "30s"}, expectedOutput, nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleHelmUpgradeRelease(ctx, &mcp.CallToolRequest{}, helmUpgradeReleaseInput{ + result, _, err := handleHelmUpgradeRelease(ctx, &sdkmcp.CallToolRequest{}, helmUpgradeReleaseInput{ Name: "myapp", Chart: "stable/myapp", }) @@ -246,7 +246,7 @@ REVISION: 2` mock.AddCommandString("helm", expectedArgs, "Upgraded with options", nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleHelmUpgradeRelease(ctx, &mcp.CallToolRequest{}, helmUpgradeReleaseInput{ + result, _, err := handleHelmUpgradeRelease(ctx, &sdkmcp.CallToolRequest{}, helmUpgradeReleaseInput{ Name: "myapp", Chart: "stable/myapp", Namespace: "production", @@ -273,7 +273,7 @@ REVISION: 2` ctx := cmd.WithShellExecutor(context.Background(), mock) // Test missing chart - result, _, err := handleHelmUpgradeRelease(ctx, &mcp.CallToolRequest{}, helmUpgradeReleaseInput{ + result, _, err := handleHelmUpgradeRelease(ctx, &sdkmcp.CallToolRequest{}, helmUpgradeReleaseInput{ Name: "myapp", }) assert.NoError(t, err) @@ -295,7 +295,7 @@ func TestHandleHelmUninstall(t *testing.T) { mock.AddCommandString("helm", []string{"uninstall", "myapp", "-n", "default"}, expectedOutput, nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleHelmUninstall(ctx, &mcp.CallToolRequest{}, helmUninstallInput{ + result, _, err := handleHelmUninstall(ctx, &sdkmcp.CallToolRequest{}, helmUninstallInput{ Name: "myapp", Namespace: "default", }) @@ -319,7 +319,7 @@ func TestHandleHelmUninstall(t *testing.T) { mock.AddCommandString("helm", []string{"uninstall", "myapp", "-n", "production", "--dry-run", "--wait"}, expectedOutput, nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleHelmUninstall(ctx, &mcp.CallToolRequest{}, helmUninstallInput{ + result, _, err := handleHelmUninstall(ctx, &sdkmcp.CallToolRequest{}, helmUninstallInput{ Name: "myapp", Namespace: "production", DryRun: true, @@ -341,7 +341,7 @@ func TestHandleHelmUninstall(t *testing.T) { ctx := cmd.WithShellExecutor(context.Background(), mock) // Test missing name - result, _, err := handleHelmUninstall(ctx, &mcp.CallToolRequest{}, helmUninstallInput{ + result, _, err := handleHelmUninstall(ctx, &sdkmcp.CallToolRequest{}, helmUninstallInput{ Namespace: "default", }) assert.NoError(t, err) @@ -349,7 +349,7 @@ func TestHandleHelmUninstall(t *testing.T) { assert.Contains(t, getResultText(result), "name and namespace parameters are required") // Test missing namespace - result, _, err = handleHelmUninstall(ctx, &mcp.CallToolRequest{}, helmUninstallInput{ + result, _, err = handleHelmUninstall(ctx, &sdkmcp.CallToolRequest{}, helmUninstallInput{ Name: "myapp", }) assert.NoError(t, err) @@ -371,7 +371,7 @@ func TestHandleHelmRepoAdd(t *testing.T) { mock.AddCommandString("helm", []string{"repo", "add", "my-repo", "https://charts.example.com/"}, expectedOutput, nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleHelmRepoAdd(ctx, &mcp.CallToolRequest{}, helmRepoAddInput{ + result, _, err := handleHelmRepoAdd(ctx, &sdkmcp.CallToolRequest{}, helmRepoAddInput{ Name: "my-repo", URL: "https://charts.example.com/", }) @@ -392,7 +392,7 @@ func TestHandleHelmRepoAdd(t *testing.T) { ctx := cmd.WithShellExecutor(context.Background(), mock) // Test missing name - result, _, err := handleHelmRepoAdd(ctx, &mcp.CallToolRequest{}, helmRepoAddInput{ + result, _, err := handleHelmRepoAdd(ctx, &sdkmcp.CallToolRequest{}, helmRepoAddInput{ URL: "https://charts.example.com/", }) assert.NoError(t, err) @@ -416,7 +416,7 @@ Update Complete. ⎈Happy Helming!⎈` mock.AddCommandString("helm", []string{"repo", "update"}, expectedOutput, nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleHelmRepoUpdate(ctx, &mcp.CallToolRequest{}, helmRepoUpdateInput{}) + result, _, err := handleHelmRepoUpdate(ctx, &sdkmcp.CallToolRequest{}, helmRepoUpdateInput{}) assert.NoError(t, err) assert.False(t, result.IsError) @@ -431,11 +431,11 @@ Update Complete. ⎈Happy Helming!⎈` } // Helper function to extract text content from MCP result -func getResultText(result *mcp.CallToolResult) string { +func getResultText(result *sdkmcp.CallToolResult) string { if result == nil || len(result.Content) == 0 { return "" } - if textContent, ok := result.Content[0].(*mcp.TextContent); ok { + if textContent, ok := result.Content[0].(*sdkmcp.TextContent); ok { return textContent.Text } return "" diff --git a/pkg/istio/istio.go b/pkg/istio/istio.go index 7bdea518..ef3731b9 100644 --- a/pkg/istio/istio.go +++ b/pkg/istio/istio.go @@ -8,6 +8,7 @@ import ( "github.com/kagent-dev/tools/internal/commands" mcp "github.com/kagent-dev/tools/internal/mcp" "github.com/kagent-dev/tools/pkg/utils" + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" ) type istioProxyStatusInput struct { @@ -16,7 +17,7 @@ type istioProxyStatusInput struct { } // Istio proxy status -func handleIstioProxyStatus(ctx context.Context, request *mcp.CallToolRequest, in istioProxyStatusInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleIstioProxyStatus(ctx context.Context, request *sdkmcp.CallToolRequest, in istioProxyStatusInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { args := []string{"proxy-status"} if in.Namespace != "" { @@ -50,7 +51,7 @@ type istioProxyConfigInput struct { } // Istio proxy config -func handleIstioProxyConfig(ctx context.Context, request *mcp.CallToolRequest, in istioProxyConfigInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleIstioProxyConfig(ctx context.Context, request *sdkmcp.CallToolRequest, in istioProxyConfigInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { if in.ConfigType == "" { in.ConfigType = "all" } @@ -80,7 +81,7 @@ type istioInstallInput struct { } // Istio install -func handleIstioInstall(ctx context.Context, request *mcp.CallToolRequest, in istioInstallInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleIstioInstall(ctx context.Context, request *sdkmcp.CallToolRequest, in istioInstallInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { if in.Profile == "" { in.Profile = "default" } @@ -100,7 +101,7 @@ type istioGenerateManifestInput struct { } // Istio generate manifest -func handleIstioGenerateManifest(ctx context.Context, request *mcp.CallToolRequest, in istioGenerateManifestInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleIstioGenerateManifest(ctx context.Context, request *sdkmcp.CallToolRequest, in istioGenerateManifestInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { if in.Profile == "" { in.Profile = "default" } @@ -121,7 +122,7 @@ type istioAnalyzeClusterConfigurationInput struct { } // Istio analyze -func handleIstioAnalyzeClusterConfiguration(ctx context.Context, request *mcp.CallToolRequest, in istioAnalyzeClusterConfigurationInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleIstioAnalyzeClusterConfiguration(ctx context.Context, request *sdkmcp.CallToolRequest, in istioAnalyzeClusterConfigurationInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { args := []string{"analyze"} if in.AllNamespaces { @@ -143,7 +144,7 @@ type istioVersionInput struct { } // Istio version -func handleIstioVersion(ctx context.Context, request *mcp.CallToolRequest, in istioVersionInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleIstioVersion(ctx context.Context, request *sdkmcp.CallToolRequest, in istioVersionInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { args := []string{"version"} if in.Short { @@ -161,7 +162,7 @@ func handleIstioVersion(ctx context.Context, request *mcp.CallToolRequest, in is type istioRemoteClustersInput struct{} // Istio remote clusters -func handleIstioRemoteClusters(ctx context.Context, request *mcp.CallToolRequest, in istioRemoteClustersInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleIstioRemoteClusters(ctx context.Context, request *sdkmcp.CallToolRequest, in istioRemoteClustersInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { args := []string{"remote-clusters"} result, err := runIstioCtl(ctx, args) @@ -178,7 +179,7 @@ type waypointListInput struct { } // Waypoint list -func handleWaypointList(ctx context.Context, request *mcp.CallToolRequest, in waypointListInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleWaypointList(ctx context.Context, request *sdkmcp.CallToolRequest, in waypointListInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { args := []string{"waypoint", "list"} if in.AllNamespaces { @@ -202,7 +203,7 @@ type waypointGenerateInput struct { } // Waypoint generate -func handleWaypointGenerate(ctx context.Context, request *mcp.CallToolRequest, in waypointGenerateInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleWaypointGenerate(ctx context.Context, request *sdkmcp.CallToolRequest, in waypointGenerateInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { if in.Name == "" { in.Name = "waypoint" } @@ -240,7 +241,7 @@ type waypointApplyInput struct { } // Waypoint apply -func handleWaypointApply(ctx context.Context, request *mcp.CallToolRequest, in waypointApplyInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleWaypointApply(ctx context.Context, request *sdkmcp.CallToolRequest, in waypointApplyInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { if in.Namespace == "" { return mcp.TextError("namespace parameter is required") } @@ -266,7 +267,7 @@ type waypointDeleteInput struct { } // Waypoint delete -func handleWaypointDelete(ctx context.Context, request *mcp.CallToolRequest, in waypointDeleteInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleWaypointDelete(ctx context.Context, request *sdkmcp.CallToolRequest, in waypointDeleteInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { if in.Namespace == "" { return mcp.TextError("namespace parameter is required") } @@ -298,7 +299,7 @@ type waypointStatusInput struct { } // Waypoint status -func handleWaypointStatus(ctx context.Context, request *mcp.CallToolRequest, in waypointStatusInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleWaypointStatus(ctx context.Context, request *sdkmcp.CallToolRequest, in waypointStatusInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { if in.Namespace == "" { return mcp.TextError("namespace parameter is required") } @@ -325,7 +326,7 @@ type ztunnelConfigInput struct { } // Ztunnel config -func handleZtunnelConfig(ctx context.Context, request *mcp.CallToolRequest, in ztunnelConfigInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleZtunnelConfig(ctx context.Context, request *sdkmcp.CallToolRequest, in ztunnelConfigInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { if in.ConfigType == "" { in.ConfigType = "all" } @@ -345,72 +346,72 @@ func handleZtunnelConfig(ctx context.Context, request *mcp.CallToolRequest, in z } // Register Istio tools -func RegisterTools(s *mcp.Server, readOnly bool) { +func RegisterTools(s *sdkmcp.Server, readOnly bool) { // Read-only tools - always registered - mcp.AddTool(s, "istio", &mcp.Tool{ + mcp.AddTool(s, "istio", &sdkmcp.Tool{ Name: "istio_proxy_status", Description: "Get Envoy proxy status for pods, retrieves last sent and acknowledged xDS sync from Istiod to each Envoy in the mesh", }, handleIstioProxyStatus) - mcp.AddTool(s, "istio", &mcp.Tool{ + mcp.AddTool(s, "istio", &sdkmcp.Tool{ Name: "istio_proxy_config", Description: "Get specific proxy configuration for a single pod", }, handleIstioProxyConfig) - mcp.AddTool(s, "istio", &mcp.Tool{ + mcp.AddTool(s, "istio", &sdkmcp.Tool{ Name: "istio_generate_manifest", Description: "Generate Istio manifest for a given profile", }, handleIstioGenerateManifest) - mcp.AddTool(s, "istio", &mcp.Tool{ + mcp.AddTool(s, "istio", &sdkmcp.Tool{ Name: "istio_analyze_cluster_configuration", Description: "Analyze Istio cluster configuration for issues", }, handleIstioAnalyzeClusterConfiguration) - mcp.AddTool(s, "istio", &mcp.Tool{ + mcp.AddTool(s, "istio", &sdkmcp.Tool{ Name: "istio_version", Description: "Get Istio version information", }, handleIstioVersion) - mcp.AddTool(s, "istio", &mcp.Tool{ + mcp.AddTool(s, "istio", &sdkmcp.Tool{ Name: "istio_remote_clusters", Description: "List remote clusters registered with Istio", }, handleIstioRemoteClusters) - mcp.AddTool(s, "istio", &mcp.Tool{ + mcp.AddTool(s, "istio", &sdkmcp.Tool{ Name: "istio_list_waypoints", Description: "List all waypoints in the mesh", }, handleWaypointList) - mcp.AddTool(s, "istio", &mcp.Tool{ + mcp.AddTool(s, "istio", &sdkmcp.Tool{ Name: "istio_generate_waypoint", Description: "Generate a waypoint resource YAML", }, handleWaypointGenerate) - mcp.AddTool(s, "istio", &mcp.Tool{ + mcp.AddTool(s, "istio", &sdkmcp.Tool{ Name: "istio_waypoint_status", Description: "Get the status of a waypoint resource", }, handleWaypointStatus) - mcp.AddTool(s, "istio", &mcp.Tool{ + mcp.AddTool(s, "istio", &sdkmcp.Tool{ Name: "istio_ztunnel_config", Description: "Get the ztunnel configuration for a namespace", }, handleZtunnelConfig) // Write tools - only registered when write operations are enabled if !readOnly { - mcp.AddTool(s, "istio", &mcp.Tool{ + mcp.AddTool(s, "istio", &sdkmcp.Tool{ Name: "istio_install_istio", Description: "Install Istio with a specified configuration profile", }, handleIstioInstall) - mcp.AddTool(s, "istio", &mcp.Tool{ + mcp.AddTool(s, "istio", &sdkmcp.Tool{ Name: "istio_apply_waypoint", Description: "Apply a waypoint resource to the cluster", }, handleWaypointApply) - mcp.AddTool(s, "istio", &mcp.Tool{ + mcp.AddTool(s, "istio", &sdkmcp.Tool{ Name: "istio_delete_waypoint", Description: "Delete a waypoint resource from the cluster", }, handleWaypointDelete) diff --git a/pkg/istio/istio_test.go b/pkg/istio/istio_test.go index e57f4c1d..944aa22d 100644 --- a/pkg/istio/istio_test.go +++ b/pkg/istio/istio_test.go @@ -5,13 +5,13 @@ import ( "testing" "github.com/kagent-dev/tools/internal/cmd" - mcp "github.com/kagent-dev/tools/internal/mcp" + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestRegisterTools(t *testing.T) { - s := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + s := sdkmcp.NewServer(&sdkmcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) RegisterTools(s, false) // false = enable all tools including write operations } @@ -24,7 +24,7 @@ func TestHandleIstioProxyStatus(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleIstioProxyStatus(ctx, &mcp.CallToolRequest{}, istioProxyStatusInput{}) + result, _, err := handleIstioProxyStatus(ctx, &sdkmcp.CallToolRequest{}, istioProxyStatusInput{}) require.NoError(t, err) assert.NotNil(t, result) @@ -37,7 +37,7 @@ func TestHandleIstioProxyStatus(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleIstioProxyStatus(ctx, &mcp.CallToolRequest{}, istioProxyStatusInput{ + result, _, err := handleIstioProxyStatus(ctx, &sdkmcp.CallToolRequest{}, istioProxyStatusInput{ Namespace: "istio-system", }) @@ -52,7 +52,7 @@ func TestHandleIstioProxyStatus(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleIstioProxyStatus(ctx, &mcp.CallToolRequest{}, istioProxyStatusInput{ + result, _, err := handleIstioProxyStatus(ctx, &sdkmcp.CallToolRequest{}, istioProxyStatusInput{ PodName: "test-pod", Namespace: "default", }) @@ -67,7 +67,7 @@ func TestHandleIstioProxyConfig(t *testing.T) { ctx := context.Background() t.Run("missing pod_name parameter", func(t *testing.T) { - result, _, err := handleIstioProxyConfig(ctx, &mcp.CallToolRequest{}, istioProxyConfigInput{}) + result, _, err := handleIstioProxyConfig(ctx, &sdkmcp.CallToolRequest{}, istioProxyConfigInput{}) require.NoError(t, err) assert.NotNil(t, result) @@ -80,7 +80,7 @@ func TestHandleIstioProxyConfig(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleIstioProxyConfig(ctx, &mcp.CallToolRequest{}, istioProxyConfigInput{ + result, _, err := handleIstioProxyConfig(ctx, &sdkmcp.CallToolRequest{}, istioProxyConfigInput{ PodName: "test-pod", }) @@ -95,7 +95,7 @@ func TestHandleIstioProxyConfig(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleIstioProxyConfig(ctx, &mcp.CallToolRequest{}, istioProxyConfigInput{ + result, _, err := handleIstioProxyConfig(ctx, &sdkmcp.CallToolRequest{}, istioProxyConfigInput{ PodName: "test-pod", Namespace: "default", ConfigType: "cluster", @@ -116,7 +116,7 @@ func TestHandleIstioInstall(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleIstioInstall(ctx, &mcp.CallToolRequest{}, istioInstallInput{}) + result, _, err := handleIstioInstall(ctx, &sdkmcp.CallToolRequest{}, istioInstallInput{}) require.NoError(t, err) assert.NotNil(t, result) @@ -129,7 +129,7 @@ func TestHandleIstioInstall(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleIstioInstall(ctx, &mcp.CallToolRequest{}, istioInstallInput{ + result, _, err := handleIstioInstall(ctx, &sdkmcp.CallToolRequest{}, istioInstallInput{ Profile: "demo", }) @@ -147,7 +147,7 @@ func TestHandleIstioGenerateManifest(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleIstioGenerateManifest(ctx, &mcp.CallToolRequest{}, istioGenerateManifestInput{ + result, _, err := handleIstioGenerateManifest(ctx, &sdkmcp.CallToolRequest{}, istioGenerateManifestInput{ Profile: "minimal", }) @@ -165,7 +165,7 @@ func TestHandleIstioAnalyzeClusterConfiguration(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleIstioAnalyzeClusterConfiguration(ctx, &mcp.CallToolRequest{}, istioAnalyzeClusterConfigurationInput{ + result, _, err := handleIstioAnalyzeClusterConfiguration(ctx, &sdkmcp.CallToolRequest{}, istioAnalyzeClusterConfigurationInput{ AllNamespaces: true, }) @@ -180,7 +180,7 @@ func TestHandleIstioAnalyzeClusterConfiguration(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleIstioAnalyzeClusterConfiguration(ctx, &mcp.CallToolRequest{}, istioAnalyzeClusterConfigurationInput{ + result, _, err := handleIstioAnalyzeClusterConfiguration(ctx, &sdkmcp.CallToolRequest{}, istioAnalyzeClusterConfigurationInput{ Namespace: "default", }) @@ -199,7 +199,7 @@ func TestHandleIstioVersion(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleIstioVersion(ctx, &mcp.CallToolRequest{}, istioVersionInput{}) + result, _, err := handleIstioVersion(ctx, &sdkmcp.CallToolRequest{}, istioVersionInput{}) require.NoError(t, err) assert.NotNil(t, result) @@ -212,7 +212,7 @@ func TestHandleIstioVersion(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleIstioVersion(ctx, &mcp.CallToolRequest{}, istioVersionInput{ + result, _, err := handleIstioVersion(ctx, &sdkmcp.CallToolRequest{}, istioVersionInput{ Short: true, }) @@ -230,7 +230,7 @@ func TestHandleIstioRemoteClusters(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleIstioRemoteClusters(ctx, &mcp.CallToolRequest{}, istioRemoteClustersInput{}) + result, _, err := handleIstioRemoteClusters(ctx, &sdkmcp.CallToolRequest{}, istioRemoteClustersInput{}) require.NoError(t, err) assert.NotNil(t, result) @@ -246,7 +246,7 @@ func TestHandleWaypointList(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleWaypointList(ctx, &mcp.CallToolRequest{}, waypointListInput{ + result, _, err := handleWaypointList(ctx, &sdkmcp.CallToolRequest{}, waypointListInput{ AllNamespaces: true, }) @@ -261,7 +261,7 @@ func TestHandleWaypointList(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleWaypointList(ctx, &mcp.CallToolRequest{}, waypointListInput{ + result, _, err := handleWaypointList(ctx, &sdkmcp.CallToolRequest{}, waypointListInput{ Namespace: "default", }) @@ -280,7 +280,7 @@ func TestHandleWaypointGenerate(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) - result, _, err := handleWaypointGenerate(ctx, &mcp.CallToolRequest{}, waypointGenerateInput{ + result, _, err := handleWaypointGenerate(ctx, &sdkmcp.CallToolRequest{}, waypointGenerateInput{ Namespace: "default", Name: "waypoint", TrafficType: "all", @@ -311,7 +311,7 @@ func TestIstioErrorHandling(t *testing.T) { mock.AddCommandString("istioctl", []string{"proxy-status"}, "", assert.AnError) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleIstioProxyStatus(ctx, &mcp.CallToolRequest{}, istioProxyStatusInput{}) + result, _, err := handleIstioProxyStatus(ctx, &sdkmcp.CallToolRequest{}, istioProxyStatusInput{}) require.NoError(t, err) assert.NotNil(t, result) @@ -325,7 +325,7 @@ func TestHandleWaypointApply(t *testing.T) { mock.AddCommandString("istioctl", []string{"waypoint", "apply", "-n", "default"}, "applied", nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleWaypointApply(ctx, &mcp.CallToolRequest{}, waypointApplyInput{Namespace: "default"}) + result, _, err := handleWaypointApply(ctx, &sdkmcp.CallToolRequest{}, waypointApplyInput{Namespace: "default"}) require.NoError(t, err) assert.False(t, result.IsError) }) @@ -335,7 +335,7 @@ func TestHandleWaypointApply(t *testing.T) { mock.AddCommandString("istioctl", []string{"waypoint", "apply", "-n", "default", "--enroll-namespace"}, "applied", nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleWaypointApply(ctx, &mcp.CallToolRequest{}, waypointApplyInput{ + result, _, err := handleWaypointApply(ctx, &sdkmcp.CallToolRequest{}, waypointApplyInput{ Namespace: "default", EnrollNamespace: true, }) @@ -346,7 +346,7 @@ func TestHandleWaypointApply(t *testing.T) { t.Run("missing namespace", func(t *testing.T) { mock := cmd.NewMockShellExecutor() ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleWaypointApply(ctx, &mcp.CallToolRequest{}, waypointApplyInput{}) + result, _, err := handleWaypointApply(ctx, &sdkmcp.CallToolRequest{}, waypointApplyInput{}) require.NoError(t, err) assert.True(t, result.IsError) }) @@ -355,7 +355,7 @@ func TestHandleWaypointApply(t *testing.T) { mock := cmd.NewMockShellExecutor() mock.AddCommandString("istioctl", []string{"waypoint", "apply", "-n", "default"}, "", assert.AnError) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleWaypointApply(ctx, &mcp.CallToolRequest{}, waypointApplyInput{Namespace: "default"}) + result, _, err := handleWaypointApply(ctx, &sdkmcp.CallToolRequest{}, waypointApplyInput{Namespace: "default"}) require.NoError(t, err) assert.True(t, result.IsError) }) @@ -366,7 +366,7 @@ func TestHandleWaypointDelete(t *testing.T) { mock := cmd.NewMockShellExecutor() mock.AddCommandString("istioctl", []string{"waypoint", "delete", "--all", "-n", "default"}, "deleted", nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleWaypointDelete(ctx, &mcp.CallToolRequest{}, waypointDeleteInput{ + result, _, err := handleWaypointDelete(ctx, &sdkmcp.CallToolRequest{}, waypointDeleteInput{ Namespace: "default", All: true, }) @@ -378,7 +378,7 @@ func TestHandleWaypointDelete(t *testing.T) { mock := cmd.NewMockShellExecutor() mock.AddCommandString("istioctl", []string{"waypoint", "delete", "wp1", "wp2", "-n", "default"}, "deleted", nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleWaypointDelete(ctx, &mcp.CallToolRequest{}, waypointDeleteInput{ + result, _, err := handleWaypointDelete(ctx, &sdkmcp.CallToolRequest{}, waypointDeleteInput{ Namespace: "default", Names: "wp1, wp2", }) @@ -389,7 +389,7 @@ func TestHandleWaypointDelete(t *testing.T) { t.Run("missing namespace", func(t *testing.T) { mock := cmd.NewMockShellExecutor() ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleWaypointDelete(ctx, &mcp.CallToolRequest{}, waypointDeleteInput{}) + result, _, err := handleWaypointDelete(ctx, &sdkmcp.CallToolRequest{}, waypointDeleteInput{}) require.NoError(t, err) assert.True(t, result.IsError) }) @@ -400,7 +400,7 @@ func TestHandleWaypointStatus(t *testing.T) { mock := cmd.NewMockShellExecutor() mock.AddCommandString("istioctl", []string{"waypoint", "status", "wp1", "-n", "default"}, "status", nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleWaypointStatus(ctx, &mcp.CallToolRequest{}, waypointStatusInput{ + result, _, err := handleWaypointStatus(ctx, &sdkmcp.CallToolRequest{}, waypointStatusInput{ Namespace: "default", Name: "wp1", }) @@ -412,7 +412,7 @@ func TestHandleWaypointStatus(t *testing.T) { mock := cmd.NewMockShellExecutor() mock.AddCommandString("istioctl", []string{"waypoint", "status", "-n", "default"}, "status", nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleWaypointStatus(ctx, &mcp.CallToolRequest{}, waypointStatusInput{ + result, _, err := handleWaypointStatus(ctx, &sdkmcp.CallToolRequest{}, waypointStatusInput{ Namespace: "default", }) require.NoError(t, err) @@ -422,7 +422,7 @@ func TestHandleWaypointStatus(t *testing.T) { t.Run("missing namespace", func(t *testing.T) { mock := cmd.NewMockShellExecutor() ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleWaypointStatus(ctx, &mcp.CallToolRequest{}, waypointStatusInput{}) + result, _, err := handleWaypointStatus(ctx, &sdkmcp.CallToolRequest{}, waypointStatusInput{}) require.NoError(t, err) assert.True(t, result.IsError) }) @@ -433,7 +433,7 @@ func TestHandleZtunnelConfig(t *testing.T) { mock := cmd.NewMockShellExecutor() mock.AddCommandString("istioctl", []string{"ztunnel", "config", "all"}, "ztunnel config", nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleZtunnelConfig(ctx, &mcp.CallToolRequest{}, ztunnelConfigInput{}) + result, _, err := handleZtunnelConfig(ctx, &sdkmcp.CallToolRequest{}, ztunnelConfigInput{}) require.NoError(t, err) assert.False(t, result.IsError) }) @@ -442,7 +442,7 @@ func TestHandleZtunnelConfig(t *testing.T) { mock := cmd.NewMockShellExecutor() mock.AddCommandString("istioctl", []string{"ztunnel", "config", "workloads", "-n", "istio-system"}, "ztunnel config", nil) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleZtunnelConfig(ctx, &mcp.CallToolRequest{}, ztunnelConfigInput{ + result, _, err := handleZtunnelConfig(ctx, &sdkmcp.CallToolRequest{}, ztunnelConfigInput{ ConfigType: "workloads", Namespace: "istio-system", }) @@ -454,7 +454,7 @@ func TestHandleZtunnelConfig(t *testing.T) { mock := cmd.NewMockShellExecutor() mock.AddCommandString("istioctl", []string{"ztunnel", "config", "all"}, "", assert.AnError) ctx := cmd.WithShellExecutor(context.Background(), mock) - result, _, err := handleZtunnelConfig(ctx, &mcp.CallToolRequest{}, ztunnelConfigInput{}) + result, _, err := handleZtunnelConfig(ctx, &sdkmcp.CallToolRequest{}, ztunnelConfigInput{}) require.NoError(t, err) assert.True(t, result.IsError) }) diff --git a/pkg/k8s/k8s.go b/pkg/k8s/k8s.go index 0a36eb91..5ca121d7 100644 --- a/pkg/k8s/k8s.go +++ b/pkg/k8s/k8s.go @@ -19,6 +19,7 @@ import ( "github.com/kagent-dev/tools/internal/logger" mcp "github.com/kagent-dev/tools/internal/mcp" "github.com/kagent-dev/tools/internal/security" + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" ) // K8sTool struct to hold the LLM model @@ -37,7 +38,7 @@ func NewK8sToolWithConfig(kubeconfig string, llmModel llms.Model) *K8sTool { } // runKubectlCommandWithCacheInvalidation runs a kubectl command and invalidates cache if it's a modification operation -func (k *K8sTool) runKubectlCommandWithCacheInvalidation(ctx context.Context, headers http.Header, args ...string) (*mcp.CallToolResult, error) { +func (k *K8sTool) runKubectlCommandWithCacheInvalidation(ctx context.Context, headers http.Header, args ...string) (*sdkmcp.CallToolResult, error) { result, err := k.runKubectlCommand(ctx, headers, args...) // If command succeeded and it's a modification command, invalidate cache @@ -62,7 +63,7 @@ type getResourcesInput struct { } // Enhanced kubectl get -func (k *K8sTool) handleKubectlGetEnhanced(ctx context.Context, request *mcp.CallToolRequest, in getResourcesInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func (k *K8sTool) handleKubectlGetEnhanced(ctx context.Context, request *sdkmcp.CallToolRequest, in getResourcesInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { if in.ResourceType == "" { return mcp.TextError("resource_type parameter is required") } @@ -98,7 +99,7 @@ type logsInput struct { } // Get pod logs -func (k *K8sTool) handleKubectlLogsEnhanced(ctx context.Context, request *mcp.CallToolRequest, in logsInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func (k *K8sTool) handleKubectlLogsEnhanced(ctx context.Context, request *sdkmcp.CallToolRequest, in logsInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { if in.PodName == "" { return mcp.TextError("pod_name parameter is required") } @@ -135,7 +136,7 @@ type scaleInput struct { } // Scale deployment -func (k *K8sTool) handleScaleDeployment(ctx context.Context, request *mcp.CallToolRequest, in scaleInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func (k *K8sTool) handleScaleDeployment(ctx context.Context, request *sdkmcp.CallToolRequest, in scaleInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { if in.Name == "" { return mcp.TextError("name parameter is required") } @@ -162,7 +163,7 @@ type patchResourceInput struct { } // Patch resource -func (k *K8sTool) handlePatchResource(ctx context.Context, request *mcp.CallToolRequest, in patchResourceInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func (k *K8sTool) handlePatchResource(ctx context.Context, request *sdkmcp.CallToolRequest, in patchResourceInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { if in.Namespace == "" { in.Namespace = "default" } @@ -209,7 +210,7 @@ type patchStatusInput struct { } // Patch resource status -func (k *K8sTool) handlePatchStatus(ctx context.Context, request *mcp.CallToolRequest, in patchStatusInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func (k *K8sTool) handlePatchStatus(ctx context.Context, request *sdkmcp.CallToolRequest, in patchStatusInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { if in.Namespace == "" { in.Namespace = "default" } @@ -252,7 +253,7 @@ type applyManifestInput struct { } // Apply manifest from content -func (k *K8sTool) handleApplyManifest(ctx context.Context, request *mcp.CallToolRequest, in applyManifestInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func (k *K8sTool) handleApplyManifest(ctx context.Context, request *sdkmcp.CallToolRequest, in applyManifestInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { if in.Manifest == "" { return mcp.TextError("manifest parameter is required") } @@ -297,7 +298,7 @@ type deleteResourceInput struct { } // Delete resource -func (k *K8sTool) handleDeleteResource(ctx context.Context, request *mcp.CallToolRequest, in deleteResourceInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func (k *K8sTool) handleDeleteResource(ctx context.Context, request *sdkmcp.CallToolRequest, in deleteResourceInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { if in.Namespace == "" { in.Namespace = "default" } @@ -324,7 +325,7 @@ type waitInput struct { } // Wait for a condition on one or more resources (kubectl wait) -func (k *K8sTool) handleKubectlWait(ctx context.Context, request *mcp.CallToolRequest, in waitInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func (k *K8sTool) handleKubectlWait(ctx context.Context, request *sdkmcp.CallToolRequest, in waitInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { if in.Namespace == "" { in.Namespace = "default" } @@ -370,7 +371,7 @@ type serviceConnectivityInput struct { } // Check service connectivity -func (k *K8sTool) handleCheckServiceConnectivity(ctx context.Context, request *mcp.CallToolRequest, in serviceConnectivityInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func (k *K8sTool) handleCheckServiceConnectivity(ctx context.Context, request *sdkmcp.CallToolRequest, in serviceConnectivityInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { if in.Namespace == "" { in.Namespace = "default" } @@ -409,7 +410,7 @@ type eventsInput struct { } // Get cluster events -func (k *K8sTool) handleGetEvents(ctx context.Context, request *mcp.CallToolRequest, in eventsInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func (k *K8sTool) handleGetEvents(ctx context.Context, request *sdkmcp.CallToolRequest, in eventsInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { args := []string{"get", "events", "-o", "json"} if in.Namespace != "" { args = append(args, "-n", in.Namespace) @@ -431,7 +432,7 @@ type execCommandInput struct { } // Execute command in pod -func (k *K8sTool) handleExecCommand(ctx context.Context, request *mcp.CallToolRequest, in execCommandInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func (k *K8sTool) handleExecCommand(ctx context.Context, request *sdkmcp.CallToolRequest, in execCommandInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { if in.Namespace == "" { in.Namespace = "default" } @@ -484,7 +485,7 @@ func (k *K8sTool) handleExecCommand(ctx context.Context, request *mcp.CallToolRe type noInput struct{} // Get available API resources -func (k *K8sTool) handleGetAvailableAPIResources(ctx context.Context, request *mcp.CallToolRequest, _ noInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func (k *K8sTool) handleGetAvailableAPIResources(ctx context.Context, request *sdkmcp.CallToolRequest, _ noInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { res, err := k.runKubectlCommand(ctx, mcp.Header(request), "api-resources") return res, mcp.TextOf(res), err } @@ -497,7 +498,7 @@ type describeInput struct { } // Kubectl describe tool -func (k *K8sTool) handleKubectlDescribeTool(ctx context.Context, request *mcp.CallToolRequest, in describeInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func (k *K8sTool) handleKubectlDescribeTool(ctx context.Context, request *sdkmcp.CallToolRequest, in describeInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { if in.ResourceType == "" || in.ResourceName == "" { return mcp.TextError("resource_type and resource_name parameters are required") } @@ -520,7 +521,7 @@ type rolloutInput struct { } // Rollout operations -func (k *K8sTool) handleRollout(ctx context.Context, request *mcp.CallToolRequest, in rolloutInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func (k *K8sTool) handleRollout(ctx context.Context, request *sdkmcp.CallToolRequest, in rolloutInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { if in.Action == "" || in.ResourceType == "" || in.ResourceName == "" { return mcp.TextError("action, resource_type, and resource_name parameters are required") } @@ -535,7 +536,7 @@ func (k *K8sTool) handleRollout(ctx context.Context, request *mcp.CallToolReques } // Get cluster configuration -func (k *K8sTool) handleGetClusterConfiguration(ctx context.Context, request *mcp.CallToolRequest, _ noInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func (k *K8sTool) handleGetClusterConfiguration(ctx context.Context, request *sdkmcp.CallToolRequest, _ noInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { res, err := k.runKubectlCommand(ctx, mcp.Header(request), "config", "view", "-o", "json") return res, mcp.TextOf(res), err } @@ -549,7 +550,7 @@ type removeAnnotationInput struct { } // Remove annotation -func (k *K8sTool) handleRemoveAnnotation(ctx context.Context, request *mcp.CallToolRequest, in removeAnnotationInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func (k *K8sTool) handleRemoveAnnotation(ctx context.Context, request *sdkmcp.CallToolRequest, in removeAnnotationInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { if in.ResourceType == "" || in.ResourceName == "" || in.AnnotationKey == "" { return mcp.TextError("resource_type, resource_name, and annotation_key parameters are required") } @@ -572,7 +573,7 @@ type removeLabelInput struct { } // Remove label -func (k *K8sTool) handleRemoveLabel(ctx context.Context, request *mcp.CallToolRequest, in removeLabelInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func (k *K8sTool) handleRemoveLabel(ctx context.Context, request *sdkmcp.CallToolRequest, in removeLabelInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { if in.ResourceType == "" || in.ResourceName == "" || in.LabelKey == "" { return mcp.TextError("resource_type, resource_name, and label_key parameters are required") } @@ -595,7 +596,7 @@ type annotateInput struct { } // Annotate resource -func (k *K8sTool) handleAnnotateResource(ctx context.Context, request *mcp.CallToolRequest, in annotateInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func (k *K8sTool) handleAnnotateResource(ctx context.Context, request *sdkmcp.CallToolRequest, in annotateInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { if in.ResourceType == "" || in.ResourceName == "" || in.Annotations == "" { return mcp.TextError("resource_type, resource_name, and annotations parameters are required") } @@ -620,7 +621,7 @@ type labelInput struct { } // Label resource -func (k *K8sTool) handleLabelResource(ctx context.Context, request *mcp.CallToolRequest, in labelInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func (k *K8sTool) handleLabelResource(ctx context.Context, request *sdkmcp.CallToolRequest, in labelInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { if in.ResourceType == "" || in.ResourceName == "" || in.Labels == "" { return mcp.TextError("resource_type, resource_name, and labels parameters are required") } @@ -643,7 +644,7 @@ type createFromURLInput struct { } // Create resource from URL -func (k *K8sTool) handleCreateResourceFromURL(ctx context.Context, request *mcp.CallToolRequest, in createFromURLInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func (k *K8sTool) handleCreateResourceFromURL(ctx context.Context, request *sdkmcp.CallToolRequest, in createFromURLInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { if in.URL == "" { return mcp.TextError("url parameter is required") } @@ -665,7 +666,7 @@ type getResourceYAMLInput struct { } // Get resource YAML -func (k *K8sTool) handleGetResourceYAML(ctx context.Context, request *mcp.CallToolRequest, in getResourceYAMLInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func (k *K8sTool) handleGetResourceYAML(ctx context.Context, request *sdkmcp.CallToolRequest, in getResourceYAMLInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { if in.ResourceType == "" || in.ResourceName == "" { return mcp.TextError("resource_type and resource_name are required") } @@ -688,7 +689,7 @@ type createResourceInput struct { } // Create resource from YAML content -func (k *K8sTool) handleCreateResource(ctx context.Context, request *mcp.CallToolRequest, in createResourceInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func (k *K8sTool) handleCreateResource(ctx context.Context, request *sdkmcp.CallToolRequest, in createResourceInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { if in.YAMLContent == "" { return mcp.TextError("yaml_content is required") } @@ -762,7 +763,7 @@ type generateResourceInput struct { } // Generate resource using LLM -func (k *K8sTool) handleGenerateResource(ctx context.Context, request *mcp.CallToolRequest, in generateResourceInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func (k *K8sTool) handleGenerateResource(ctx context.Context, request *sdkmcp.CallToolRequest, in generateResourceInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { if in.ResourceType == "" || in.ResourceDescription == "" { return mcp.TextError("resource_type and resource_description parameters are required") } @@ -829,7 +830,7 @@ func (k *K8sTool) tokenForKubectl(headers http.Header) (string, error) { } // runKubectlCommand is a helper function to execute kubectl commands -func (k *K8sTool) runKubectlCommand(ctx context.Context, headers http.Header, args ...string) (*mcp.CallToolResult, error) { +func (k *K8sTool) runKubectlCommand(ctx context.Context, headers http.Header, args ...string) (*sdkmcp.CallToolResult, error) { token, err := k.tokenForKubectl(headers) if err != nil { return mcp.NewToolResultError(err.Error()), nil @@ -848,7 +849,7 @@ func (k *K8sTool) runKubectlCommand(ctx context.Context, headers http.Header, ar } // runKubectlCommandWithTimeout is a helper function to execute kubectl commands with a timeout -func (k *K8sTool) runKubectlCommandWithTimeout(ctx context.Context, headers http.Header, timeout time.Duration, args ...string) (*mcp.CallToolResult, error) { +func (k *K8sTool) runKubectlCommandWithTimeout(ctx context.Context, headers http.Header, timeout time.Duration, args ...string) (*sdkmcp.CallToolResult, error) { token, err := k.tokenForKubectl(headers) if err != nil { return mcp.NewToolResultError(err.Error()), nil @@ -868,11 +869,11 @@ func (k *K8sTool) runKubectlCommandWithTimeout(ctx context.Context, headers http } // RegisterTools registers all k8s tools with the MCP server -func RegisterTools(s *mcp.Server, llm llms.Model, kubeconfig string, readOnly bool) { +func RegisterTools(s *sdkmcp.Server, llm llms.Model, kubeconfig string, readOnly bool) { k8sTool := NewK8sToolWithConfig(kubeconfig, llm) // Read-only tools - always registered - mcp.AddTool(s, "k8s", &mcp.Tool{ + mcp.AddTool(s, "k8s", &sdkmcp.Tool{ Name: "k8s_get_resources", Description: "List Kubernetes resources with kubectl. " + "Scope: with neither all_namespaces nor namespace set, this queries ONLY the namespace " + @@ -884,114 +885,114 @@ func RegisterTools(s *mcp.Server, llm llms.Model, kubeconfig string, readOnly bo "Node versions (kubelet, container runtime): resource_type=node.", }, k8sTool.handleKubectlGetEnhanced) - mcp.AddTool(s, "k8s", &mcp.Tool{ + mcp.AddTool(s, "k8s", &sdkmcp.Tool{ Name: "k8s_get_pod_logs", Description: "Get logs from a Kubernetes pod", }, k8sTool.handleKubectlLogsEnhanced) - mcp.AddTool(s, "k8s", &mcp.Tool{ + mcp.AddTool(s, "k8s", &sdkmcp.Tool{ Name: "k8s_get_events", Description: "Get events from a Kubernetes namespace", }, k8sTool.handleGetEvents) - mcp.AddTool(s, "k8s", &mcp.Tool{ + mcp.AddTool(s, "k8s", &sdkmcp.Tool{ Name: "k8s_get_available_api_resources", Description: "Get available Kubernetes API resources", }, k8sTool.handleGetAvailableAPIResources) - mcp.AddTool(s, "k8s", &mcp.Tool{ + mcp.AddTool(s, "k8s", &sdkmcp.Tool{ Name: "k8s_get_cluster_configuration", Description: "Get cluster configuration details", }, k8sTool.handleGetClusterConfiguration) - mcp.AddTool(s, "k8s", &mcp.Tool{ + mcp.AddTool(s, "k8s", &sdkmcp.Tool{ Name: "k8s_get_resource_yaml", Description: "Get the YAML representation of a Kubernetes resource", }, k8sTool.handleGetResourceYAML) - mcp.AddTool(s, "k8s", &mcp.Tool{ + mcp.AddTool(s, "k8s", &sdkmcp.Tool{ Name: "k8s_describe_resource", Description: "Describe a Kubernetes resource in detail", }, k8sTool.handleKubectlDescribeTool) - mcp.AddTool(s, "k8s", &mcp.Tool{ + mcp.AddTool(s, "k8s", &sdkmcp.Tool{ Name: "k8s_wait", Description: "Wait for a condition on Kubernetes resources (kubectl wait). Blocks until the condition is met or the timeout elapses.", }, k8sTool.handleKubectlWait) - mcp.AddTool(s, "k8s", &mcp.Tool{ + mcp.AddTool(s, "k8s", &sdkmcp.Tool{ Name: "k8s_generate_resource", Description: fmt.Sprintf("Generate a Kubernetes resource YAML from a description. Supported resource_type values: %s", strings.Join(slices.Collect(resourceTypes), ", ")), }, k8sTool.handleGenerateResource) // Write tools - only registered when write operations are enabled if !readOnly { - mcp.AddTool(s, "k8s", &mcp.Tool{ + mcp.AddTool(s, "k8s", &sdkmcp.Tool{ Name: "k8s_scale", Description: "Scale a Kubernetes deployment", }, k8sTool.handleScaleDeployment) - mcp.AddTool(s, "k8s", &mcp.Tool{ + mcp.AddTool(s, "k8s", &sdkmcp.Tool{ Name: "k8s_patch_resource", Description: "Patch a Kubernetes resource. Defaults to a strategic merge patch, which is only supported for built-in types; set patch_type to \"merge\" (or \"json\") to patch a CustomResource/CRD.", }, k8sTool.handlePatchResource) - mcp.AddTool(s, "k8s", &mcp.Tool{ + mcp.AddTool(s, "k8s", &sdkmcp.Tool{ Name: "k8s_patch_status", Description: "Patch the status of a Kubernetes resource", }, k8sTool.handlePatchStatus) - mcp.AddTool(s, "k8s", &mcp.Tool{ + mcp.AddTool(s, "k8s", &sdkmcp.Tool{ Name: "k8s_apply_manifest", Description: "Apply a YAML manifest to the Kubernetes cluster", }, k8sTool.handleApplyManifest) - mcp.AddTool(s, "k8s", &mcp.Tool{ + mcp.AddTool(s, "k8s", &sdkmcp.Tool{ Name: "k8s_delete_resource", Description: "Delete a Kubernetes resource", }, k8sTool.handleDeleteResource) - mcp.AddTool(s, "k8s", &mcp.Tool{ + mcp.AddTool(s, "k8s", &sdkmcp.Tool{ Name: "k8s_check_service_connectivity", Description: "Check connectivity to a service using a temporary curl pod", }, k8sTool.handleCheckServiceConnectivity) - mcp.AddTool(s, "k8s", &mcp.Tool{ + mcp.AddTool(s, "k8s", &sdkmcp.Tool{ Name: "k8s_execute_command", Description: "Execute a command in a Kubernetes pod", }, k8sTool.handleExecCommand) - mcp.AddTool(s, "k8s", &mcp.Tool{ + mcp.AddTool(s, "k8s", &sdkmcp.Tool{ Name: "k8s_rollout", Description: "Perform rollout operations on Kubernetes resources (history, pause, restart, resume, status, undo)", }, k8sTool.handleRollout) - mcp.AddTool(s, "k8s", &mcp.Tool{ + mcp.AddTool(s, "k8s", &sdkmcp.Tool{ Name: "k8s_label_resource", Description: "Add or update labels on a Kubernetes resource", }, k8sTool.handleLabelResource) - mcp.AddTool(s, "k8s", &mcp.Tool{ + mcp.AddTool(s, "k8s", &sdkmcp.Tool{ Name: "k8s_annotate_resource", Description: "Add or update annotations on a Kubernetes resource", }, k8sTool.handleAnnotateResource) - mcp.AddTool(s, "k8s", &mcp.Tool{ + mcp.AddTool(s, "k8s", &sdkmcp.Tool{ Name: "k8s_remove_annotation", Description: "Remove an annotation from a Kubernetes resource", }, k8sTool.handleRemoveAnnotation) - mcp.AddTool(s, "k8s", &mcp.Tool{ + mcp.AddTool(s, "k8s", &sdkmcp.Tool{ Name: "k8s_remove_label", Description: "Remove a label from a Kubernetes resource", }, k8sTool.handleRemoveLabel) - mcp.AddTool(s, "k8s", &mcp.Tool{ + mcp.AddTool(s, "k8s", &sdkmcp.Tool{ Name: "k8s_create_resource", Description: "Create a Kubernetes resource from YAML content", }, k8sTool.handleCreateResource) - mcp.AddTool(s, "k8s", &mcp.Tool{ + mcp.AddTool(s, "k8s", &sdkmcp.Tool{ Name: "k8s_create_resource_from_url", Description: "Create a Kubernetes resource from a URL pointing to a YAML manifest", }, k8sTool.handleCreateResourceFromURL) diff --git a/pkg/k8s/k8s_test.go b/pkg/k8s/k8s_test.go index 16c47422..57d85661 100644 --- a/pkg/k8s/k8s_test.go +++ b/pkg/k8s/k8s_test.go @@ -6,7 +6,7 @@ import ( "testing" "github.com/kagent-dev/tools/internal/cmd" - mcp "github.com/kagent-dev/tools/internal/mcp" + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/tmc/langchaingo/llms" @@ -14,11 +14,11 @@ import ( func TestRegisterTools(t *testing.T) { t.Run("read-write", func(t *testing.T) { - s := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "v0.0.1"}, nil) + s := sdkmcp.NewServer(&sdkmcp.Implementation{Name: "test", Version: "v0.0.1"}, nil) RegisterTools(s, nil, "", false) }) t.Run("read-only", func(t *testing.T) { - s := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "v0.0.1"}, nil) + s := sdkmcp.NewServer(&sdkmcp.Implementation{Name: "test", Version: "v0.0.1"}, nil) RegisterTools(s, nil, "/tmp/kubeconfig", true) }) } @@ -46,11 +46,11 @@ func newTestK8sToolWithLLM(llm llms.Model) *K8sTool { } // Helper function to extract text content from MCP result -func getResultText(result *mcp.CallToolResult) string { +func getResultText(result *sdkmcp.CallToolResult) string { if result == nil || len(result.Content) == 0 { return "" } - if textContent, ok := result.Content[0].(*mcp.TextContent); ok { + if textContent, ok := result.Content[0].(*sdkmcp.TextContent); ok { return textContent.Text } return "" @@ -64,8 +64,8 @@ func headerWithBearerToken(token string) http.Header { } // Helper function to create a CallToolRequest with Bearer token -func requestWithBearerToken(token string) *mcp.CallToolRequest { - return &mcp.CallToolRequest{Extra: &mcp.RequestExtra{Header: headerWithBearerToken(token)}} +func requestWithBearerToken(token string) *sdkmcp.CallToolRequest { + return &sdkmcp.CallToolRequest{Extra: &sdkmcp.RequestExtra{Header: headerWithBearerToken(token)}} } func TestHandleGetAvailableAPIResources(t *testing.T) { @@ -81,7 +81,7 @@ services svc v1 k8sTool := newTestK8sTool() - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handleGetAvailableAPIResources(ctx, req, noInput{}) assert.NoError(t, err) assert.NotNil(t, result) @@ -99,7 +99,7 @@ services svc v1 k8sTool := newTestK8sTool() - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handleGetAvailableAPIResources(ctx, req, noInput{}) assert.NoError(t, err) // MCP handlers should not return Go errors assert.NotNil(t, result) @@ -118,7 +118,7 @@ func TestHandleScaleDeployment(t *testing.T) { k8sTool := newTestK8sTool() - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handleScaleDeployment(ctx, req, scaleInput{Name: "test-deployment", Replicas: 5}) assert.NoError(t, err) assert.NotNil(t, result) @@ -135,7 +135,7 @@ func TestHandleScaleDeployment(t *testing.T) { k8sTool := newTestK8sTool() - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handleScaleDeployment(ctx, req, scaleInput{Replicas: 3}) assert.NoError(t, err) assert.NotNil(t, result) @@ -155,7 +155,7 @@ func TestHandleScaleDeployment(t *testing.T) { k8sTool := newTestK8sTool() - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handleScaleDeployment(ctx, req, scaleInput{Name: "test-deployment"}) assert.NoError(t, err) assert.NotNil(t, result) @@ -183,7 +183,7 @@ func TestHandleGetEvents(t *testing.T) { k8sTool := newTestK8sTool() - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handleGetEvents(ctx, req, eventsInput{}) assert.NoError(t, err) assert.NotNil(t, result) @@ -201,7 +201,7 @@ func TestHandleGetEvents(t *testing.T) { k8sTool := newTestK8sTool() - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handleGetEvents(ctx, req, eventsInput{Namespace: "custom-namespace"}) assert.NoError(t, err) assert.NotNil(t, result) @@ -218,7 +218,7 @@ func TestHandlePatchResource(t *testing.T) { k8sTool := newTestK8sTool() - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handlePatchResource(ctx, req, patchResourceInput{ResourceType: "deployment"}) assert.NoError(t, err) assert.NotNil(t, result) @@ -237,7 +237,7 @@ func TestHandlePatchResource(t *testing.T) { k8sTool := newTestK8sTool() - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handlePatchResource(ctx, req, patchResourceInput{ResourceType: "deployment", ResourceName: "test-deployment", Patch: `{"spec":{"replicas":5}}`}) assert.NoError(t, err) assert.NotNil(t, result) @@ -255,7 +255,7 @@ func TestHandlePatchResource(t *testing.T) { k8sTool := newTestK8sTool() - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handlePatchResource(ctx, req, patchResourceInput{ ResourceType: "installers.composition.krateo.io", ResourceName: "installer", @@ -277,7 +277,7 @@ func TestHandlePatchResource(t *testing.T) { k8sTool := newTestK8sTool() - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handlePatchResource(ctx, req, patchResourceInput{ ResourceType: "deployment", ResourceName: "test-deployment", @@ -302,7 +302,7 @@ func TestHandlePatchStatus(t *testing.T) { k8sTool := newTestK8sTool() - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handlePatchStatus(ctx, req, patchStatusInput{ResourceType: "customresource"}) assert.NoError(t, err) assert.NotNil(t, result) @@ -321,7 +321,7 @@ func TestHandlePatchStatus(t *testing.T) { k8sTool := newTestK8sTool() - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handlePatchStatus(ctx, req, patchStatusInput{ResourceType: "customresource", ResourceName: "test-resource", Patch: `{"status":{"phase":"Ready"}}`}) assert.NoError(t, err) assert.NotNil(t, result) @@ -341,7 +341,7 @@ func TestHandleDeleteResource(t *testing.T) { k8sTool := newTestK8sTool() - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handleDeleteResource(ctx, req, deleteResourceInput{ResourceType: "pod"}) assert.NoError(t, err) assert.NotNil(t, result) @@ -360,7 +360,7 @@ func TestHandleDeleteResource(t *testing.T) { k8sTool := newTestK8sTool() - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handleDeleteResource(ctx, req, deleteResourceInput{ResourceType: "deployment", ResourceName: "test-deployment"}) assert.NoError(t, err) assert.NotNil(t, result) @@ -380,7 +380,7 @@ func TestHandleCheckServiceConnectivity(t *testing.T) { k8sTool := newTestK8sTool() - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handleCheckServiceConnectivity(ctx, req, serviceConnectivityInput{}) assert.NoError(t, err) assert.NotNil(t, result) @@ -404,7 +404,7 @@ func TestHandleCheckServiceConnectivity(t *testing.T) { k8sTool := newTestK8sTool() - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handleCheckServiceConnectivity(ctx, req, serviceConnectivityInput{ServiceName: "test-service.default.svc.cluster.local:80"}) assert.NoError(t, err) assert.NotNil(t, result) @@ -421,7 +421,7 @@ func TestHandleKubectlDescribeTool(t *testing.T) { k8sTool := newTestK8sTool() - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handleKubectlDescribeTool(ctx, req, describeInput{ResourceType: "deployment"}) assert.NoError(t, err) assert.NotNil(t, result) @@ -442,7 +442,7 @@ Labels: app=test` k8sTool := newTestK8sTool() - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handleKubectlDescribeTool(ctx, req, describeInput{ResourceType: "deployment", ResourceName: "test-deployment", Namespace: "default"}) assert.NoError(t, err) assert.NotNil(t, result) @@ -461,7 +461,7 @@ func TestHandleKubectlGetEnhanced(t *testing.T) { ctx := cmd.WithShellExecutor(context.Background(), mock) k8sTool := newTestK8sTool() - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handleKubectlGetEnhanced(ctx, req, getResourcesInput{}) assert.NoError(t, err) assert.NotNil(t, result) @@ -479,7 +479,7 @@ func TestHandleKubectlGetEnhanced(t *testing.T) { ctx := cmd.WithShellExecutor(ctx, mock) k8sTool := newTestK8sTool() - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handleKubectlGetEnhanced(ctx, req, getResourcesInput{ResourceType: "pods"}) assert.NoError(t, err) assert.NotNil(t, result) @@ -495,7 +495,7 @@ func TestHandleKubectlLogsEnhanced(t *testing.T) { ctx := cmd.WithShellExecutor(context.Background(), mock) k8sTool := newTestK8sTool() - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handleKubectlLogsEnhanced(ctx, req, logsInput{}) assert.NoError(t, err) assert.NotNil(t, result) @@ -514,7 +514,7 @@ log line 2` ctx := cmd.WithShellExecutor(ctx, mock) k8sTool := newTestK8sTool() - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handleKubectlLogsEnhanced(ctx, req, logsInput{PodName: "test-pod"}) assert.NoError(t, err) assert.NotNil(t, result) @@ -529,7 +529,7 @@ log line 2` logsCtx := cmd.WithShellExecutor(context.Background(), mock) k8sTool := newTestK8sTool() - result, _, err := k8sTool.handleKubectlLogsEnhanced(logsCtx, &mcp.CallToolRequest{}, + result, _, err := k8sTool.handleKubectlLogsEnhanced(logsCtx, &sdkmcp.CallToolRequest{}, logsInput{PodName: "test-pod", Previous: true}) assert.NoError(t, err) assert.False(t, result.IsError) @@ -545,7 +545,7 @@ log line 2` logsCtx := cmd.WithShellExecutor(context.Background(), mock) k8sTool := newTestK8sTool() - _, _, err := k8sTool.handleKubectlLogsEnhanced(logsCtx, &mcp.CallToolRequest{}, + _, _, err := k8sTool.handleKubectlLogsEnhanced(logsCtx, &sdkmcp.CallToolRequest{}, logsInput{PodName: "test-pod", Previous: false}) assert.NoError(t, err) @@ -575,7 +575,7 @@ spec: k8sTool := newTestK8sTool() - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handleApplyManifest(ctx, req, applyManifestInput{Manifest: manifest}) assert.NoError(t, err) assert.NotNil(t, result) @@ -602,7 +602,7 @@ spec: k8sTool := newTestK8sTool() - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handleApplyManifest(ctx, req, applyManifestInput{}) assert.NoError(t, err) assert.NotNil(t, result) @@ -629,7 +629,7 @@ drwxr-xr-x 1 root root 4096 Jan 1 12:00 ..` k8sTool := newTestK8sTool() - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handleExecCommand(ctx, req, execCommandInput{PodName: "mypod", Namespace: "default", Command: "ls -la"}) assert.NoError(t, err) assert.NotNil(t, result) @@ -656,7 +656,7 @@ drwxr-xr-x 1 root root 4096 Jan 1 12:00 ..` k8sTool := newTestK8sTool() - result, _, err := k8sTool.handleExecCommand(ctx, &mcp.CallToolRequest{}, execCommandInput{ + result, _, err := k8sTool.handleExecCommand(ctx, &sdkmcp.CallToolRequest{}, execCommandInput{ PodName: "mypod", Namespace: "default", Command: "uname", Args: []string{"-a"}, }) assert.NoError(t, err) @@ -673,7 +673,7 @@ drwxr-xr-x 1 root root 4096 Jan 1 12:00 ..` k8sTool := newTestK8sTool() - _, _, err := k8sTool.handleExecCommand(ctx, &mcp.CallToolRequest{}, execCommandInput{ + _, _, err := k8sTool.handleExecCommand(ctx, &sdkmcp.CallToolRequest{}, execCommandInput{ PodName: "mypod", Namespace: "default", Command: "echo", Args: []string{"hello world"}, }) assert.NoError(t, err) @@ -690,7 +690,7 @@ drwxr-xr-x 1 root root 4096 Jan 1 12:00 ..` k8sTool := newTestK8sTool() - _, _, err := k8sTool.handleExecCommand(ctx, &mcp.CallToolRequest{}, execCommandInput{ + _, _, err := k8sTool.handleExecCommand(ctx, &sdkmcp.CallToolRequest{}, execCommandInput{ PodName: "mypod", Namespace: "default", Container: "sidecar", Command: "uname", }) assert.NoError(t, err) @@ -706,7 +706,7 @@ drwxr-xr-x 1 root root 4096 Jan 1 12:00 ..` k8sTool := newTestK8sTool() - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handleExecCommand(ctx, req, execCommandInput{PodName: "mypod"}) assert.NoError(t, err) assert.NotNil(t, result) @@ -730,7 +730,7 @@ func TestHandleRollout(t *testing.T) { k8sTool := newTestK8sTool() - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handleRollout(ctx, req, rolloutInput{ Action: "restart", ResourceType: "deployment", @@ -758,7 +758,7 @@ func TestHandleRollout(t *testing.T) { k8sTool := newTestK8sTool() - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handleRollout(ctx, req, rolloutInput{Action: "restart"}) assert.NoError(t, err) assert.NotNil(t, result) @@ -815,7 +815,7 @@ spec: k8sTool := newTestK8sToolWithLLM(mockLLM) - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handleGenerateResource(ctx, req, generateResourceInput{ResourceType: "istio_auth_policy", ResourceDescription: "A peer authentication policy for strict mTLS"}) assert.NoError(t, err) assert.NotNil(t, result) @@ -832,7 +832,7 @@ spec: t.Run("missing parameters", func(t *testing.T) { k8sTool := newTestK8sTool() - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handleGenerateResource(ctx, req, generateResourceInput{ResourceType: "istio_auth_policy"}) assert.NoError(t, err) assert.NotNil(t, result) @@ -843,7 +843,7 @@ spec: t.Run("no LLM model", func(t *testing.T) { k8sTool := newTestK8sTool() // No LLM model - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handleGenerateResource(ctx, req, generateResourceInput{ResourceType: "istio_auth_policy", ResourceDescription: "A peer authentication policy for strict mTLS"}) assert.NoError(t, err) assert.NotNil(t, result) @@ -860,7 +860,7 @@ spec: k8sTool := newTestK8sToolWithLLM(mockLLM) - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handleGenerateResource(ctx, req, generateResourceInput{ResourceType: "invalid_resource_type", ResourceDescription: "A test resource"}) assert.NoError(t, err) assert.NotNil(t, result) @@ -884,7 +884,7 @@ func TestHandleAnnotateResource(t *testing.T) { k8sTool := newTestK8sTool() - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handleAnnotateResource(ctx, req, annotateInput{ ResourceType: "deployment", ResourceName: "test-deployment", @@ -905,7 +905,7 @@ func TestHandleAnnotateResource(t *testing.T) { k8sTool := newTestK8sTool() - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handleAnnotateResource(ctx, req, annotateInput{ResourceType: "deployment"}) assert.NoError(t, err) assert.NotNil(t, result) @@ -929,7 +929,7 @@ func TestHandleLabelResource(t *testing.T) { k8sTool := newTestK8sTool() - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handleLabelResource(ctx, req, labelInput{ ResourceType: "deployment", ResourceName: "test-deployment", @@ -950,7 +950,7 @@ func TestHandleLabelResource(t *testing.T) { k8sTool := newTestK8sTool() - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handleLabelResource(ctx, req, labelInput{ResourceType: "deployment"}) assert.NoError(t, err) assert.NotNil(t, result) @@ -974,7 +974,7 @@ func TestHandleRemoveAnnotation(t *testing.T) { k8sTool := newTestK8sTool() - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handleRemoveAnnotation(ctx, req, removeAnnotationInput{ ResourceType: "deployment", ResourceName: "test-deployment", @@ -995,7 +995,7 @@ func TestHandleRemoveAnnotation(t *testing.T) { k8sTool := newTestK8sTool() - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handleRemoveAnnotation(ctx, req, removeAnnotationInput{ResourceType: "deployment"}) assert.NoError(t, err) assert.NotNil(t, result) @@ -1019,7 +1019,7 @@ func TestHandleRemoveLabel(t *testing.T) { k8sTool := newTestK8sTool() - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handleRemoveLabel(ctx, req, removeLabelInput{ ResourceType: "deployment", ResourceName: "test-deployment", @@ -1040,7 +1040,7 @@ func TestHandleRemoveLabel(t *testing.T) { k8sTool := newTestK8sTool() - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handleRemoveLabel(ctx, req, removeLabelInput{ResourceType: "deployment"}) assert.NoError(t, err) assert.NotNil(t, result) @@ -1064,7 +1064,7 @@ func TestHandleCreateResourceFromURL(t *testing.T) { k8sTool := newTestK8sTool() - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handleCreateResourceFromURL(ctx, req, createFromURLInput{URL: "https://example.com/manifest.yaml", Namespace: "default"}) assert.NoError(t, err) assert.NotNil(t, result) @@ -1080,7 +1080,7 @@ func TestHandleCreateResourceFromURL(t *testing.T) { k8sTool := newTestK8sTool() - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handleCreateResourceFromURL(ctx, req, createFromURLInput{}) assert.NoError(t, err) assert.NotNil(t, result) @@ -1118,7 +1118,7 @@ users: k8sTool := newTestK8sTool() - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handleGetClusterConfiguration(ctx, req, noInput{}) assert.NoError(t, err) assert.NotNil(t, result) @@ -1468,7 +1468,7 @@ metadata: t.Run("returns error when passthrough true and authorization header missing", func(t *testing.T) { k8sTool := newTestK8sToolWithPassthrough(true) - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handleKubectlGetEnhanced(ctx, req, getResourcesInput{ResourceType: "pods"}) assert.NoError(t, err) assert.NotNil(t, result) @@ -1484,7 +1484,7 @@ metadata: ctx := cmd.WithShellExecutor(ctx, mock) k8sTool := newTestK8sToolWithPassthrough(false) - req := &mcp.CallToolRequest{} + req := &sdkmcp.CallToolRequest{} result, _, err := k8sTool.handleKubectlGetEnhanced(ctx, req, getResourcesInput{ResourceType: "pods"}) assert.NoError(t, err) assert.NotNil(t, result) @@ -1504,7 +1504,7 @@ metadata: ctx := cmd.WithShellExecutor(ctx, mock) k8sTool := newTestK8sToolWithPassthrough(false) - req := &mcp.CallToolRequest{Extra: &mcp.RequestExtra{Header: http.Header{}}} + req := &sdkmcp.CallToolRequest{Extra: &sdkmcp.RequestExtra{Header: http.Header{}}} req.Extra.Header.Set("Authorization", "Basic dXNlcjpwYXNz") result, _, err := k8sTool.handleKubectlGetEnhanced(ctx, req, getResourcesInput{ResourceType: "pods"}) assert.NoError(t, err) diff --git a/pkg/kubescape/kubescape.go b/pkg/kubescape/kubescape.go index fbaa3977..dcadfee7 100644 --- a/pkg/kubescape/kubescape.go +++ b/pkg/kubescape/kubescape.go @@ -12,6 +12,7 @@ import ( helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers" "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" spdxv1beta1 "github.com/kubescape/storage/pkg/generated/clientset/versioned/typed/softwarecomposition/v1beta1" + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" corev1 "k8s.io/api/core/v1" apiextensionsclientset "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" k8serrors "k8s.io/apimachinery/pkg/api/errors" @@ -37,7 +38,7 @@ const ( ) // kubescapeErrResult adapts ToolError to an MCP error result. -func kubescapeErrResult(toolErr *errors.ToolError) *mcp.CallToolResult { +func kubescapeErrResult(toolErr *errors.ToolError) *sdkmcp.CallToolResult { return toolErr.ToMCPResult() } @@ -307,7 +308,7 @@ type getNetworkNeighborhoodOutput struct { } // handleCheckHealth verifies Kubescape operator installation and readiness -func (k *KubescapeTool) handleCheckHealth(ctx context.Context, request *mcp.CallToolRequest, in checkHealthInput) (*mcp.CallToolResult, HealthCheckResult, error) { +func (k *KubescapeTool) handleCheckHealth(ctx context.Context, request *sdkmcp.CallToolRequest, in checkHealthInput) (*sdkmcp.CallToolResult, HealthCheckResult, error) { if k.initError != nil { toolErr := errors.NewKubescapeError("check_health", k.initError) return kubescapeErrResult(toolErr), HealthCheckResult{}, nil @@ -658,7 +659,7 @@ func (k *KubescapeTool) handleCheckHealth(ctx context.Context, request *mcp.Call } // handleListVulnerabilityManifests lists vulnerability manifests at image and workload levels -func (k *KubescapeTool) handleListVulnerabilityManifests(ctx context.Context, request *mcp.CallToolRequest, in listVulnerabilityManifestsInput) (*mcp.CallToolResult, listVulnerabilityManifestsOutput, error) { +func (k *KubescapeTool) handleListVulnerabilityManifests(ctx context.Context, request *sdkmcp.CallToolRequest, in listVulnerabilityManifestsInput) (*sdkmcp.CallToolResult, listVulnerabilityManifestsOutput, error) { if k.initError != nil { toolErr := errors.NewKubescapeError("list_vulnerability_manifests", k.initError) return kubescapeErrResult(toolErr), listVulnerabilityManifestsOutput{}, nil @@ -735,7 +736,7 @@ func (k *KubescapeTool) handleListVulnerabilityManifests(ctx context.Context, re } // handleListVulnerabilitiesInManifest lists all CVEs in a specific manifest -func (k *KubescapeTool) handleListVulnerabilitiesInManifest(ctx context.Context, request *mcp.CallToolRequest, in listVulnerabilitiesInManifestInput) (*mcp.CallToolResult, listVulnerabilitiesInManifestOutput, error) { +func (k *KubescapeTool) handleListVulnerabilitiesInManifest(ctx context.Context, request *sdkmcp.CallToolRequest, in listVulnerabilitiesInManifestInput) (*sdkmcp.CallToolResult, listVulnerabilitiesInManifestOutput, error) { if k.initError != nil { toolErr := errors.NewKubescapeError("list_vulnerabilities", k.initError) return kubescapeErrResult(toolErr), listVulnerabilitiesInManifestOutput{}, nil @@ -811,7 +812,7 @@ func (k *KubescapeTool) handleListVulnerabilitiesInManifest(ctx context.Context, } // handleGetVulnerabilityDetails gets detailed info about a specific CVE in a manifest -func (k *KubescapeTool) handleGetVulnerabilityDetails(ctx context.Context, request *mcp.CallToolRequest, in getVulnerabilityDetailsInput) (*mcp.CallToolResult, []v1beta1.Match, error) { +func (k *KubescapeTool) handleGetVulnerabilityDetails(ctx context.Context, request *sdkmcp.CallToolRequest, in getVulnerabilityDetailsInput) (*sdkmcp.CallToolResult, []v1beta1.Match, error) { if k.initError != nil { toolErr := errors.NewKubescapeError("get_vulnerability_details", k.initError) return kubescapeErrResult(toolErr), nil, nil @@ -859,7 +860,7 @@ func (k *KubescapeTool) handleGetVulnerabilityDetails(ctx context.Context, reque } // handleListConfigurationScans lists configuration security scan results -func (k *KubescapeTool) handleListConfigurationScans(ctx context.Context, request *mcp.CallToolRequest, in listConfigurationScansInput) (*mcp.CallToolResult, listConfigurationScansOutput, error) { +func (k *KubescapeTool) handleListConfigurationScans(ctx context.Context, request *sdkmcp.CallToolRequest, in listConfigurationScansInput) (*sdkmcp.CallToolResult, listConfigurationScansOutput, error) { if k.initError != nil { toolErr := errors.NewKubescapeError("list_configuration_scans", k.initError) return kubescapeErrResult(toolErr), listConfigurationScansOutput{}, nil @@ -902,7 +903,7 @@ func (k *KubescapeTool) handleListConfigurationScans(ctx context.Context, reques } // handleGetConfigurationScan gets details of a specific configuration scan -func (k *KubescapeTool) handleGetConfigurationScan(ctx context.Context, request *mcp.CallToolRequest, in getConfigurationScanInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func (k *KubescapeTool) handleGetConfigurationScan(ctx context.Context, request *sdkmcp.CallToolRequest, in getConfigurationScanInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { if k.initError != nil { toolErr := errors.NewKubescapeError("get_configuration_scan", k.initError) res := kubescapeErrResult(toolErr) @@ -937,7 +938,7 @@ func (k *KubescapeTool) handleGetConfigurationScan(ctx context.Context, request } // handleListApplicationProfiles lists application profiles showing runtime behavior data -func (k *KubescapeTool) handleListApplicationProfiles(ctx context.Context, request *mcp.CallToolRequest, in listApplicationProfilesInput) (*mcp.CallToolResult, listApplicationProfilesOutput, error) { +func (k *KubescapeTool) handleListApplicationProfiles(ctx context.Context, request *sdkmcp.CallToolRequest, in listApplicationProfilesInput) (*sdkmcp.CallToolResult, listApplicationProfilesOutput, error) { if k.initError != nil { toolErr := errors.NewKubescapeError("list_application_profiles", k.initError) return kubescapeErrResult(toolErr), listApplicationProfilesOutput{}, nil @@ -1011,7 +1012,7 @@ func (k *KubescapeTool) handleListApplicationProfiles(ctx context.Context, reque } // handleGetApplicationProfile gets detailed runtime behavior for a specific workload -func (k *KubescapeTool) handleGetApplicationProfile(ctx context.Context, request *mcp.CallToolRequest, in getApplicationProfileInput) (*mcp.CallToolResult, getApplicationProfileOutput, error) { +func (k *KubescapeTool) handleGetApplicationProfile(ctx context.Context, request *sdkmcp.CallToolRequest, in getApplicationProfileInput) (*sdkmcp.CallToolResult, getApplicationProfileOutput, error) { if k.initError != nil { toolErr := errors.NewKubescapeError("get_application_profile", k.initError) return kubescapeErrResult(toolErr), getApplicationProfileOutput{}, nil @@ -1087,7 +1088,7 @@ func (k *KubescapeTool) handleGetApplicationProfile(ctx context.Context, request } // handleListNetworkNeighborhoods lists network communication patterns for workloads -func (k *KubescapeTool) handleListNetworkNeighborhoods(ctx context.Context, request *mcp.CallToolRequest, in listNetworkNeighborhoodsInput) (*mcp.CallToolResult, listNetworkNeighborhoodsOutput, error) { +func (k *KubescapeTool) handleListNetworkNeighborhoods(ctx context.Context, request *sdkmcp.CallToolRequest, in listNetworkNeighborhoodsInput) (*sdkmcp.CallToolResult, listNetworkNeighborhoodsOutput, error) { if k.initError != nil { toolErr := errors.NewKubescapeError("list_network_neighborhoods", k.initError) return kubescapeErrResult(toolErr), listNetworkNeighborhoodsOutput{}, nil @@ -1144,7 +1145,7 @@ func (k *KubescapeTool) handleListNetworkNeighborhoods(ctx context.Context, requ } // handleGetNetworkNeighborhood gets detailed network connections for a specific workload -func (k *KubescapeTool) handleGetNetworkNeighborhood(ctx context.Context, request *mcp.CallToolRequest, in getNetworkNeighborhoodInput) (*mcp.CallToolResult, getNetworkNeighborhoodOutput, error) { +func (k *KubescapeTool) handleGetNetworkNeighborhood(ctx context.Context, request *sdkmcp.CallToolRequest, in getNetworkNeighborhoodInput) (*sdkmcp.CallToolResult, getNetworkNeighborhoodOutput, error) { if k.initError != nil { toolErr := errors.NewKubescapeError("get_network_neighborhood", k.initError) return kubescapeErrResult(toolErr), getNetworkNeighborhoodOutput{}, nil @@ -1256,16 +1257,16 @@ func truncateString(s string, maxLen int) string { } // RegisterTools registers all Kubescape tools with the MCP server -func RegisterTools(s *mcp.Server, kubeconfig string, readOnly bool) { +func RegisterTools(s *sdkmcp.Server, kubeconfig string, readOnly bool) { tool := NewKubescapeTool(kubeconfig) _ = readOnly // all kubescape tools are read-only - mcp.AddTool(s, "kubescape", &mcp.Tool{ + mcp.AddTool(s, "kubescape", &sdkmcp.Tool{ Name: "kubescape_check_health", Description: "Check if Kubescape operator is installed and operational. Verifies namespace, operator pods, storage pods, CRDs, and scan data availability.", }, tool.handleCheckHealth) - mcp.AddTool(s, "kubescape", &mcp.Tool{ + mcp.AddTool(s, "kubescape", &sdkmcp.Tool{ Name: "kubescape_list_vulnerability_manifests", Description: "List vulnerability manifests from Kubescape operator, at image or workload level. " + "This is an index only: it does NOT report how many vulnerabilities each manifest contains, " + @@ -1274,27 +1275,27 @@ func RegisterTools(s *mcp.Server, kubeconfig string, readOnly bool) { "with its manifest_name.", }, tool.handleListVulnerabilityManifests) - mcp.AddTool(s, "kubescape", &mcp.Tool{ + mcp.AddTool(s, "kubescape", &sdkmcp.Tool{ Name: "kubescape_list_vulnerabilities", Description: "List all CVEs/vulnerabilities found in a specific vulnerability manifest. Returns severity summary and vulnerability details.", }, tool.handleListVulnerabilitiesInManifest) - mcp.AddTool(s, "kubescape", &mcp.Tool{ + mcp.AddTool(s, "kubescape", &sdkmcp.Tool{ Name: "kubescape_get_vulnerability_details", Description: "Get detailed information about a specific CVE in a vulnerability manifest, including affected packages and fix information.", }, tool.handleGetVulnerabilityDetails) - mcp.AddTool(s, "kubescape", &mcp.Tool{ + mcp.AddTool(s, "kubescape", &sdkmcp.Tool{ Name: "kubescape_list_configuration_scans", Description: "List configuration security scan results from Kubescape operator. Shows workloads that have been scanned for security misconfigurations.", }, tool.handleListConfigurationScans) - mcp.AddTool(s, "kubescape", &mcp.Tool{ + mcp.AddTool(s, "kubescape", &sdkmcp.Tool{ Name: "kubescape_get_configuration_scan", Description: "Get detailed configuration security scan results for a specific workload, including failed controls and remediation guidance.", }, tool.handleGetConfigurationScan) - mcp.AddTool(s, "kubescape", &mcp.Tool{ + mcp.AddTool(s, "kubescape", &sdkmcp.Tool{ Name: "kubescape_list_application_profiles", Description: "List ApplicationProfiles showing runtime behavior of workloads. These profiles capture: " + "executed processes (Execs), file access patterns (Opens), system calls (Syscalls), Linux capabilities used, and HTTP endpoints. " + @@ -1302,14 +1303,14 @@ func RegisterTools(s *mcp.Server, kubeconfig string, readOnly bool) { "Requires 'capabilities.runtimeObservability=enable' in Kubescape Helm chart.", }, tool.handleListApplicationProfiles) - mcp.AddTool(s, "kubescape", &mcp.Tool{ + mcp.AddTool(s, "kubescape", &sdkmcp.Tool{ Name: "kubescape_get_application_profile", Description: "Get detailed runtime behavior profile for a specific workload. Shows what processes run, what files are accessed, " + "what system calls are made, and what capabilities are used per container. " + "Compare with CVE findings to prioritize remediation - focus on vulnerabilities affecting actively used components.", }, tool.handleGetApplicationProfile) - mcp.AddTool(s, "kubescape", &mcp.Tool{ + mcp.AddTool(s, "kubescape", &sdkmcp.Tool{ Name: "kubescape_list_network_neighborhoods", Description: "List NetworkNeighborhoods showing actual network communication patterns of workloads. " + "These capture: ingress connections (who talks TO the workload), egress connections (who the workload talks TO), " + @@ -1318,7 +1319,7 @@ func RegisterTools(s *mcp.Server, kubeconfig string, readOnly bool) { "Requires 'capabilities.runtimeObservability=enable' in Kubescape Helm chart.", }, tool.handleListNetworkNeighborhoods) - mcp.AddTool(s, "kubescape", &mcp.Tool{ + mcp.AddTool(s, "kubescape", &sdkmcp.Tool{ Name: "kubescape_get_network_neighborhood", Description: "Get detailed network connections for a specific workload. Shows all observed ingress and egress traffic " + "with DNS names, IPs, ports, and protocols. Use this to verify if a workload with a vulnerability is actually exposed to the network.", @@ -1329,58 +1330,58 @@ func RegisterTools(s *mcp.Server, kubeconfig string, readOnly bool) { // Interfaces for testing - allows mocking the Kubernetes clients type KubescapeToolInterface interface { - HandleCheckHealth(ctx context.Context, in checkHealthInput) (*mcp.CallToolResult, HealthCheckResult, error) - HandleListVulnerabilityManifests(ctx context.Context, in listVulnerabilityManifestsInput) (*mcp.CallToolResult, listVulnerabilityManifestsOutput, error) - HandleListVulnerabilitiesInManifest(ctx context.Context, in listVulnerabilitiesInManifestInput) (*mcp.CallToolResult, listVulnerabilitiesInManifestOutput, error) - HandleGetVulnerabilityDetails(ctx context.Context, in getVulnerabilityDetailsInput) (*mcp.CallToolResult, []v1beta1.Match, error) - HandleListConfigurationScans(ctx context.Context, in listConfigurationScansInput) (*mcp.CallToolResult, listConfigurationScansOutput, error) - HandleGetConfigurationScan(ctx context.Context, in getConfigurationScanInput) (*mcp.CallToolResult, mcp.TextOutput, error) - HandleListApplicationProfiles(ctx context.Context, in listApplicationProfilesInput) (*mcp.CallToolResult, listApplicationProfilesOutput, error) - HandleGetApplicationProfile(ctx context.Context, in getApplicationProfileInput) (*mcp.CallToolResult, getApplicationProfileOutput, error) - HandleListNetworkNeighborhoods(ctx context.Context, in listNetworkNeighborhoodsInput) (*mcp.CallToolResult, listNetworkNeighborhoodsOutput, error) - HandleGetNetworkNeighborhood(ctx context.Context, in getNetworkNeighborhoodInput) (*mcp.CallToolResult, getNetworkNeighborhoodOutput, error) + HandleCheckHealth(ctx context.Context, in checkHealthInput) (*sdkmcp.CallToolResult, HealthCheckResult, error) + HandleListVulnerabilityManifests(ctx context.Context, in listVulnerabilityManifestsInput) (*sdkmcp.CallToolResult, listVulnerabilityManifestsOutput, error) + HandleListVulnerabilitiesInManifest(ctx context.Context, in listVulnerabilitiesInManifestInput) (*sdkmcp.CallToolResult, listVulnerabilitiesInManifestOutput, error) + HandleGetVulnerabilityDetails(ctx context.Context, in getVulnerabilityDetailsInput) (*sdkmcp.CallToolResult, []v1beta1.Match, error) + HandleListConfigurationScans(ctx context.Context, in listConfigurationScansInput) (*sdkmcp.CallToolResult, listConfigurationScansOutput, error) + HandleGetConfigurationScan(ctx context.Context, in getConfigurationScanInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) + HandleListApplicationProfiles(ctx context.Context, in listApplicationProfilesInput) (*sdkmcp.CallToolResult, listApplicationProfilesOutput, error) + HandleGetApplicationProfile(ctx context.Context, in getApplicationProfileInput) (*sdkmcp.CallToolResult, getApplicationProfileOutput, error) + HandleListNetworkNeighborhoods(ctx context.Context, in listNetworkNeighborhoodsInput) (*sdkmcp.CallToolResult, listNetworkNeighborhoodsOutput, error) + HandleGetNetworkNeighborhood(ctx context.Context, in getNetworkNeighborhoodInput) (*sdkmcp.CallToolResult, getNetworkNeighborhoodOutput, error) } // Ensure KubescapeTool implements the interface var _ KubescapeToolInterface = (*KubescapeTool)(nil) // Export handler methods for testing -func (k *KubescapeTool) HandleCheckHealth(ctx context.Context, in checkHealthInput) (*mcp.CallToolResult, HealthCheckResult, error) { - return k.handleCheckHealth(ctx, &mcp.CallToolRequest{}, in) +func (k *KubescapeTool) HandleCheckHealth(ctx context.Context, in checkHealthInput) (*sdkmcp.CallToolResult, HealthCheckResult, error) { + return k.handleCheckHealth(ctx, &sdkmcp.CallToolRequest{}, in) } -func (k *KubescapeTool) HandleListVulnerabilityManifests(ctx context.Context, in listVulnerabilityManifestsInput) (*mcp.CallToolResult, listVulnerabilityManifestsOutput, error) { - return k.handleListVulnerabilityManifests(ctx, &mcp.CallToolRequest{}, in) +func (k *KubescapeTool) HandleListVulnerabilityManifests(ctx context.Context, in listVulnerabilityManifestsInput) (*sdkmcp.CallToolResult, listVulnerabilityManifestsOutput, error) { + return k.handleListVulnerabilityManifests(ctx, &sdkmcp.CallToolRequest{}, in) } -func (k *KubescapeTool) HandleListVulnerabilitiesInManifest(ctx context.Context, in listVulnerabilitiesInManifestInput) (*mcp.CallToolResult, listVulnerabilitiesInManifestOutput, error) { - return k.handleListVulnerabilitiesInManifest(ctx, &mcp.CallToolRequest{}, in) +func (k *KubescapeTool) HandleListVulnerabilitiesInManifest(ctx context.Context, in listVulnerabilitiesInManifestInput) (*sdkmcp.CallToolResult, listVulnerabilitiesInManifestOutput, error) { + return k.handleListVulnerabilitiesInManifest(ctx, &sdkmcp.CallToolRequest{}, in) } -func (k *KubescapeTool) HandleGetVulnerabilityDetails(ctx context.Context, in getVulnerabilityDetailsInput) (*mcp.CallToolResult, []v1beta1.Match, error) { - return k.handleGetVulnerabilityDetails(ctx, &mcp.CallToolRequest{}, in) +func (k *KubescapeTool) HandleGetVulnerabilityDetails(ctx context.Context, in getVulnerabilityDetailsInput) (*sdkmcp.CallToolResult, []v1beta1.Match, error) { + return k.handleGetVulnerabilityDetails(ctx, &sdkmcp.CallToolRequest{}, in) } -func (k *KubescapeTool) HandleListConfigurationScans(ctx context.Context, in listConfigurationScansInput) (*mcp.CallToolResult, listConfigurationScansOutput, error) { - return k.handleListConfigurationScans(ctx, &mcp.CallToolRequest{}, in) +func (k *KubescapeTool) HandleListConfigurationScans(ctx context.Context, in listConfigurationScansInput) (*sdkmcp.CallToolResult, listConfigurationScansOutput, error) { + return k.handleListConfigurationScans(ctx, &sdkmcp.CallToolRequest{}, in) } -func (k *KubescapeTool) HandleGetConfigurationScan(ctx context.Context, in getConfigurationScanInput) (*mcp.CallToolResult, mcp.TextOutput, error) { - return k.handleGetConfigurationScan(ctx, &mcp.CallToolRequest{}, in) +func (k *KubescapeTool) HandleGetConfigurationScan(ctx context.Context, in getConfigurationScanInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { + return k.handleGetConfigurationScan(ctx, &sdkmcp.CallToolRequest{}, in) } -func (k *KubescapeTool) HandleListApplicationProfiles(ctx context.Context, in listApplicationProfilesInput) (*mcp.CallToolResult, listApplicationProfilesOutput, error) { - return k.handleListApplicationProfiles(ctx, &mcp.CallToolRequest{}, in) +func (k *KubescapeTool) HandleListApplicationProfiles(ctx context.Context, in listApplicationProfilesInput) (*sdkmcp.CallToolResult, listApplicationProfilesOutput, error) { + return k.handleListApplicationProfiles(ctx, &sdkmcp.CallToolRequest{}, in) } -func (k *KubescapeTool) HandleGetApplicationProfile(ctx context.Context, in getApplicationProfileInput) (*mcp.CallToolResult, getApplicationProfileOutput, error) { - return k.handleGetApplicationProfile(ctx, &mcp.CallToolRequest{}, in) +func (k *KubescapeTool) HandleGetApplicationProfile(ctx context.Context, in getApplicationProfileInput) (*sdkmcp.CallToolResult, getApplicationProfileOutput, error) { + return k.handleGetApplicationProfile(ctx, &sdkmcp.CallToolRequest{}, in) } -func (k *KubescapeTool) HandleListNetworkNeighborhoods(ctx context.Context, in listNetworkNeighborhoodsInput) (*mcp.CallToolResult, listNetworkNeighborhoodsOutput, error) { - return k.handleListNetworkNeighborhoods(ctx, &mcp.CallToolRequest{}, in) +func (k *KubescapeTool) HandleListNetworkNeighborhoods(ctx context.Context, in listNetworkNeighborhoodsInput) (*sdkmcp.CallToolResult, listNetworkNeighborhoodsOutput, error) { + return k.handleListNetworkNeighborhoods(ctx, &sdkmcp.CallToolRequest{}, in) } -func (k *KubescapeTool) HandleGetNetworkNeighborhood(ctx context.Context, in getNetworkNeighborhoodInput) (*mcp.CallToolResult, getNetworkNeighborhoodOutput, error) { - return k.handleGetNetworkNeighborhood(ctx, &mcp.CallToolRequest{}, in) +func (k *KubescapeTool) HandleGetNetworkNeighborhood(ctx context.Context, in getNetworkNeighborhoodInput) (*sdkmcp.CallToolResult, getNetworkNeighborhoodOutput, error) { + return k.handleGetNetworkNeighborhood(ctx, &sdkmcp.CallToolRequest{}, in) } diff --git a/pkg/kubescape/kubescape_test.go b/pkg/kubescape/kubescape_test.go index 24160041..fd9a6725 100644 --- a/pkg/kubescape/kubescape_test.go +++ b/pkg/kubescape/kubescape_test.go @@ -6,9 +6,9 @@ import ( "errors" "testing" - mcp "github.com/kagent-dev/tools/internal/mcp" "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" kubescapefake "github.com/kubescape/storage/pkg/generated/clientset/versioned/fake" + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" @@ -19,18 +19,18 @@ import ( ) // Helper function to extract text content from MCP result -func getResultText(result *mcp.CallToolResult) string { +func getResultText(result *sdkmcp.CallToolResult) string { if result == nil || len(result.Content) == 0 { return "" } - if textContent, ok := result.Content[0].(*mcp.TextContent); ok { + if textContent, ok := result.Content[0].(*sdkmcp.TextContent); ok { return textContent.Text } return "" } func TestRegisterTools(t *testing.T) { - s := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "1.0.0"}, nil) + s := sdkmcp.NewServer(&sdkmcp.Implementation{Name: "test", Version: "1.0.0"}, nil) assert.NotPanics(t, func() { RegisterTools(s, "", false) }) diff --git a/pkg/prometheus/prometheus.go b/pkg/prometheus/prometheus.go index a6a7ccae..8d01bdb7 100644 --- a/pkg/prometheus/prometheus.go +++ b/pkg/prometheus/prometheus.go @@ -13,6 +13,7 @@ import ( "github.com/kagent-dev/tools/internal/errors" mcp "github.com/kagent-dev/tools/internal/mcp" "github.com/kagent-dev/tools/internal/security" + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" ) // clientKey is the context key for the http client. @@ -26,7 +27,7 @@ func getHTTPClient(ctx context.Context) *http.Client { } // prometheusErrResult adapts ToolError to an MCP error result. -func prometheusErrResult(toolErr *errors.ToolError) *mcp.CallToolResult { +func prometheusErrResult(toolErr *errors.ToolError) *sdkmcp.CallToolResult { return toolErr.ToMCPResult() } @@ -47,7 +48,7 @@ type prometheusQueryInput struct { PrometheusURL string `json:"prometheus_url" jsonschema:"Prometheus server URL (default: http://localhost:9090)"` } -func handlePrometheusQueryTool(ctx context.Context, request *mcp.CallToolRequest, in prometheusQueryInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handlePrometheusQueryTool(ctx context.Context, request *sdkmcp.CallToolRequest, in prometheusQueryInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { prometheusURL := in.PrometheusURL if prometheusURL == "" { prometheusURL = "http://localhost:9090" @@ -125,7 +126,7 @@ type prometheusRangeQueryInput struct { PrometheusURL string `json:"prometheus_url" jsonschema:"Prometheus server URL (default: http://localhost:9090)"` } -func handlePrometheusRangeQueryTool(ctx context.Context, request *mcp.CallToolRequest, in prometheusRangeQueryInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handlePrometheusRangeQueryTool(ctx context.Context, request *sdkmcp.CallToolRequest, in prometheusRangeQueryInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { prometheusURL := in.PrometheusURL if prometheusURL == "" { prometheusURL = "http://localhost:9090" @@ -216,7 +217,7 @@ type prometheusLabelsInput struct { PrometheusURL string `json:"prometheus_url" jsonschema:"Prometheus server URL (default: http://localhost:9090)"` } -func handlePrometheusLabelsQueryTool(ctx context.Context, request *mcp.CallToolRequest, in prometheusLabelsInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handlePrometheusLabelsQueryTool(ctx context.Context, request *sdkmcp.CallToolRequest, in prometheusLabelsInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { prometheusURL := in.PrometheusURL if prometheusURL == "" { prometheusURL = "http://localhost:9090" @@ -274,7 +275,7 @@ type prometheusTargetsInput struct { PrometheusURL string `json:"prometheus_url" jsonschema:"Prometheus server URL (default: http://localhost:9090)"` } -func handlePrometheusTargetsQueryTool(ctx context.Context, request *mcp.CallToolRequest, in prometheusTargetsInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handlePrometheusTargetsQueryTool(ctx context.Context, request *sdkmcp.CallToolRequest, in prometheusTargetsInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { prometheusURL := in.PrometheusURL if prometheusURL == "" { prometheusURL = "http://localhost:9090" @@ -313,28 +314,28 @@ func handlePrometheusTargetsQueryTool(ctx context.Context, request *mcp.CallTool return mcp.TextResult(prettyJSONBody(body)) } -func RegisterTools(s *mcp.Server, readOnly bool) { - mcp.AddTool(s, "prometheus", &mcp.Tool{ +func RegisterTools(s *sdkmcp.Server, readOnly bool) { + mcp.AddTool(s, "prometheus", &sdkmcp.Tool{ Name: "prometheus_query_tool", Description: "Execute a PromQL query against Prometheus", }, handlePrometheusQueryTool) - mcp.AddTool(s, "prometheus", &mcp.Tool{ + mcp.AddTool(s, "prometheus", &sdkmcp.Tool{ Name: "prometheus_query_range_tool", Description: "Execute a PromQL range query against Prometheus", }, handlePrometheusRangeQueryTool) - mcp.AddTool(s, "prometheus", &mcp.Tool{ + mcp.AddTool(s, "prometheus", &sdkmcp.Tool{ Name: "prometheus_label_names_tool", Description: "Get all available labels from Prometheus", }, handlePrometheusLabelsQueryTool) - mcp.AddTool(s, "prometheus", &mcp.Tool{ + mcp.AddTool(s, "prometheus", &sdkmcp.Tool{ Name: "prometheus_targets_tool", Description: "Get all Prometheus targets and their status", }, handlePrometheusTargetsQueryTool) - mcp.AddTool(s, "prometheus", &mcp.Tool{ + mcp.AddTool(s, "prometheus", &sdkmcp.Tool{ Name: "prometheus_promql_tool", Description: "Generate a PromQL query", }, handlePromql) diff --git a/pkg/prometheus/prometheus_test.go b/pkg/prometheus/prometheus_test.go index 792fad20..518a84a8 100644 --- a/pkg/prometheus/prometheus_test.go +++ b/pkg/prometheus/prometheus_test.go @@ -7,17 +7,17 @@ import ( "strings" "testing" - mcp "github.com/kagent-dev/tools/internal/mcp" + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" ) func TestRegisterTools(t *testing.T) { t.Run("read-write", func(t *testing.T) { - s := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "v0.0.1"}, nil) + s := sdkmcp.NewServer(&sdkmcp.Implementation{Name: "test", Version: "v0.0.1"}, nil) RegisterTools(s, false) }) t.Run("read-only", func(t *testing.T) { - s := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "v0.0.1"}, nil) + s := sdkmcp.NewServer(&sdkmcp.Implementation{Name: "test", Version: "v0.0.1"}, nil) RegisterTools(s, true) }) } @@ -26,7 +26,7 @@ func TestPrometheusInputValidation(t *testing.T) { ctx := context.Background() t.Run("query invalid url", func(t *testing.T) { - res, _, err := handlePrometheusQueryTool(ctx, &mcp.CallToolRequest{}, prometheusQueryInput{ + res, _, err := handlePrometheusQueryTool(ctx, &sdkmcp.CallToolRequest{}, prometheusQueryInput{ PrometheusURL: "not a url", Query: "up", }) @@ -35,7 +35,7 @@ func TestPrometheusInputValidation(t *testing.T) { }) t.Run("range invalid url", func(t *testing.T) { - res, _, err := handlePrometheusRangeQueryTool(ctx, &mcp.CallToolRequest{}, prometheusRangeQueryInput{ + res, _, err := handlePrometheusRangeQueryTool(ctx, &sdkmcp.CallToolRequest{}, prometheusRangeQueryInput{ PrometheusURL: "not a url", Query: "up", }) @@ -44,7 +44,7 @@ func TestPrometheusInputValidation(t *testing.T) { }) t.Run("labels invalid url", func(t *testing.T) { - res, _, err := handlePrometheusLabelsQueryTool(ctx, &mcp.CallToolRequest{}, prometheusLabelsInput{ + res, _, err := handlePrometheusLabelsQueryTool(ctx, &sdkmcp.CallToolRequest{}, prometheusLabelsInput{ PrometheusURL: "not a url", }) assert.NoError(t, err) @@ -52,7 +52,7 @@ func TestPrometheusInputValidation(t *testing.T) { }) t.Run("targets invalid url", func(t *testing.T) { - res, _, err := handlePrometheusTargetsQueryTool(ctx, &mcp.CallToolRequest{}, prometheusTargetsInput{ + res, _, err := handlePrometheusTargetsQueryTool(ctx, &sdkmcp.CallToolRequest{}, prometheusTargetsInput{ PrometheusURL: "not a url", }) assert.NoError(t, err) @@ -60,7 +60,7 @@ func TestPrometheusInputValidation(t *testing.T) { }) t.Run("query invalid promql", func(t *testing.T) { - res, _, err := handlePrometheusQueryTool(ctx, &mcp.CallToolRequest{}, prometheusQueryInput{ + res, _, err := handlePrometheusQueryTool(ctx, &sdkmcp.CallToolRequest{}, prometheusQueryInput{ PrometheusURL: "http://localhost:9090", Query: "up; drop", }) @@ -69,7 +69,7 @@ func TestPrometheusInputValidation(t *testing.T) { }) t.Run("range invalid promql", func(t *testing.T) { - res, _, err := handlePrometheusRangeQueryTool(ctx, &mcp.CallToolRequest{}, prometheusRangeQueryInput{ + res, _, err := handlePrometheusRangeQueryTool(ctx, &sdkmcp.CallToolRequest{}, prometheusRangeQueryInput{ PrometheusURL: "http://localhost:9090", Query: "up; drop", }) @@ -81,7 +81,7 @@ func TestPrometheusInputValidation(t *testing.T) { func TestPrometheusLabelsTargetsErrorPaths(t *testing.T) { t.Run("labels client error", func(t *testing.T) { ctx := contextWithMockClient(newTestClient(nil, assert.AnError)) - res, _, err := handlePrometheusLabelsQueryTool(ctx, &mcp.CallToolRequest{}, prometheusLabelsInput{ + res, _, err := handlePrometheusLabelsQueryTool(ctx, &sdkmcp.CallToolRequest{}, prometheusLabelsInput{ PrometheusURL: "http://localhost:9090", }) assert.NoError(t, err) @@ -90,7 +90,7 @@ func TestPrometheusLabelsTargetsErrorPaths(t *testing.T) { t.Run("labels malformed json", func(t *testing.T) { ctx := contextWithMockClient(newTestClient(createMockResponse(200, "not json"), nil)) - res, _, err := handlePrometheusLabelsQueryTool(ctx, &mcp.CallToolRequest{}, prometheusLabelsInput{ + res, _, err := handlePrometheusLabelsQueryTool(ctx, &sdkmcp.CallToolRequest{}, prometheusLabelsInput{ PrometheusURL: "http://localhost:9090", }) assert.NoError(t, err) @@ -100,7 +100,7 @@ func TestPrometheusLabelsTargetsErrorPaths(t *testing.T) { t.Run("targets client error", func(t *testing.T) { ctx := contextWithMockClient(newTestClient(nil, assert.AnError)) - res, _, err := handlePrometheusTargetsQueryTool(ctx, &mcp.CallToolRequest{}, prometheusTargetsInput{ + res, _, err := handlePrometheusTargetsQueryTool(ctx, &sdkmcp.CallToolRequest{}, prometheusTargetsInput{ PrometheusURL: "http://localhost:9090", }) assert.NoError(t, err) @@ -109,7 +109,7 @@ func TestPrometheusLabelsTargetsErrorPaths(t *testing.T) { t.Run("targets malformed json", func(t *testing.T) { ctx := contextWithMockClient(newTestClient(createMockResponse(200, "not json"), nil)) - res, _, err := handlePrometheusTargetsQueryTool(ctx, &mcp.CallToolRequest{}, prometheusTargetsInput{ + res, _, err := handlePrometheusTargetsQueryTool(ctx, &sdkmcp.CallToolRequest{}, prometheusTargetsInput{ PrometheusURL: "http://localhost:9090", }) assert.NoError(t, err) @@ -148,11 +148,11 @@ func newTestClient(response *http.Response, err error) *http.Client { } // Helper function to extract text content from MCP result -func getResultText(result *mcp.CallToolResult) string { +func getResultText(result *sdkmcp.CallToolResult) string { if result == nil || len(result.Content) == 0 { return "" } - if textContent, ok := result.Content[0].(*mcp.TextContent); ok { + if textContent, ok := result.Content[0].(*sdkmcp.TextContent); ok { return textContent.Text } return "" @@ -190,7 +190,7 @@ func TestHandlePrometheusQueryTool(t *testing.T) { client := newTestClient(createMockResponse(200, mockResponse), nil) ctx := contextWithMockClient(client) - result, _, err := handlePrometheusQueryTool(ctx, &mcp.CallToolRequest{}, prometheusQueryInput{ + result, _, err := handlePrometheusQueryTool(ctx, &sdkmcp.CallToolRequest{}, prometheusQueryInput{ Query: "up", PrometheusURL: "http://localhost:9090", }) @@ -206,7 +206,7 @@ func TestHandlePrometheusQueryTool(t *testing.T) { t.Run("missing query parameter", func(t *testing.T) { ctx := context.Background() - result, _, err := handlePrometheusQueryTool(ctx, &mcp.CallToolRequest{}, prometheusQueryInput{ + result, _, err := handlePrometheusQueryTool(ctx, &sdkmcp.CallToolRequest{}, prometheusQueryInput{ PrometheusURL: "http://localhost:9090", }) @@ -220,7 +220,7 @@ func TestHandlePrometheusQueryTool(t *testing.T) { client := newTestClient(nil, assert.AnError) ctx := contextWithMockClient(client) - result, _, err := handlePrometheusQueryTool(ctx, &mcp.CallToolRequest{}, prometheusQueryInput{ + result, _, err := handlePrometheusQueryTool(ctx, &sdkmcp.CallToolRequest{}, prometheusQueryInput{ Query: "up", }) @@ -234,7 +234,7 @@ func TestHandlePrometheusQueryTool(t *testing.T) { client := newTestClient(createMockResponse(500, "Internal Server Error"), nil) ctx := contextWithMockClient(client) - result, _, err := handlePrometheusQueryTool(ctx, &mcp.CallToolRequest{}, prometheusQueryInput{ + result, _, err := handlePrometheusQueryTool(ctx, &sdkmcp.CallToolRequest{}, prometheusQueryInput{ Query: "up", }) @@ -248,7 +248,7 @@ func TestHandlePrometheusQueryTool(t *testing.T) { client := newTestClient(createMockResponse(200, "invalid json {"), nil) ctx := contextWithMockClient(client) - result, _, err := handlePrometheusQueryTool(ctx, &mcp.CallToolRequest{}, prometheusQueryInput{ + result, _, err := handlePrometheusQueryTool(ctx, &sdkmcp.CallToolRequest{}, prometheusQueryInput{ Query: "up", }) @@ -264,7 +264,7 @@ func TestHandlePrometheusQueryTool(t *testing.T) { client := newTestClient(createMockResponse(200, mockResponse), nil) ctx := contextWithMockClient(client) - result, _, err := handlePrometheusQueryTool(ctx, &mcp.CallToolRequest{}, prometheusQueryInput{ + result, _, err := handlePrometheusQueryTool(ctx, &sdkmcp.CallToolRequest{}, prometheusQueryInput{ Query: "up", }) @@ -292,7 +292,7 @@ func TestHandlePrometheusRangeQueryTool(t *testing.T) { client := newTestClient(createMockResponse(200, mockResponse), nil) ctx := contextWithMockClient(client) - result, _, err := handlePrometheusRangeQueryTool(ctx, &mcp.CallToolRequest{}, prometheusRangeQueryInput{ + result, _, err := handlePrometheusRangeQueryTool(ctx, &sdkmcp.CallToolRequest{}, prometheusRangeQueryInput{ Query: "up", Start: "1609459200", End: "1609459260", @@ -310,7 +310,7 @@ func TestHandlePrometheusRangeQueryTool(t *testing.T) { t.Run("missing query parameter", func(t *testing.T) { ctx := context.Background() - result, _, err := handlePrometheusRangeQueryTool(ctx, &mcp.CallToolRequest{}, prometheusRangeQueryInput{}) + result, _, err := handlePrometheusRangeQueryTool(ctx, &sdkmcp.CallToolRequest{}, prometheusRangeQueryInput{}) assert.NoError(t, err) assert.NotNil(t, result) @@ -323,7 +323,7 @@ func TestHandlePrometheusRangeQueryTool(t *testing.T) { client := newTestClient(createMockResponse(200, mockResponse), nil) ctx := contextWithMockClient(client) - result, _, err := handlePrometheusRangeQueryTool(ctx, &mcp.CallToolRequest{}, prometheusRangeQueryInput{ + result, _, err := handlePrometheusRangeQueryTool(ctx, &sdkmcp.CallToolRequest{}, prometheusRangeQueryInput{ Query: "up", }) @@ -343,7 +343,7 @@ func TestHandlePrometheusLabelsQueryTool(t *testing.T) { client := newTestClient(createMockResponse(200, mockResponse), nil) ctx := contextWithMockClient(client) - result, _, err := handlePrometheusLabelsQueryTool(ctx, &mcp.CallToolRequest{}, prometheusLabelsInput{}) + result, _, err := handlePrometheusLabelsQueryTool(ctx, &sdkmcp.CallToolRequest{}, prometheusLabelsInput{}) assert.NoError(t, err) assert.NotNil(t, result) @@ -359,7 +359,7 @@ func TestHandlePrometheusLabelsQueryTool(t *testing.T) { client := newTestClient(nil, assert.AnError) ctx := contextWithMockClient(client) - result, _, err := handlePrometheusLabelsQueryTool(ctx, &mcp.CallToolRequest{}, prometheusLabelsInput{}) + result, _, err := handlePrometheusLabelsQueryTool(ctx, &sdkmcp.CallToolRequest{}, prometheusLabelsInput{}) assert.NoError(t, err) assert.NotNil(t, result) @@ -372,7 +372,7 @@ func TestHandlePrometheusLabelsQueryTool(t *testing.T) { client := newTestClient(createMockResponse(200, mockResponse), nil) ctx := contextWithMockClient(client) - result, _, err := handlePrometheusLabelsQueryTool(ctx, &mcp.CallToolRequest{}, prometheusLabelsInput{ + result, _, err := handlePrometheusLabelsQueryTool(ctx, &sdkmcp.CallToolRequest{}, prometheusLabelsInput{ PrometheusURL: "http://custom:9090", }) @@ -402,7 +402,7 @@ func TestHandlePrometheusTargetsQueryTool(t *testing.T) { client := newTestClient(createMockResponse(200, mockResponse), nil) ctx := contextWithMockClient(client) - result, _, err := handlePrometheusTargetsQueryTool(ctx, &mcp.CallToolRequest{}, prometheusTargetsInput{}) + result, _, err := handlePrometheusTargetsQueryTool(ctx, &sdkmcp.CallToolRequest{}, prometheusTargetsInput{}) assert.NoError(t, err) assert.NotNil(t, result) @@ -418,7 +418,7 @@ func TestHandlePrometheusTargetsQueryTool(t *testing.T) { client := newTestClient(createMockResponse(404, "Not Found"), nil) ctx := contextWithMockClient(client) - result, _, err := handlePrometheusTargetsQueryTool(ctx, &mcp.CallToolRequest{}, prometheusTargetsInput{}) + result, _, err := handlePrometheusTargetsQueryTool(ctx, &sdkmcp.CallToolRequest{}, prometheusTargetsInput{}) assert.NoError(t, err) assert.NotNil(t, result) @@ -430,7 +430,7 @@ func TestHandlePrometheusTargetsQueryTool(t *testing.T) { func TestHandlePromql(t *testing.T) { t.Run("missing query description", func(t *testing.T) { ctx := context.Background() - result, _, err := handlePromql(ctx, &mcp.CallToolRequest{}, promqlInput{}) + result, _, err := handlePromql(ctx, &sdkmcp.CallToolRequest{}, promqlInput{}) assert.NoError(t, err) assert.NotNil(t, result) @@ -440,7 +440,7 @@ func TestHandlePromql(t *testing.T) { t.Run("with query description", func(t *testing.T) { ctx := context.Background() - result, _, err := handlePromql(ctx, &mcp.CallToolRequest{}, promqlInput{ + result, _, err := handlePromql(ctx, &sdkmcp.CallToolRequest{}, promqlInput{ QueryDescription: "CPU usage percentage", }) @@ -472,7 +472,7 @@ func TestPrometheusToolsContextCancellation(t *testing.T) { ctx := contextWithMockClient(client) _ = cancelCtx - result, _, err := handlePrometheusQueryTool(ctx, &mcp.CallToolRequest{}, prometheusQueryInput{ + result, _, err := handlePrometheusQueryTool(ctx, &sdkmcp.CallToolRequest{}, prometheusQueryInput{ Query: "up", }) @@ -498,7 +498,7 @@ func TestPrometheusToolsEdgeCases(t *testing.T) { client := newTestClient(createMockResponse(200, largeResponse), nil) ctx := contextWithMockClient(client) - result, _, err := handlePrometheusQueryTool(ctx, &mcp.CallToolRequest{}, prometheusQueryInput{ + result, _, err := handlePrometheusQueryTool(ctx, &sdkmcp.CallToolRequest{}, prometheusQueryInput{ Query: "up", }) @@ -515,7 +515,7 @@ func TestPrometheusToolsEdgeCases(t *testing.T) { client := newTestClient(createMockResponse(200, mockResponse), nil) ctx := contextWithMockClient(client) - result, _, err := handlePrometheusQueryTool(ctx, &mcp.CallToolRequest{}, prometheusQueryInput{ + result, _, err := handlePrometheusQueryTool(ctx, &sdkmcp.CallToolRequest{}, prometheusQueryInput{ Query: `up{instance=~".*:9090"}`, }) @@ -528,7 +528,7 @@ func TestPrometheusToolsEdgeCases(t *testing.T) { client := newTestClient(createMockResponse(200, ""), nil) ctx := contextWithMockClient(client) - result, _, err := handlePrometheusQueryTool(ctx, &mcp.CallToolRequest{}, prometheusQueryInput{ + result, _, err := handlePrometheusQueryTool(ctx, &sdkmcp.CallToolRequest{}, prometheusQueryInput{ Query: "up", }) @@ -545,7 +545,7 @@ func TestPrometheusURLEncoding(t *testing.T) { client := newTestClient(createMockResponse(200, mockResponse), nil) ctx := contextWithMockClient(client) - result, _, err := handlePrometheusQueryTool(ctx, &mcp.CallToolRequest{}, prometheusQueryInput{ + result, _, err := handlePrometheusQueryTool(ctx, &sdkmcp.CallToolRequest{}, prometheusQueryInput{ Query: `up{job="test service"}`, }) diff --git a/pkg/prometheus/promql.go b/pkg/prometheus/promql.go index 94cb1f68..245641c5 100644 --- a/pkg/prometheus/promql.go +++ b/pkg/prometheus/promql.go @@ -5,6 +5,7 @@ import ( _ "embed" mcp "github.com/kagent-dev/tools/internal/mcp" + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/tmc/langchaingo/llms" "github.com/tmc/langchaingo/llms/openai" ) @@ -16,7 +17,7 @@ type promqlInput struct { QueryDescription string `json:"query_description" jsonschema:"A string describing the query to generate"` } -func handlePromql(ctx context.Context, request *mcp.CallToolRequest, in promqlInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handlePromql(ctx context.Context, request *sdkmcp.CallToolRequest, in promqlInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { queryDescription := in.QueryDescription if queryDescription == "" { return mcp.TextError("query_description is required") diff --git a/pkg/utils/common.go b/pkg/utils/common.go index cf4c089a..08593093 100644 --- a/pkg/utils/common.go +++ b/pkg/utils/common.go @@ -13,6 +13,7 @@ import ( "github.com/kagent-dev/tools/internal/commands" "github.com/kagent-dev/tools/internal/logger" mcp "github.com/kagent-dev/tools/internal/mcp" + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" ) // KubeConfigManager manages kubeconfig path with thread safety @@ -82,7 +83,7 @@ func shellTool(ctx context.Context, params shellParams) (string, error) { return commands.NewCommandBuilder(cmd).WithArgs(args...).Execute(ctx) } -func handleShellTool(ctx context.Context, request *mcp.CallToolRequest, in shellParams) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleShellTool(ctx context.Context, request *sdkmcp.CallToolRequest, in shellParams) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { if in.Command == "" { return mcp.TextError("command parameter is required") } @@ -95,7 +96,7 @@ func handleShellTool(ctx context.Context, request *mcp.CallToolRequest, in shell return mcp.TextResult(result) } -func handleMCPInspectTool(_ context.Context, request *mcp.CallToolRequest, in inspectInput) (*mcp.CallToolResult, *inspectOutput, error) { +func handleMCPInspectTool(_ context.Context, request *sdkmcp.CallToolRequest, in inspectInput) (*sdkmcp.CallToolResult, *inspectOutput, error) { output := &inspectOutput{ Echo: in.Echo, Headers: inspectHeaders(mcp.Header(request)), @@ -191,31 +192,31 @@ func inspectHeaders(headers http.Header) []inspectHeader { type datetimeInput struct{} // handleGetCurrentDateTimeTool provides datetime functionality for both MCP and testing -func handleGetCurrentDateTimeTool(ctx context.Context, request *mcp.CallToolRequest, in datetimeInput) (*mcp.CallToolResult, mcp.TextOutput, error) { +func handleGetCurrentDateTimeTool(ctx context.Context, request *sdkmcp.CallToolRequest, in datetimeInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { // Returns the current date and time in ISO 8601 format (RFC3339) // This matches the Python implementation: datetime.datetime.now().isoformat() now := time.Now() return mcp.TextResult(now.Format(time.RFC3339)) } -func RegisterTools(s *mcp.Server, readOnly bool) { +func RegisterTools(s *sdkmcp.Server, readOnly bool) { logger.Get().Info("RegisterTools initialized") // Register shell tool - disabled in read-only mode as it allows arbitrary command execution if !readOnly { - mcp.AddTool(s, "utils", &mcp.Tool{ + mcp.AddTool(s, "utils", &sdkmcp.Tool{ Name: "shell", Description: "Execute shell commands", }, handleShellTool) } // Register datetime tool - mcp.AddTool(s, "utils", &mcp.Tool{ + mcp.AddTool(s, "utils", &sdkmcp.Tool{ Name: "datetime_get_current_time", Description: "Returns the current date and time in ISO 8601 format.", }, handleGetCurrentDateTimeTool) - mcp.AddTool(s, "utils", &mcp.Tool{ + mcp.AddTool(s, "utils", &sdkmcp.Tool{ Name: "mcp_inspect", Description: "Echo input and return all HTTP headers received with the MCP request for debugging.", }, handleMCPInspectTool) diff --git a/pkg/utils/common_test.go b/pkg/utils/common_test.go index 99f7ef96..0eb661b1 100644 --- a/pkg/utils/common_test.go +++ b/pkg/utils/common_test.go @@ -7,7 +7,7 @@ import ( "testing" "github.com/kagent-dev/tools/internal/cmd" - mcp "github.com/kagent-dev/tools/internal/mcp" + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -69,12 +69,12 @@ func TestShellTool(t *testing.T) { func TestRegisterTools(t *testing.T) { t.Run("read-write registers shell", func(t *testing.T) { - s := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "v0.0.1"}, nil) + s := sdkmcp.NewServer(&sdkmcp.Implementation{Name: "test", Version: "v0.0.1"}, nil) RegisterTools(s, false) }) t.Run("read-only omits shell", func(t *testing.T) { - s := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "v0.0.1"}, nil) + s := sdkmcp.NewServer(&sdkmcp.Implementation{Name: "test", Version: "v0.0.1"}, nil) RegisterTools(s, true) }) } @@ -85,13 +85,13 @@ func TestHandleShellTool(t *testing.T) { ctx := cmd.WithShellExecutor(context.Background(), mock) t.Run("success", func(t *testing.T) { - res, _, err := handleShellTool(ctx, &mcp.CallToolRequest{}, shellParams{Command: "echo hi"}) + res, _, err := handleShellTool(ctx, &sdkmcp.CallToolRequest{}, shellParams{Command: "echo hi"}) require.NoError(t, err) assert.False(t, res.IsError) }) t.Run("missing command", func(t *testing.T) { - res, _, err := handleShellTool(ctx, &mcp.CallToolRequest{}, shellParams{}) + res, _, err := handleShellTool(ctx, &sdkmcp.CallToolRequest{}, shellParams{}) require.NoError(t, err) assert.True(t, res.IsError) assert.Contains(t, getResultText(res), "command parameter is required") @@ -101,7 +101,7 @@ func TestHandleShellTool(t *testing.T) { m := cmd.NewMockShellExecutor() m.AddCommandString("false", []string{}, "", assert.AnError) errCtx := cmd.WithShellExecutor(context.Background(), m) - res, _, err := handleShellTool(errCtx, &mcp.CallToolRequest{}, shellParams{Command: "false"}) + res, _, err := handleShellTool(errCtx, &sdkmcp.CallToolRequest{}, shellParams{Command: "false"}) require.NoError(t, err) assert.True(t, res.IsError) }) @@ -111,8 +111,8 @@ func TestHandleMCPInspectTool(t *testing.T) { ctx := context.Background() t.Run("echoes input and headers", func(t *testing.T) { - req := &mcp.CallToolRequest{ - Extra: &mcp.RequestExtra{ + req := &sdkmcp.CallToolRequest{ + Extra: &sdkmcp.RequestExtra{ Header: http.Header{ "Authorization": []string{"Bearer test-token"}, "X-Debug": []string{"one", "two"}, @@ -147,8 +147,8 @@ func TestHandleMCPInspectTool(t *testing.T) { }) t.Run("redacts every sensitive header but keeps the value count", func(t *testing.T) { - req := &mcp.CallToolRequest{ - Extra: &mcp.RequestExtra{ + req := &sdkmcp.CallToolRequest{ + Extra: &sdkmcp.RequestExtra{ Header: http.Header{ "Authorization": []string{"Bearer a", "Bearer b"}, "Cookie": []string{"session=secret"}, @@ -181,7 +181,7 @@ func TestHandleMCPInspectTool(t *testing.T) { }) t.Run("works without headers", func(t *testing.T) { - result, output, err := handleMCPInspectTool(ctx, &mcp.CallToolRequest{}, inspectInput{Echo: "stdio"}) + result, output, err := handleMCPInspectTool(ctx, &sdkmcp.CallToolRequest{}, inspectInput{Echo: "stdio"}) require.NoError(t, err) require.NotNil(t, result) assert.False(t, result.IsError) @@ -189,11 +189,11 @@ func TestHandleMCPInspectTool(t *testing.T) { }) } -func getResultText(result *mcp.CallToolResult) string { +func getResultText(result *sdkmcp.CallToolResult) string { if result == nil || len(result.Content) == 0 { return "" } - if textContent, ok := result.Content[0].(*mcp.TextContent); ok { + if textContent, ok := result.Content[0].(*sdkmcp.TextContent); ok { return textContent.Text } return "" diff --git a/pkg/utils/datetime_test.go b/pkg/utils/datetime_test.go index 1d105ee1..2202c8b1 100644 --- a/pkg/utils/datetime_test.go +++ b/pkg/utils/datetime_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - mcp "github.com/kagent-dev/tools/internal/mcp" + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" ) // Test the actual MCP tool handler functions @@ -14,7 +14,7 @@ import ( func TestHandleGetCurrentDateTimeTool(t *testing.T) { ctx := context.Background() - result, _, err := handleGetCurrentDateTimeTool(ctx, &mcp.CallToolRequest{}, datetimeInput{}) + result, _, err := handleGetCurrentDateTimeTool(ctx, &sdkmcp.CallToolRequest{}, datetimeInput{}) if err != nil { t.Fatalf("handleGetCurrentDateTimeTool failed: %v", err) } @@ -29,7 +29,7 @@ func TestHandleGetCurrentDateTimeTool(t *testing.T) { // Verify the result is a valid RFC3339 timestamp (ISO 8601 format) if len(result.Content) > 0 { - if textContent, ok := result.Content[0].(*mcp.TextContent); ok { + if textContent, ok := result.Content[0].(*sdkmcp.TextContent); ok { _, err := time.Parse(time.RFC3339, textContent.Text) if err != nil { t.Errorf("Result is not valid RFC3339 timestamp: %v", err) @@ -51,7 +51,7 @@ func TestHandleGetCurrentDateTimeToolNoParameters(t *testing.T) { // Test that the tool works without any parameters (as per Python implementation) ctx := context.Background() - result, _, err := handleGetCurrentDateTimeTool(ctx, &mcp.CallToolRequest{}, datetimeInput{}) + result, _, err := handleGetCurrentDateTimeTool(ctx, &sdkmcp.CallToolRequest{}, datetimeInput{}) if err != nil { t.Fatalf("handleGetCurrentDateTimeTool failed with empty args: %v", err) } @@ -66,7 +66,7 @@ func TestHandleGetCurrentDateTimeToolNoParameters(t *testing.T) { // Verify we get a valid timestamp if len(result.Content) > 0 { - if textContent, ok := result.Content[0].(*mcp.TextContent); ok { + if textContent, ok := result.Content[0].(*sdkmcp.TextContent); ok { _, err := time.Parse(time.RFC3339, textContent.Text) if err != nil { t.Errorf("Result is not valid RFC3339 timestamp: %v", err) @@ -83,13 +83,13 @@ func TestDateTimeFormatConsistency(t *testing.T) { // Test that our Go implementation produces ISO 8601 format consistent with Python ctx := context.Background() - result, _, err := handleGetCurrentDateTimeTool(ctx, &mcp.CallToolRequest{}, datetimeInput{}) + result, _, err := handleGetCurrentDateTimeTool(ctx, &sdkmcp.CallToolRequest{}, datetimeInput{}) if err != nil { t.Fatalf("handleGetCurrentDateTimeTool failed: %v", err) } if len(result.Content) > 0 { - if textContent, ok := result.Content[0].(*mcp.TextContent); ok { + if textContent, ok := result.Content[0].(*sdkmcp.TextContent); ok { timestamp := textContent.Text // Check that it follows RFC3339 format (which is ISO 8601 compliant) diff --git a/test/e2e/coverage_test.go b/test/e2e/coverage_test.go index b7e07db9..5d2bc4ed 100644 --- a/test/e2e/coverage_test.go +++ b/test/e2e/coverage_test.go @@ -19,7 +19,6 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - mcp "github.com/modelcontextprotocol/go-sdk/mcp" sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -96,7 +95,7 @@ func SweepReadOnlyTools(client *MCPClient) { tools, err := client.listTools() Expect(err).ToNot(HaveOccurred(), "listing tools failed: %v", err) - advertised := make(map[string]*mcp.Tool, len(tools)) + advertised := make(map[string]*sdkmcp.Tool, len(tools)) for _, t := range tools { advertised[t.Name] = t } From 35f0af6d3020bec01fa763949568f72cfc1bd90b Mon Sep 17 00:00:00 2001 From: Dmytro Rashko Date: Wed, 23 Sep 2026 18:01:56 +0200 Subject: [PATCH 20/21] docs: make AGENTS.md the single guide, drop CLAUDE.md CLAUDE.md existed only to point at AGENTS.md and add a few paragraphs. Two guides drift, and this one already had: it named the file after one specific agent tool, and its "Registration goes through internal/mcp, never call sdk.AddTool" rule now reads as a contradiction of the alias removal, where providers import the SDK directly for types while still registering through mcp.AddTool. AGENTS.md is the single source of truth. The one section CLAUDE.md held that AGENTS.md lacked - how to run the server locally, and its flags - is now a "Run Locally" subsection under Build & Test Commands, and DEVELOPMENT.md no longer points at the deleted file. Deliberately not ported: architecture prose duplicated in AGENTS.md Project Overview, the logging and commit notes duplicated verbatim, and the coverage-gate caveat AGENTS.md already carries in more detail under Testing. Signed-off-by: Dmytro Rashko --- AGENTS.md | 14 ++++++++++ CLAUDE.md | 72 -------------------------------------------------- DEVELOPMENT.md | 2 +- 3 files changed, 15 insertions(+), 73 deletions(-) delete mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md index 54ff6e0e..ffe926f0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,6 +119,20 @@ Each provider lives in `pkg/` and registers MCP tools via a `RegisterTools(serve Before submitting changes, run `make fmt && make lint && make test`. +### Run Locally + +```bash +go run ./cmd # defaults to stdio +./bin/kagent-tools --stdio # stdio transport +./bin/kagent-tools --http --port 8084 # HTTP transport +``` + +Useful flags: `--tools k8s,helm` (limit providers), `--kubeconfig `, +`--read-only` (do not register write tools), `--metrics-port`. + +`make run` builds the image and runs the server in Docker on +`http://localhost:8084/mcp`. + --- ## Code Conventions diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 387de288..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,72 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working in this repository. - -**The repository guide lives in [`AGENTS.md`](./AGENTS.md).** It covers the architecture, -tool-provider layout, the typed MCP input/output contract, error handling, caching, testing, -CI/CD, commit conventions and the "what not to do" list. Read it before making changes; it is -the single source of truth and is kept current. - -This file only adds the few things AGENTS.md does not spell out. - -## Architecture Overview - -A Go MCP (Model Context Protocol) server that wraps Kubernetes and cloud-native CLIs -(`kubectl`, `helm`, `istioctl`, `cilium`, `kubectl-argo-rollouts`, `kubescape`, -the Prometheus HTTP API) behind a single typed MCP interface. It does not reimplement the -tools' behaviour; it validates input, invokes the CLI, and returns a typed result. - -Two design points that are easy to get wrong: - -- **Registration goes through `internal/mcp`, not the SDK directly.** `mcp.AddTool` records the - provider for metrics and relaxes the inferred input schema so optional fields stay optional. - Never call `sdk.AddTool` from a provider. -- **Every handler returns a concrete `Out` type.** The SDK infers an output schema from it, - populates `structuredContent`, and validates the value on every call — including error paths. - See "Typed MCP Inputs and Outputs" in AGENTS.md for the three pitfalls that break tools. - -## Run Locally - -```bash -go run ./cmd # defaults to stdio -./bin/kagent-tools --stdio # stdio transport -./bin/kagent-tools --http --port 8084 # HTTP transport -``` - -Useful flags: `--tools k8s,helm` (limit providers), `--kubeconfig `, -`--read-only` (do not register write tools), `--metrics-port`. - -## Development Practices - -- Run the narrowest useful test first, then broaden: `go test -tags=test -v -cover ./pkg/` - before `make test`. -- `make test` = build + lint + all tests. `make test-only` skips build/lint. -- Use the mock shell executor for unit tests; never shell out to real CLIs in unit tests. -- Keep functions focused and testable, and use `context` for cancellation in long-running work. - -### Test Coverage - -- The project targets 80% coverage; every `pkg/` package currently exceeds it (lowest is - `pkg/kubescape` at ~85%, highest ~99%). -- **CI does not enforce a coverage gate.** The `go-unit-tests` job runs `go test -v -cover`, - which reports coverage but does not fail the build on a threshold. Treat 80% as the - repository standard to maintain, not as an automated gate — check it yourself with - `go test -cover ./pkg/...`. -- `internal/commands` and `internal/cmd` are below 80% and predate that standard. - -## Logging - -Structured logging lives in `internal/logger` (not `pkg/logger`). Prefer the package-level -logger used by the surrounding code. - -## Commit Messages - -Conventional Commits, with a `Signed-off-by` trailer (DCO is enforced on pull requests): -`feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `perf`, `ci`. - -## Additional Resources - -- [AGENTS.md](AGENTS.md) — the repository guide (authoritative) -- [DEVELOPMENT.md](DEVELOPMENT.md) — setup and code standards -- [CONTRIBUTION.md](CONTRIBUTION.md) — contribution process and PR guidelines -- [docs/quickstart.md](docs/quickstart.md) — quick start guide diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 4d9f5569..6a59d71b 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -420,6 +420,6 @@ git commit -m "docs(readme): update installation instructions" ### Getting Help - Check existing issues in the repository -- Review the CLAUDE.md file for project-specific guidance +- Review the AGENTS.md file for project-specific guidance - Consult Go documentation and best practices - Ask questions in code reviews or team discussions \ No newline at end of file From e2ab3a0897301b893baad8353273d4d41a9ddd0f Mon Sep 17 00:00:00 2001 From: Dmytro Rashko Date: Wed, 23 Sep 2026 18:01:59 +0200 Subject: [PATCH 21/21] chore(deps): bump istioctl to 1.31.1 make check-releases flagged the pin as one patch behind: TOOLS_ISTIO_VERSION=1.31.0 != 1.31.1 The other four CLI pins (kubectl 1.37.0, helm 4.3.0, cilium-cli 0.20.0, argo-rollouts 1.10.0) and GO_VERSION 1.27.1 are already current. The image is rebuilt from this pin in the Dockerfile, so no vendored binary is stale. Signed-off-by: Dmytro Rashko --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 6a604e04..4f1e87da 100644 --- a/Makefile +++ b/Makefile @@ -136,7 +136,7 @@ DOCKER_BUILDER ?= docker buildx DOCKER_BUILD_ARGS ?= --pull --load --platform linux/$(LOCALARCH) --builder $(BUILDX_BUILDER_NAME) # tools image build args -TOOLS_ISTIO_VERSION ?= 1.31.0 +TOOLS_ISTIO_VERSION ?= 1.31.1 TOOLS_ARGO_ROLLOUTS_VERSION ?= 1.10.0 TOOLS_KUBECTL_VERSION ?= 1.37.0 TOOLS_HELM_VERSION ?= 4.3.0