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