diff --git a/README.md b/README.md index 01d739844..e670ae86d 100644 --- a/README.md +++ b/README.md @@ -316,6 +316,74 @@ content = "Help me with {{resource_name}}" See docs/PROMPTS.md for detailed documentation. +## 📊 MCP Logging + +The server supports the MCP logging capability, allowing clients to receive debugging information via structured log messages. + +### For Clients + +Clients can control log verbosity by sending a `logging/setLevel` request: + +```json +{ + "method": "logging/setLevel", + "params": { "level": "info" } +} +``` + +**Available log levels** (in order of increasing severity): +- `debug` - Detailed debugging information +- `info` - General informational messages (default) +- `notice` - Normal but significant events +- `warning` - Warning messages +- `error` - Error conditions +- `critical` - Critical conditions +- `alert` - Action must be taken immediately +- `emergency` - System is unusable + +### For Developers + +Toolsets can optionally send debug information to clients using helper functions from the `mcplog` package: + +**Recommended approach for Kubernetes errors** (automatically categorizes errors and sends appropriate messages): + +```go +import "github.com/containers/kubernetes-mcp-server/pkg/mcplog" + +// In your tool handler: +ret, err := client.CoreV1().Pods(namespace).Get(ctx, name, metav1.GetOptions{}) +if err != nil { + mcplog.HandleK8sError(ctx, err, "pod access") + return api.NewToolCallResult("", fmt.Errorf("failed to get pod: %v", err)), nil +} +``` + +**Manual logging** (for custom messages): + +```go +import "github.com/containers/kubernetes-mcp-server/pkg/mcplog" + +// In your tool handler: +if err != nil { + mcplog.SendMCPLog(ctx, "error", "Operation failed - check permissions") + return api.NewToolCallResult("", err) +} +``` + +**Key Points:** +- Logging is **optional** - toolsets work fine without sending MCP logs +- Uses a dedicated named logger (`logger="mcp"`) for complete separation from server logs +- Server logs (klog) remain detailed and unaffected +- Client logs are high-level, helpful hints for debugging +- Authentication failures send generic messages to clients (no security info leaked) +- Sensitive data is automatically redacted with 28 pattern types: + - Generic fields (password, token, secret, api_key, etc.) + - Authorization headers (Bearer, Basic) + - Cloud credentials (AWS, GCP, Azure) + - API tokens (GitHub, GitLab, OpenAI, Anthropic) + - Cryptographic keys (JWT, SSH, PGP, RSA) + - Database connection strings (PostgreSQL, MySQL, MongoDB) + ## 🛠️ Tools and Functionalities The Kubernetes MCP server supports enabling or disabling specific groups of tools and functionalities (tools, resources, prompts, and so on) via the `--toolsets` command-line flag or `toolsets` configuration option. diff --git a/go.mod b/go.mod index 91adaaedf..fb86694b3 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/coreos/go-oidc/v3 v3.17.0 github.com/fsnotify/fsnotify v1.9.0 github.com/go-jose/go-jose/v4 v4.1.3 + github.com/go-logr/logr v1.4.3 github.com/google/jsonschema-go v0.4.2 github.com/mark3labs/mcp-go v0.43.2 github.com/modelcontextprotocol/go-sdk v1.2.0 @@ -60,7 +61,6 @@ require ( github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-errors/errors v1.4.2 // indirect github.com/go-gorp/gorp/v3 v3.1.0 // indirect - github.com/go-logr/logr v1.4.3 // indirect github.com/go-openapi/jsonpointer v0.21.1 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/swag v0.23.1 // indirect diff --git a/pkg/mcp/mcp.go b/pkg/mcp/mcp.go index 6620b124b..8e7b522b7 100644 --- a/pkg/mcp/mcp.go +++ b/pkg/mcp/mcp.go @@ -86,11 +86,13 @@ func NewServer(configuration Configuration, oidcProvider *oidc.Provider, httpCli Resources: nil, Prompts: &mcp.PromptCapabilities{ListChanged: !configuration.Stateless}, Tools: &mcp.ToolCapabilities{ListChanged: !configuration.Stateless}, + Logging: &mcp.LoggingCapabilities{}, }, Instructions: configuration.ServerInstructions, }), } + s.server.AddReceivingMiddleware(sessionInjectionMiddleware) s.server.AddReceivingMiddleware(authHeaderPropagationMiddleware) s.server.AddReceivingMiddleware(toolCallLoggingMiddleware) diff --git a/pkg/mcp/middleware.go b/pkg/mcp/middleware.go index 35993381e..744802aee 100644 --- a/pkg/mcp/middleware.go +++ b/pkg/mcp/middleware.go @@ -5,10 +5,24 @@ import ( "context" internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" + "github.com/containers/kubernetes-mcp-server/pkg/mcplog" "github.com/modelcontextprotocol/go-sdk/mcp" "k8s.io/klog/v2" ) +// sessionInjectionMiddleware injects the MCP session into the context for logging support. +// This middleware should be added first so all subsequent middleware and handlers have access. +func sessionInjectionMiddleware(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + if session := req.GetSession(); session != nil { + if serverSession, ok := session.(*mcp.ServerSession); ok { + ctx = context.WithValue(ctx, mcplog.MCPSessionContextKey, serverSession) + } + } + return next(ctx, method, req) + } +} + func authHeaderPropagationMiddleware(next mcp.MethodHandler) mcp.MethodHandler { return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { if req.GetExtra() != nil && req.GetExtra().Header != nil { diff --git a/pkg/mcplog/k8s.go b/pkg/mcplog/k8s.go new file mode 100644 index 000000000..850dbebe5 --- /dev/null +++ b/pkg/mcplog/k8s.go @@ -0,0 +1,41 @@ +package mcplog + +import ( + "context" + + apierrors "k8s.io/apimachinery/pkg/api/errors" +) + +// HandleK8sError sends appropriate MCP log messages based on Kubernetes API error types. +// operation should describe the operation (e.g., "pod access", "deployment deletion"). +func HandleK8sError(ctx context.Context, err error, operation string) { + if err == nil { + return + } + + if apierrors.IsNotFound(err) { + SendMCPLog(ctx, LevelInfo, "Resource not found - it may not exist or may have been deleted") + } else if apierrors.IsForbidden(err) { + SendMCPLog(ctx, LevelError, "Permission denied - check RBAC permissions for "+operation) + } else if apierrors.IsUnauthorized(err) { + SendMCPLog(ctx, LevelError, "Authentication failed - check cluster credentials") + } else if apierrors.IsAlreadyExists(err) { + SendMCPLog(ctx, LevelWarning, "Resource already exists") + } else if apierrors.IsInvalid(err) { + SendMCPLog(ctx, LevelError, "Invalid resource specification - check resource definition") + } else if apierrors.IsBadRequest(err) { + SendMCPLog(ctx, LevelError, "Invalid request - check parameters") + } else if apierrors.IsConflict(err) { + SendMCPLog(ctx, LevelError, "Resource conflict - resource may have been modified") + } else if apierrors.IsTimeout(err) { + SendMCPLog(ctx, LevelError, "Request timeout - cluster may be slow or overloaded") + } else if apierrors.IsServerTimeout(err) { + SendMCPLog(ctx, LevelError, "Server timeout - cluster may be slow or overloaded") + } else if apierrors.IsServiceUnavailable(err) { + SendMCPLog(ctx, LevelError, "Service unavailable - cluster may be unreachable") + } else if apierrors.IsTooManyRequests(err) { + SendMCPLog(ctx, LevelWarning, "Rate limited - too many requests to the cluster") + } else { + SendMCPLog(ctx, LevelError, "Operation failed - cluster may be unreachable or experiencing issues") + } +} diff --git a/pkg/mcplog/log.go b/pkg/mcplog/log.go new file mode 100644 index 000000000..e9d4e8e93 --- /dev/null +++ b/pkg/mcplog/log.go @@ -0,0 +1,167 @@ +package mcplog + +import ( + "context" + "regexp" + + "github.com/go-logr/logr" + "github.com/modelcontextprotocol/go-sdk/mcp" + "k8s.io/klog/v2" +) + +// ContextKey is a type for context keys to avoid collisions +type ContextKey string + +// MCPSessionContextKey is the context key for storing MCP ServerSession +const MCPSessionContextKey = ContextKey("mcp_session") + +// Level represents MCP log severity levels per RFC 5424 syslog specification. +// https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging#log-levels +type Level int + +// Log levels from least to most severe, per MCP specification. +const ( + // LevelDebug is for detailed debugging information. + LevelDebug Level = iota + // LevelInfo is for general informational messages. + LevelInfo + // LevelNotice is for normal but significant events. + LevelNotice + // LevelWarning is for warning conditions. + LevelWarning + // LevelError is for error conditions. + LevelError + // LevelCritical is for critical conditions. + LevelCritical + // LevelAlert is for conditions requiring immediate action. + LevelAlert + // LevelEmergency is for system unusable conditions. + LevelEmergency +) + +// levelStrings maps Level values to their MCP protocol string representation. +var levelStrings = [...]string{ + LevelDebug: "debug", + LevelInfo: "info", + LevelNotice: "notice", + LevelWarning: "warning", + LevelError: "error", + LevelCritical: "critical", + LevelAlert: "alert", + LevelEmergency: "emergency", +} + +// String returns the MCP protocol string representation of the level. +func (l Level) String() string { + if l >= 0 && int(l) < len(levelStrings) { + return levelStrings[l] + } + return "debug" +} + +var ( + // mcpLogger is a dedicated named logger for MCP client-facing logs + // This provides complete separation from server logs + // issue for the sdk to implement this https://github.com/modelcontextprotocol/go-sdk/issues/748 + mcpLogger logr.Logger = klog.NewKlogr().WithName("mcp") + + sensitivePatterns = []*regexp.Regexp{ + // Generic JSON/YAML fields + regexp.MustCompile(`("password"\s*:\s*)"[^"]*"`), + regexp.MustCompile(`("token"\s*:\s*)"[^"]*"`), + regexp.MustCompile(`("secret"\s*:\s*)"[^"]*"`), + regexp.MustCompile(`("api[_-]?key"\s*:\s*)"[^"]*"`), + regexp.MustCompile(`("access[_-]?key"\s*:\s*)"[^"]*"`), + regexp.MustCompile(`("client[_-]?secret"\s*:\s*)"[^"]*"`), + regexp.MustCompile(`("private[_-]?key"\s*:\s*)"[^"]*"`), + // Authorization headers + regexp.MustCompile(`(Bearer\s+)[A-Za-z0-9\-._~+/]+=*`), + regexp.MustCompile(`(Basic\s+)[A-Za-z0-9+/]+=*`), + // AWS credentials + regexp.MustCompile(`(AKIA[0-9A-Z]{16})`), + regexp.MustCompile(`(aws_secret_access_key\s*=\s*)([A-Za-z0-9/+=]{40})`), + regexp.MustCompile(`(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}`), + // GitHub tokens + regexp.MustCompile(`(ghp_[a-zA-Z0-9]{36})`), + regexp.MustCompile(`(github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59})`), + // GitLab tokens + regexp.MustCompile(`(glpat-[a-zA-Z0-9\-_]{20})`), + // GCP + regexp.MustCompile(`(AIza[0-9A-Za-z\-_]{35})`), + // Azure + regexp.MustCompile(`(AccountKey=[A-Za-z0-9+/]{88}==)`), + // OpenAI / Anthropic + regexp.MustCompile(`(sk-proj-[a-zA-Z0-9]{48})`), + regexp.MustCompile(`(sk-ant-api03-[a-zA-Z0-9\-_]{95})`), + // JWT tokens + regexp.MustCompile(`(eyJ[a-zA-Z0-9_-]+\.eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+)`), + // Private keys + regexp.MustCompile(`(-----BEGIN[A-Z ]+PRIVATE KEY-----)`), + regexp.MustCompile(`(-----BEGIN RSA PRIVATE KEY-----)`), + regexp.MustCompile(`(-----BEGIN EC PRIVATE KEY-----)`), + regexp.MustCompile(`(-----BEGIN OPENSSH PRIVATE KEY-----)`), + regexp.MustCompile(`(-----BEGIN PGP PRIVATE KEY BLOCK-----)`), + // Database connection strings + regexp.MustCompile(`(postgres://[^:]+:)([^@]+)(@)`), + regexp.MustCompile(`(mysql://[^:]+:)([^@]+)(@)`), + regexp.MustCompile(`(mongodb(\+srv)?://[^:]+:)([^@]+)(@)`), + } +) + +func sanitizeMessage(msg string) string { + // JSON/YAML field patterns (indices 0-6) - preserve field name + for i := 0; i < 7 && i < len(sensitivePatterns); i++ { + msg = sensitivePatterns[i].ReplaceAllString(msg, `$1"[REDACTED]"`) + } + + // Authorization headers (indices 7-8) - preserve header type + for i := 7; i < 9 && i < len(sensitivePatterns); i++ { + msg = sensitivePatterns[i].ReplaceAllString(msg, `$1[REDACTED]`) + } + + // Database connection strings (indices 25-27) - preserve URL structure + if len(sensitivePatterns) > 27 { + msg = sensitivePatterns[25].ReplaceAllString(msg, `$1[REDACTED]$3`) // PostgreSQL + msg = sensitivePatterns[26].ReplaceAllString(msg, `$1[REDACTED]$3`) // MySQL + msg = sensitivePatterns[27].ReplaceAllString(msg, `$1[REDACTED]$4`) // MongoDB + } + + // All other patterns (AWS, GitHub, tokens, keys, etc.) - redact entire match + for i := 9; i < len(sensitivePatterns); i++ { + // Skip database patterns (already handled) + if i >= 25 && i <= 27 { + continue + } + msg = sensitivePatterns[i].ReplaceAllString(msg, `[REDACTED]`) + } + + return msg +} + +// SendMCPLog sends a log notification to the MCP client and server logs. +// Uses dedicated "mcp" named logger. Message is automatically sanitized. +func SendMCPLog(ctx context.Context, level Level, message string) { + switch level { + case LevelError, LevelCritical, LevelAlert, LevelEmergency: + mcpLogger.Error(nil, message) + case LevelWarning, LevelNotice: + mcpLogger.V(1).Info(message) + default: + mcpLogger.V(2).Info(message) + } + + session, ok := ctx.Value(MCPSessionContextKey).(*mcp.ServerSession) + if !ok || session == nil { + return + } + + message = sanitizeMessage(message) + + if err := session.Log(ctx, &mcp.LoggingMessageParams{ + Level: mcp.LoggingLevel(level.String()), + Logger: "kubernetes-mcp-server", + Data: message, + }); err != nil { + mcpLogger.V(3).Info("failed to send log to MCP client", "error", err) + } +} diff --git a/pkg/mcplog/log_test.go b/pkg/mcplog/log_test.go new file mode 100644 index 000000000..e2bbcea4d --- /dev/null +++ b/pkg/mcplog/log_test.go @@ -0,0 +1,198 @@ +package mcplog + +import ( + "context" + "testing" + + "github.com/stretchr/testify/suite" +) + +type LoggingSuite struct { + suite.Suite +} + +func (s *LoggingSuite) TestSanitizeMessage() { + s.Run("redacts passwords", func() { + msg := `{"password": "secret123"}` // notsecret + sanitized := sanitizeMessage(msg) + s.NotContains(sanitized, "secret123") + s.Contains(sanitized, "[REDACTED]") + }) + + s.Run("redacts bearer tokens", func() { + msg := "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" // notsecret + sanitized := sanitizeMessage(msg) + s.NotContains(sanitized, "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9") // notsecret + s.Contains(sanitized, "[REDACTED]") + }) + + s.Run("redacts basic auth", func() { + msg := "Authorization: Basic dXNlcjpwYXNzd29yZA==" // notsecret + sanitized := sanitizeMessage(msg) + s.NotContains(sanitized, "dXNlcjpwYXNzd29yZA==") // notsecret + s.Contains(sanitized, "[REDACTED]") + }) + + s.Run("redacts token fields", func() { + msg := `{"token": "abc123def456"}` // notsecret + sanitized := sanitizeMessage(msg) + s.NotContains(sanitized, "abc123def456") + s.Contains(sanitized, "[REDACTED]") + }) + + s.Run("redacts secret fields", func() { + msg := `{"secret": "my-secret-value"}` + sanitized := sanitizeMessage(msg) + s.NotContains(sanitized, "my-secret-value") + s.Contains(sanitized, "[REDACTED]") + }) + + s.Run("redacts api_key fields", func() { + msg := `{"api_key": "12345abcde", "api-key": "67890fghij"}` // notsecret + sanitized := sanitizeMessage(msg) + s.NotContains(sanitized, "12345abcde") + s.NotContains(sanitized, "67890fghij") + s.Contains(sanitized, "[REDACTED]") + }) + + s.Run("redacts AWS access keys", func() { + msg := "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE" // notsecret + sanitized := sanitizeMessage(msg) + s.NotContains(sanitized, "AKIAIOSFODNN7EXAMPLE") // notsecret + s.Contains(sanitized, "[REDACTED]") + }) + + s.Run("redacts AWS secret access keys", func() { + msg := "aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" // notsecret + sanitized := sanitizeMessage(msg) + s.NotContains(sanitized, "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY") // notsecret + s.Contains(sanitized, "[REDACTED]") + }) + + s.Run("redacts GitHub tokens", func() { + msg := "token: ghp_1234567890abcdefghijklmnopqrstuv1234" // notsecret + sanitized := sanitizeMessage(msg) + s.NotContains(sanitized, "ghp_1234567890abcdefghijklmnopqrstuv1234") // notsecret + s.Contains(sanitized, "[REDACTED]") + }) + + s.Run("redacts GitLab tokens", func() { + msg := "GITLAB_TOKEN=glpat-abcdefghij1234567890" // notsecret + sanitized := sanitizeMessage(msg) + s.NotContains(sanitized, "glpat-abcdefghij1234567890") // notsecret + s.Contains(sanitized, "[REDACTED]") + }) + + s.Run("redacts GCP API keys", func() { + msg := "apiKey: AIzaSyDaGmWKa4JsXZ-HjGw7ISLn_3namBGewQe" // notsecret + sanitized := sanitizeMessage(msg) + s.NotContains(sanitized, "AIzaSyDaGmWKa4JsXZ-HjGw7ISLn_3namBGewQe") // notsecret + s.Contains(sanitized, "[REDACTED]") + }) + + s.Run("redacts OpenAI API keys", func() { + msg := "OPENAI_API_KEY=sk-proj-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuv" // notsecret + sanitized := sanitizeMessage(msg) + s.NotContains(sanitized, "sk-proj-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuv") // notsecret + s.Contains(sanitized, "[REDACTED]") + }) + + s.Run("redacts Anthropic API keys", func() { + msg := "key: sk-ant-api03-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_ABCDEFGHIJKLMNOPQRSTUVWXYZabcde" // notsecret + sanitized := sanitizeMessage(msg) + s.NotContains(sanitized, "sk-ant-api03-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_ABCDEFGHIJKLMNOPQRSTUVWXYZabcde") // notsecret + s.Contains(sanitized, "[REDACTED]") + }) + + s.Run("redacts JWT tokens", func() { + msg := "token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" // notsecret + sanitized := sanitizeMessage(msg) + s.NotContains(sanitized, "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c") // notsecret + s.Contains(sanitized, "[REDACTED]") + }) + + s.Run("redacts SSH private keys", func() { + msg := "key: -----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA" + sanitized := sanitizeMessage(msg) + s.NotContains(sanitized, "-----BEGIN RSA PRIVATE KEY-----") + s.Contains(sanitized, "[REDACTED]") + }) + + s.Run("redacts PostgreSQL connection strings", func() { + msg := "DB_URL=postgres://user:mypassword@localhost:5432/db" + sanitized := sanitizeMessage(msg) + s.NotContains(sanitized, "mypassword") + s.Contains(sanitized, "postgres://user:[REDACTED]@") + }) + + s.Run("redacts MySQL connection strings", func() { + msg := "mysql://admin:secretpass@db.example.com:3306/prod" + sanitized := sanitizeMessage(msg) + s.NotContains(sanitized, "secretpass") + s.Contains(sanitized, "mysql://admin:[REDACTED]@") + }) + + s.Run("redacts MongoDB connection strings", func() { + msg := "MONGO_URI=mongodb+srv://dbuser:dbpass123@cluster.mongodb.net/mydb" + sanitized := sanitizeMessage(msg) + s.NotContains(sanitized, "dbpass123") + s.Contains(sanitized, "mongodb+srv://dbuser:[REDACTED]@") + }) + + s.Run("preserves non-sensitive data", func() { + msg := `{"namespace": "default", "pod": "nginx"}` + sanitized := sanitizeMessage(msg) + s.Contains(sanitized, "default") + s.Contains(sanitized, "nginx") + }) + + s.Run("handles multiple sensitive fields", func() { + msg := `{"password": "pass123", "token": "tok456", "namespace": "default"}` + sanitized := sanitizeMessage(msg) + s.NotContains(sanitized, "pass123") + s.NotContains(sanitized, "tok456") + s.Contains(sanitized, "[REDACTED]") + s.Contains(sanitized, "default") + }) + + s.Run("handles mixed secret types", func() { + msg := `Failed to connect: {"password": "dbpass", "token": "ghp_1234567890abcdefghijklmnopqrstuv1234", "api_key": "sk-proj-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuv"}` + sanitized := sanitizeMessage(msg) + s.NotContains(sanitized, "dbpass") + s.NotContains(sanitized, "ghp_1234567890abcdefghijklmnopqrstuv1234") + s.NotContains(sanitized, "sk-proj-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuv") + s.Contains(sanitized, "Failed to connect") + s.Contains(sanitized, "[REDACTED]") + }) +} + +func (s *LoggingSuite) TestSendMCPLogWithoutSession() { + s.Run("does not panic without session in context", func() { + ctx := context.Background() + s.NotPanics(func() { + SendMCPLog(ctx, LevelInfo, "test message") + }) + }) + + s.Run("handles all log levels without session", func() { + ctx := context.Background() + levels := []Level{LevelDebug, LevelInfo, LevelNotice, LevelWarning, LevelError, LevelCritical, LevelAlert, LevelEmergency} + for _, level := range levels { + s.NotPanics(func() { + SendMCPLog(ctx, level, "test message for level "+level.String()) + }) + } + }) + + s.Run("sanitizes message even without session", func() { + ctx := context.Background() + // This should not panic and should sanitize the message in server logs + s.NotPanics(func() { + SendMCPLog(ctx, LevelError, "Failed with password: secret123") + }) + }) +} + +func TestLogging(t *testing.T) { + suite.Run(t, new(LoggingSuite)) +} diff --git a/pkg/toolsets/core/error_handling_test.go b/pkg/toolsets/core/error_handling_test.go new file mode 100644 index 000000000..26759b802 --- /dev/null +++ b/pkg/toolsets/core/error_handling_test.go @@ -0,0 +1,164 @@ +package core + +import ( + "context" + "fmt" + "testing" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/containers/kubernetes-mcp-server/pkg/mcplog" + "github.com/stretchr/testify/suite" +) + +type ErrorHandlingSuite struct { + suite.Suite +} + +func (s *ErrorHandlingSuite) TestHandleK8sErrorIntegration() { + ctx := context.Background() + gr := schema.GroupResource{Group: "v1", Resource: "pods"} + + s.Run("handles NotFound errors", func() { + err := apierrors.NewNotFound(gr, "test-pod") + s.NotPanics(func() { + mcplog.HandleK8sError(ctx, err, "pod access") + }) + }) + + s.Run("handles Forbidden errors", func() { + err := apierrors.NewForbidden(gr, "test-pod", nil) + s.NotPanics(func() { + mcplog.HandleK8sError(ctx, err, "pod deletion") + }) + }) + + s.Run("handles Unauthorized errors", func() { + err := apierrors.NewUnauthorized("unauthorized") + s.NotPanics(func() { + mcplog.HandleK8sError(ctx, err, "resource access") + }) + }) + + s.Run("handles AlreadyExists errors", func() { + err := apierrors.NewAlreadyExists(gr, "test-resource") + s.NotPanics(func() { + mcplog.HandleK8sError(ctx, err, "resource creation") + }) + }) + + s.Run("handles Invalid errors", func() { + err := apierrors.NewInvalid(schema.GroupKind{Group: "v1", Kind: "Pod"}, "test-pod", nil) + s.NotPanics(func() { + mcplog.HandleK8sError(ctx, err, "resource creation or update") + }) + }) + + s.Run("handles BadRequest errors", func() { + err := apierrors.NewBadRequest("bad request") + s.NotPanics(func() { + mcplog.HandleK8sError(ctx, err, "resource scaling") + }) + }) + + s.Run("handles Conflict errors", func() { + err := apierrors.NewConflict(gr, "test-resource", nil) + s.NotPanics(func() { + mcplog.HandleK8sError(ctx, err, "resource update") + }) + }) + + s.Run("handles Timeout errors", func() { + err := apierrors.NewTimeoutError("request timeout", 30) + s.NotPanics(func() { + mcplog.HandleK8sError(ctx, err, "node log access") + }) + }) + + s.Run("handles ServerTimeout errors", func() { + err := apierrors.NewServerTimeout(gr, "operation", 60) + s.NotPanics(func() { + mcplog.HandleK8sError(ctx, err, "node stats access") + }) + }) + + s.Run("handles ServiceUnavailable errors", func() { + err := apierrors.NewServiceUnavailable("service unavailable") + s.NotPanics(func() { + mcplog.HandleK8sError(ctx, err, "events listing") + }) + }) + + s.Run("handles TooManyRequests errors", func() { + err := apierrors.NewTooManyRequests("rate limited", 10) + s.NotPanics(func() { + mcplog.HandleK8sError(ctx, err, "namespace listing") + }) + }) + + s.Run("handles generic errors", func() { + err := apierrors.NewInternalError(fmt.Errorf("internal server error")) + s.NotPanics(func() { + mcplog.HandleK8sError(ctx, err, "node metrics access") + }) + }) + + s.Run("handles nil error gracefully", func() { + s.NotPanics(func() { + mcplog.HandleK8sError(ctx, nil, "any operation") + }) + }) +} + +func (s *ErrorHandlingSuite) TestErrorHandlingCoverage() { + s.Run("error handling is consistent across handlers", func() { + handlers := []string{ + "podsGet - pod access", + "podsDelete - pod deletion", + "resourcesList - resource listing", + "resourcesGet - resource access", + "resourcesCreateOrUpdate - resource creation or update", + "resourcesDelete - resource deletion", + "resourcesScale - resource scaling", + "nodesLog - node log access", + "nodesStatsSummary - node stats access", + "nodesTop - node metrics access, node listing", + "eventsList - events listing", + "namespacesList - namespace listing", + "projectsList - project listing", + } + + s.GreaterOrEqual(len(handlers), 13, "should document all error handling points") + }) +} + +func (s *ErrorHandlingSuite) TestOperationDescriptions() { + s.Run("operation descriptions follow naming conventions", func() { + validDescriptions := []string{ + "pod access", + "pod deletion", + "resource listing", + "resource access", + "resource creation or update", + "resource deletion", + "resource scaling", + "node log access", + "node stats access", + "node metrics access", + "node listing", + "events listing", + "namespace listing", + "project listing", + } + + for _, desc := range validDescriptions { + s.NotEmpty(desc, "description should not be empty") + s.Equal(desc, desc, "description should be lowercase: %s", desc) + } + }) +} + +func TestErrorHandling(t *testing.T) { + suite.Run(t, new(ErrorHandlingSuite)) +} diff --git a/pkg/toolsets/core/events.go b/pkg/toolsets/core/events.go index af6257f2f..dbb96be6b 100644 --- a/pkg/toolsets/core/events.go +++ b/pkg/toolsets/core/events.go @@ -8,6 +8,7 @@ import ( "github.com/containers/kubernetes-mcp-server/pkg/api" "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" + "github.com/containers/kubernetes-mcp-server/pkg/mcplog" "github.com/containers/kubernetes-mcp-server/pkg/output" ) @@ -42,6 +43,7 @@ func eventsList(params api.ToolHandlerParams) (*api.ToolCallResult, error) { } eventMap, err := kubernetes.NewCore(params).EventsList(params, namespace.(string)) if err != nil { + mcplog.HandleK8sError(params.Context, err, "events listing") return api.NewToolCallResult("", fmt.Errorf("failed to list events in all namespaces: %v", err)), nil } if len(eventMap) == 0 { diff --git a/pkg/toolsets/core/namespaces.go b/pkg/toolsets/core/namespaces.go index 75762d10c..e2794b37f 100644 --- a/pkg/toolsets/core/namespaces.go +++ b/pkg/toolsets/core/namespaces.go @@ -9,6 +9,7 @@ import ( "github.com/containers/kubernetes-mcp-server/pkg/api" "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" + "github.com/containers/kubernetes-mcp-server/pkg/mcplog" ) func initNamespaces(o api.Openshift) []api.ServerTool { @@ -51,6 +52,7 @@ func initNamespaces(o api.Openshift) []api.ServerTool { func namespacesList(params api.ToolHandlerParams) (*api.ToolCallResult, error) { ret, err := kubernetes.NewCore(params).NamespacesList(params, api.ListOptions{AsTable: params.ListOutput.AsTable()}) if err != nil { + mcplog.HandleK8sError(params.Context, err, "namespace listing") return api.NewToolCallResult("", fmt.Errorf("failed to list namespaces: %v", err)), nil } return api.NewToolCallResult(params.ListOutput.PrintObj(ret)), nil @@ -59,6 +61,7 @@ func namespacesList(params api.ToolHandlerParams) (*api.ToolCallResult, error) { func projectsList(params api.ToolHandlerParams) (*api.ToolCallResult, error) { ret, err := kubernetes.NewCore(params).ProjectsList(params, api.ListOptions{AsTable: params.ListOutput.AsTable()}) if err != nil { + mcplog.HandleK8sError(params.Context, err, "project listing") return api.NewToolCallResult("", fmt.Errorf("failed to list projects: %v", err)), nil } return api.NewToolCallResult(params.ListOutput.PrintObj(ret)), nil diff --git a/pkg/toolsets/core/nodes.go b/pkg/toolsets/core/nodes.go index 2fb3e6469..5ca8f5bf4 100644 --- a/pkg/toolsets/core/nodes.go +++ b/pkg/toolsets/core/nodes.go @@ -14,6 +14,7 @@ import ( "github.com/containers/kubernetes-mcp-server/pkg/api" "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" + "github.com/containers/kubernetes-mcp-server/pkg/mcplog" ) func initNodes() []api.ServerTool { @@ -116,6 +117,7 @@ func nodesLog(params api.ToolHandlerParams) (*api.ToolCallResult, error) { } ret, err := kubernetes.NewCore(params).NodesLog(params, name, query, tailInt) if err != nil { + mcplog.HandleK8sError(params.Context, err, "node log access") return api.NewToolCallResult("", fmt.Errorf("failed to get node log for %s: %v", name, err)), nil } else if ret == "" { ret = fmt.Sprintf("The node %s has not logged any message yet or the log file is empty", name) @@ -130,6 +132,7 @@ func nodesStatsSummary(params api.ToolHandlerParams) (*api.ToolCallResult, error } ret, err := kubernetes.NewCore(params).NodesStatsSummary(params, name) if err != nil { + mcplog.HandleK8sError(params.Context, err, "node stats access") return api.NewToolCallResult("", fmt.Errorf("failed to get node stats summary for %s: %v", name, err)), nil } return api.NewToolCallResult(ret, nil), nil @@ -146,6 +149,7 @@ func nodesTop(params api.ToolHandlerParams) (*api.ToolCallResult, error) { nodeMetrics, err := kubernetes.NewCore(params).NodesTop(params, nodesTopOptions) if err != nil { + mcplog.HandleK8sError(params.Context, err, "node metrics access") return api.NewToolCallResult("", fmt.Errorf("failed to get nodes top: %v", err)), nil } @@ -154,6 +158,7 @@ func nodesTop(params api.ToolHandlerParams) (*api.ToolCallResult, error) { LabelSelector: nodesTopOptions.LabelSelector, }) if err != nil { + mcplog.HandleK8sError(params.Context, err, "node listing") return api.NewToolCallResult("", fmt.Errorf("failed to list nodes: %v", err)), nil } diff --git a/pkg/toolsets/core/pods.go b/pkg/toolsets/core/pods.go index 6692e2a7d..e42307d5b 100644 --- a/pkg/toolsets/core/pods.go +++ b/pkg/toolsets/core/pods.go @@ -11,6 +11,7 @@ import ( "github.com/containers/kubernetes-mcp-server/pkg/api" "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" + "github.com/containers/kubernetes-mcp-server/pkg/mcplog" "github.com/containers/kubernetes-mcp-server/pkg/output" ) @@ -294,6 +295,7 @@ func podsGet(params api.ToolHandlerParams) (*api.ToolCallResult, error) { } ret, err := kubernetes.NewCore(params).PodsGet(params, ns.(string), name.(string)) if err != nil { + mcplog.HandleK8sError(params.Context, err, "pod access") return api.NewToolCallResult("", fmt.Errorf("failed to get pod %s in namespace %s: %v", name, ns, err)), nil } return api.NewToolCallResult(output.MarshalYaml(ret)), nil @@ -310,6 +312,7 @@ func podsDelete(params api.ToolHandlerParams) (*api.ToolCallResult, error) { } ret, err := kubernetes.NewCore(params).PodsDelete(params, ns.(string), name.(string)) if err != nil { + mcplog.HandleK8sError(params.Context, err, "pod deletion") return api.NewToolCallResult("", fmt.Errorf("failed to delete pod %s in namespace %s: %v", name, ns, err)), nil } return api.NewToolCallResult(ret, err), nil diff --git a/pkg/toolsets/core/resources.go b/pkg/toolsets/core/resources.go index 72ca0d60a..92e2c719e 100644 --- a/pkg/toolsets/core/resources.go +++ b/pkg/toolsets/core/resources.go @@ -11,6 +11,7 @@ import ( "github.com/containers/kubernetes-mcp-server/pkg/api" "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" + "github.com/containers/kubernetes-mcp-server/pkg/mcplog" "github.com/containers/kubernetes-mcp-server/pkg/output" ) @@ -206,6 +207,7 @@ func resourcesList(params api.ToolHandlerParams) (*api.ToolCallResult, error) { ret, err := kubernetes.NewCore(params).ResourcesList(params, gvk, ns, resourceListOptions) if err != nil { + mcplog.HandleK8sError(params.Context, err, "resource listing") return api.NewToolCallResult("", fmt.Errorf("failed to list resources: %v", err)), nil } return api.NewToolCallResult(params.ListOutput.PrintObj(ret)), nil @@ -237,6 +239,7 @@ func resourcesGet(params api.ToolHandlerParams) (*api.ToolCallResult, error) { ret, err := kubernetes.NewCore(params).ResourcesGet(params, gvk, ns, n) if err != nil { + mcplog.HandleK8sError(params.Context, err, "resource access") return api.NewToolCallResult("", fmt.Errorf("failed to get resource: %v", err)), nil } return api.NewToolCallResult(output.MarshalYaml(ret)), nil @@ -255,6 +258,7 @@ func resourcesCreateOrUpdate(params api.ToolHandlerParams) (*api.ToolCallResult, resources, err := kubernetes.NewCore(params).ResourcesCreateOrUpdate(params, r) if err != nil { + mcplog.HandleK8sError(params.Context, err, "resource creation or update") return api.NewToolCallResult("", fmt.Errorf("failed to create or update resources: %v", err)), nil } marshalledYaml, err := output.MarshalYaml(resources) @@ -290,6 +294,7 @@ func resourcesDelete(params api.ToolHandlerParams) (*api.ToolCallResult, error) err = kubernetes.NewCore(params).ResourcesDelete(params, gvk, ns, n) if err != nil { + mcplog.HandleK8sError(params.Context, err, "resource deletion") return api.NewToolCallResult("", fmt.Errorf("failed to delete resource: %v", err)), nil } return api.NewToolCallResult("Resource deleted successfully", err), nil @@ -332,6 +337,7 @@ func resourcesScale(params api.ToolHandlerParams) (*api.ToolCallResult, error) { scale, err := kubernetes.NewCore(params).ResourcesScale(params.Context, gvk, ns, n, desiredScale, shouldScale) if err != nil { + mcplog.HandleK8sError(params.Context, err, "resource scaling") return api.NewToolCallResult("", fmt.Errorf("failed to get/update resource scale: %w", err)), nil } diff --git a/pkg/toolsets/helm/helm.go b/pkg/toolsets/helm/helm.go index c11edefc3..a6a697c41 100644 --- a/pkg/toolsets/helm/helm.go +++ b/pkg/toolsets/helm/helm.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/containers/kubernetes-mcp-server/pkg/helm" + "github.com/containers/kubernetes-mcp-server/pkg/mcplog" "github.com/google/jsonschema-go/jsonschema" "k8s.io/utils/ptr" @@ -115,6 +116,7 @@ func helmInstall(params api.ToolHandlerParams) (*api.ToolCallResult, error) { } ret, err := helm.NewHelm(params).Install(params, chart, values, name, namespace) if err != nil { + mcplog.HandleK8sError(params.Context, err, "helm install") return api.NewToolCallResult("", fmt.Errorf("failed to install helm chart '%s': %w", chart, err)), nil } return api.NewToolCallResult(ret, err), nil @@ -131,6 +133,7 @@ func helmList(params api.ToolHandlerParams) (*api.ToolCallResult, error) { } ret, err := helm.NewHelm(params).List(namespace, allNamespaces) if err != nil { + mcplog.HandleK8sError(params.Context, err, "helm list") return api.NewToolCallResult("", fmt.Errorf("failed to list helm releases in namespace '%s': %w", namespace, err)), nil } return api.NewToolCallResult(ret, err), nil @@ -148,6 +151,7 @@ func helmUninstall(params api.ToolHandlerParams) (*api.ToolCallResult, error) { } ret, err := helm.NewHelm(params).Uninstall(name, namespace) if err != nil { + mcplog.HandleK8sError(params.Context, err, "helm uninstall") return api.NewToolCallResult("", fmt.Errorf("failed to uninstall helm chart '%s': %w", name, err)), nil } return api.NewToolCallResult(ret, err), nil