Skip to main content
Glama

kubernetes-mcp-server

http_test.go38.4 kB
package http import ( "bufio" "bytes" "context" "crypto/rand" "crypto/rsa" "flag" "fmt" "io" "net" "net/http" "net/http/httptest" "os" "regexp" "strconv" "strings" "testing" "time" "github.com/containers/kubernetes-mcp-server/internal/test" "github.com/coreos/go-oidc/v3/oidc" "github.com/coreos/go-oidc/v3/oidc/oidctest" "golang.org/x/sync/errgroup" "k8s.io/klog/v2" "k8s.io/klog/v2/textlogger" "github.com/containers/kubernetes-mcp-server/pkg/config" "github.com/containers/kubernetes-mcp-server/pkg/mcp" ) type httpContext struct { klogState klog.State mockServer *test.MockServer LogBuffer bytes.Buffer HttpAddress string // HTTP server address timeoutCancel context.CancelFunc // Release resources if test completes before the timeout StopServer context.CancelFunc WaitForShutdown func() error StaticConfig *config.StaticConfig OidcProvider *oidc.Provider } const tokenReviewSuccessful = ` { "kind": "TokenReview", "apiVersion": "authentication.k8s.io/v1", "spec": {"token": "valid-token"}, "status": { "authenticated": true, "user": { "username": "test-user", "groups": ["system:authenticated"] } } }` func (c *httpContext) beforeEach(t *testing.T) { t.Helper() http.DefaultClient.Timeout = 10 * time.Second if c.StaticConfig == nil { c.StaticConfig = config.Default() } c.mockServer = test.NewMockServer() // Fake Kubernetes configuration c.StaticConfig.KubeConfig = c.mockServer.KubeconfigFile(t) // Capture logging c.klogState = klog.CaptureState() flags := flag.NewFlagSet("test", flag.ContinueOnError) klog.InitFlags(flags) _ = flags.Set("v", "5") klog.SetLogger(textlogger.NewLogger(textlogger.NewConfig(textlogger.Verbosity(5), textlogger.Output(&c.LogBuffer)))) // Start server in random port ln, err := net.Listen("tcp", "0.0.0.0:0") if err != nil { t.Fatalf("Failed to find random port for HTTP server: %v", err) } c.HttpAddress = ln.Addr().String() if randomPortErr := ln.Close(); randomPortErr != nil { t.Fatalf("Failed to close random port listener: %v", randomPortErr) } c.StaticConfig.Port = fmt.Sprintf("%d", ln.Addr().(*net.TCPAddr).Port) mcpServer, err := mcp.NewServer(mcp.Configuration{StaticConfig: c.StaticConfig}) if err != nil { t.Fatalf("Failed to create MCP server: %v", err) } var timeoutCtx, cancelCtx context.Context timeoutCtx, c.timeoutCancel = context.WithTimeout(t.Context(), 10*time.Second) group, gc := errgroup.WithContext(timeoutCtx) cancelCtx, c.StopServer = context.WithCancel(gc) group.Go(func() error { return Serve(cancelCtx, mcpServer, c.StaticConfig, c.OidcProvider) }) c.WaitForShutdown = group.Wait // Wait for HTTP server to start (using net) for i := 0; i < 10; i++ { conn, err := net.Dial("tcp", c.HttpAddress) if err == nil { _ = conn.Close() break } time.Sleep(50 * time.Millisecond) // Wait before retrying } } func (c *httpContext) afterEach(t *testing.T) { t.Helper() c.mockServer.Close() c.StopServer() err := c.WaitForShutdown() if err != nil { t.Errorf("HTTP server did not shut down gracefully: %v", err) } c.timeoutCancel() c.klogState.Restore() _ = os.Setenv("KUBECONFIG", "") } func testCase(t *testing.T, test func(c *httpContext)) { testCaseWithContext(t, &httpContext{}, test) } func testCaseWithContext(t *testing.T, httpCtx *httpContext, test func(c *httpContext)) { httpCtx.beforeEach(t) t.Cleanup(func() { httpCtx.afterEach(t) }) test(httpCtx) } type OidcTestServer struct { *rsa.PrivateKey *oidc.Provider *httptest.Server TokenEndpointHandler http.HandlerFunc } func NewOidcTestServer(t *testing.T) (oidcTestServer *OidcTestServer) { t.Helper() var err error oidcTestServer = &OidcTestServer{} oidcTestServer.PrivateKey, err = rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("failed to generate private key for oidc: %v", err) } oidcServer := &oidctest.Server{ Algorithms: []string{oidc.RS256, oidc.ES256}, PublicKeys: []oidctest.PublicKey{ { PublicKey: oidcTestServer.Public(), KeyID: "test-oidc-key-id", Algorithm: oidc.RS256, }, }, } oidcTestServer.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/token" && oidcTestServer.TokenEndpointHandler != nil { oidcTestServer.TokenEndpointHandler.ServeHTTP(w, r) return } oidcServer.ServeHTTP(w, r) })) oidcServer.SetIssuer(oidcTestServer.URL) oidcTestServer.Provider, err = oidc.NewProvider(t.Context(), oidcTestServer.URL) if err != nil { t.Fatalf("failed to create OIDC provider: %v", err) } return } func TestGracefulShutdown(t *testing.T) { testCase(t, func(ctx *httpContext) { ctx.StopServer() err := ctx.WaitForShutdown() t.Run("Stops gracefully", func(t *testing.T) { if err != nil { t.Errorf("Expected graceful shutdown, but got error: %v", err) } }) t.Run("Stops on context cancel", func(t *testing.T) { if !strings.Contains(ctx.LogBuffer.String(), "Context cancelled, initiating graceful shutdown") { t.Errorf("Context cancelled, initiating graceful shutdown, got: %s", ctx.LogBuffer.String()) } }) t.Run("Starts server shutdown", func(t *testing.T) { if !strings.Contains(ctx.LogBuffer.String(), "Shutting down HTTP server gracefully") { t.Errorf("Expected graceful shutdown log, got: %s", ctx.LogBuffer.String()) } }) t.Run("Server shutdown completes", func(t *testing.T) { if !strings.Contains(ctx.LogBuffer.String(), "HTTP server shutdown complete") { t.Errorf("Expected HTTP server shutdown completed log, got: %s", ctx.LogBuffer.String()) } }) }) } func TestSseTransport(t *testing.T) { testCase(t, func(ctx *httpContext) { sseResp, sseErr := http.Get(fmt.Sprintf("http://%s/sse", ctx.HttpAddress)) t.Cleanup(func() { _ = sseResp.Body.Close() }) t.Run("Exposes SSE endpoint at /sse", func(t *testing.T) { if sseErr != nil { t.Fatalf("Failed to get SSE endpoint: %v", sseErr) } if sseResp.StatusCode != http.StatusOK { t.Errorf("Expected HTTP 200 OK, got %d", sseResp.StatusCode) } }) t.Run("SSE endpoint returns text/event-stream content type", func(t *testing.T) { if sseResp.Header.Get("Content-Type") != "text/event-stream" { t.Errorf("Expected Content-Type text/event-stream, got %s", sseResp.Header.Get("Content-Type")) } }) responseReader := bufio.NewReader(sseResp.Body) event, eventErr := responseReader.ReadString('\n') endpoint, endpointErr := responseReader.ReadString('\n') t.Run("SSE endpoint returns stream with messages endpoint", func(t *testing.T) { if eventErr != nil { t.Fatalf("Failed to read SSE response body (event): %v", eventErr) } if event != "event: endpoint\n" { t.Errorf("Expected SSE event 'endpoint', got %s", event) } if endpointErr != nil { t.Fatalf("Failed to read SSE response body (endpoint): %v", endpointErr) } if !strings.HasPrefix(endpoint, "data: /message?sessionId=") { t.Errorf("Expected SSE data: '/message', got %s", endpoint) } }) messageResp, messageErr := http.Post( fmt.Sprintf("http://%s/message?sessionId=%s", ctx.HttpAddress, strings.TrimSpace(endpoint[25:])), "application/json", bytes.NewBufferString("{}"), ) t.Cleanup(func() { _ = messageResp.Body.Close() }) t.Run("Exposes message endpoint at /message", func(t *testing.T) { if messageErr != nil { t.Fatalf("Failed to get message endpoint: %v", messageErr) } if messageResp.StatusCode != http.StatusAccepted { t.Errorf("Expected HTTP 202 OK, got %d", messageResp.StatusCode) } }) }) } func TestStreamableHttpTransport(t *testing.T) { testCase(t, func(ctx *httpContext) { mcpGetResp, mcpGetErr := http.Get(fmt.Sprintf("http://%s/mcp", ctx.HttpAddress)) t.Cleanup(func() { _ = mcpGetResp.Body.Close() }) t.Run("Exposes MCP GET endpoint at /mcp", func(t *testing.T) { if mcpGetErr != nil { t.Fatalf("Failed to get MCP endpoint: %v", mcpGetErr) } if mcpGetResp.StatusCode != http.StatusOK { t.Errorf("Expected HTTP 200 OK, got %d", mcpGetResp.StatusCode) } }) t.Run("MCP GET endpoint returns text/event-stream content type", func(t *testing.T) { if mcpGetResp.Header.Get("Content-Type") != "text/event-stream" { t.Errorf("Expected Content-Type text/event-stream (GET), got %s", mcpGetResp.Header.Get("Content-Type")) } }) mcpPostResp, mcpPostErr := http.Post(fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), "application/json", bytes.NewBufferString("{}")) t.Cleanup(func() { _ = mcpPostResp.Body.Close() }) t.Run("Exposes MCP POST endpoint at /mcp", func(t *testing.T) { if mcpPostErr != nil { t.Fatalf("Failed to post to MCP endpoint: %v", mcpPostErr) } if mcpPostResp.StatusCode != http.StatusOK { t.Errorf("Expected HTTP 200 OK, got %d", mcpPostResp.StatusCode) } }) t.Run("MCP POST endpoint returns application/json content type", func(t *testing.T) { if mcpPostResp.Header.Get("Content-Type") != "application/json" { t.Errorf("Expected Content-Type application/json (POST), got %s", mcpPostResp.Header.Get("Content-Type")) } }) }) } func TestHealthCheck(t *testing.T) { testCase(t, func(ctx *httpContext) { t.Run("Exposes health check endpoint at /healthz", func(t *testing.T) { resp, err := http.Get(fmt.Sprintf("http://%s/healthz", ctx.HttpAddress)) if err != nil { t.Fatalf("Failed to get health check endpoint: %v", err) } t.Cleanup(func() { _ = resp.Body.Close }) if resp.StatusCode != http.StatusOK { t.Errorf("Expected HTTP 200 OK, got %d", resp.StatusCode) } }) }) // Health exposed even when require Authorization testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, ValidateToken: true, ClusterProviderStrategy: config.ClusterProviderKubeConfig}}, func(ctx *httpContext) { resp, err := http.Get(fmt.Sprintf("http://%s/healthz", ctx.HttpAddress)) if err != nil { t.Fatalf("Failed to get health check endpoint with OAuth: %v", err) } t.Cleanup(func() { _ = resp.Body.Close() }) t.Run("Health check with OAuth returns HTTP 200 OK", func(t *testing.T) { if resp.StatusCode != http.StatusOK { t.Errorf("Expected HTTP 200 OK, got %d", resp.StatusCode) } }) }) } func TestWellKnownReverseProxy(t *testing.T) { cases := []string{ ".well-known/oauth-authorization-server", ".well-known/oauth-protected-resource", ".well-known/openid-configuration", } // With No Authorization URL configured testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, ValidateToken: true, ClusterProviderStrategy: config.ClusterProviderKubeConfig}}, func(ctx *httpContext) { for _, path := range cases { resp, err := http.Get(fmt.Sprintf("http://%s/%s", ctx.HttpAddress, path)) t.Cleanup(func() { _ = resp.Body.Close() }) t.Run("Protected resource '"+path+"' without Authorization URL returns 404 - Not Found", func(t *testing.T) { if err != nil { t.Fatalf("Failed to get %s endpoint: %v", path, err) } if resp.StatusCode != http.StatusNotFound { t.Errorf("Expected HTTP 404 Not Found, got %d", resp.StatusCode) } }) } }) // With Authorization URL configured but invalid payload invalidPayloadServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`NOT A JSON PAYLOAD`)) })) t.Cleanup(invalidPayloadServer.Close) invalidPayloadConfig := &config.StaticConfig{ AuthorizationURL: invalidPayloadServer.URL, RequireOAuth: true, ValidateToken: true, ClusterProviderStrategy: config.ClusterProviderKubeConfig, } testCaseWithContext(t, &httpContext{StaticConfig: invalidPayloadConfig}, func(ctx *httpContext) { for _, path := range cases { resp, err := http.Get(fmt.Sprintf("http://%s/%s", ctx.HttpAddress, path)) t.Cleanup(func() { _ = resp.Body.Close() }) t.Run("Protected resource '"+path+"' with invalid Authorization URL payload returns 500 - Internal Server Error", func(t *testing.T) { if err != nil { t.Fatalf("Failed to get %s endpoint: %v", path, err) } if resp.StatusCode != http.StatusInternalServerError { t.Errorf("Expected HTTP 500 Internal Server Error, got %d", resp.StatusCode) } }) } }) // With Authorization URL configured and valid payload testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !strings.HasPrefix(r.URL.EscapedPath(), "/.well-known/") { http.NotFound(w, r) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"issuer": "https://example.com","scopes_supported":["mcp-server"]}`)) })) t.Cleanup(testServer.Close) staticConfig := &config.StaticConfig{ AuthorizationURL: testServer.URL, RequireOAuth: true, ValidateToken: true, ClusterProviderStrategy: config.ClusterProviderKubeConfig, } testCaseWithContext(t, &httpContext{StaticConfig: staticConfig}, func(ctx *httpContext) { for _, path := range cases { resp, err := http.Get(fmt.Sprintf("http://%s/%s", ctx.HttpAddress, path)) t.Cleanup(func() { _ = resp.Body.Close() }) t.Run("Exposes "+path+" endpoint", func(t *testing.T) { if err != nil { t.Fatalf("Failed to get %s endpoint: %v", path, err) } if resp.StatusCode != http.StatusOK { t.Errorf("Expected HTTP 200 OK, got %d", resp.StatusCode) } }) t.Run(path+" returns application/json content type", func(t *testing.T) { if resp.Header.Get("Content-Type") != "application/json" { t.Errorf("Expected Content-Type application/json, got %s", resp.Header.Get("Content-Type")) } }) } }) } func TestWellKnownOverrides(t *testing.T) { cases := []string{ ".well-known/oauth-authorization-server", ".well-known/oauth-protected-resource", ".well-known/openid-configuration", } testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !strings.HasPrefix(r.URL.EscapedPath(), "/.well-known/") { http.NotFound(w, r) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(` { "issuer": "https://localhost", "registration_endpoint": "https://localhost/clients-registrations/openid-connect", "require_request_uri_registration": true, "scopes_supported":["scope-1", "scope-2"] }`)) })) t.Cleanup(testServer.Close) baseConfig := config.StaticConfig{ AuthorizationURL: testServer.URL, RequireOAuth: true, ValidateToken: true, ClusterProviderStrategy: config.ClusterProviderKubeConfig, } // With Dynamic Client Registration disabled disableDynamicRegistrationConfig := baseConfig disableDynamicRegistrationConfig.DisableDynamicClientRegistration = true testCaseWithContext(t, &httpContext{StaticConfig: &disableDynamicRegistrationConfig}, func(ctx *httpContext) { for _, path := range cases { resp, _ := http.Get(fmt.Sprintf("http://%s/%s", ctx.HttpAddress, path)) t.Cleanup(func() { _ = resp.Body.Close() }) body, err := io.ReadAll(resp.Body) if err != nil { t.Fatalf("Failed to read response body: %v", err) } t.Run("DisableDynamicClientRegistration removes registration_endpoint field", func(t *testing.T) { if strings.Contains(string(body), "registration_endpoint") { t.Error("Expected registration_endpoint to be removed, but it was found in the response") } }) t.Run("DisableDynamicClientRegistration sets require_request_uri_registration = false", func(t *testing.T) { if !strings.Contains(string(body), `"require_request_uri_registration":false`) { t.Error("Expected require_request_uri_registration to be false, but it was not found in the response") } }) t.Run("DisableDynamicClientRegistration includes/preserves scopes_supported", func(t *testing.T) { if !strings.Contains(string(body), `"scopes_supported":["scope-1","scope-2"]`) { t.Error("Expected scopes_supported to be present, but it was not found in the response") } }) } }) // With overrides for OAuth scopes (client/frontend) oAuthScopesConfig := baseConfig oAuthScopesConfig.OAuthScopes = []string{"openid", "mcp-server"} testCaseWithContext(t, &httpContext{StaticConfig: &oAuthScopesConfig}, func(ctx *httpContext) { for _, path := range cases { resp, _ := http.Get(fmt.Sprintf("http://%s/%s", ctx.HttpAddress, path)) t.Cleanup(func() { _ = resp.Body.Close() }) body, err := io.ReadAll(resp.Body) if err != nil { t.Fatalf("Failed to read response body: %v", err) } t.Run("OAuthScopes overrides scopes_supported", func(t *testing.T) { if !strings.Contains(string(body), `"scopes_supported":["openid","mcp-server"]`) { t.Errorf("Expected scopes_supported to be overridden, but original was preserved, response: %s", string(body)) } }) t.Run("OAuthScopes preserves other fields", func(t *testing.T) { if !strings.Contains(string(body), `"issuer":"https://localhost"`) { t.Errorf("Expected issuer to be preserved, but got: %s", string(body)) } if !strings.Contains(string(body), `"registration_endpoint":"https://localhost`) { t.Errorf("Expected registration_endpoint to be preserved, but got: %s", string(body)) } if !strings.Contains(string(body), `"require_request_uri_registration":true`) { t.Error("Expected require_request_uri_registration to be true, but it was not found in the response") } }) } }) } func TestMiddlewareLogging(t *testing.T) { testCase(t, func(ctx *httpContext) { _, _ = http.Get(fmt.Sprintf("http://%s/.well-known/oauth-protected-resource", ctx.HttpAddress)) t.Run("Logs HTTP requests and responses", func(t *testing.T) { if !strings.Contains(ctx.LogBuffer.String(), "GET /.well-known/oauth-protected-resource 404") { t.Errorf("Expected log entry for GET /.well-known/oauth-protected-resource, got: %s", ctx.LogBuffer.String()) } }) t.Run("Logs HTTP request duration", func(t *testing.T) { expected := `"GET /.well-known/oauth-protected-resource 404 (.+)"` m := regexp.MustCompile(expected).FindStringSubmatch(ctx.LogBuffer.String()) if len(m) != 2 { t.Fatalf("Expected log entry to contain duration, got %s", ctx.LogBuffer.String()) } duration, err := time.ParseDuration(m[1]) if err != nil { t.Fatalf("Failed to parse duration from log entry: %v", err) } if duration < 0 { t.Errorf("Expected duration to be non-negative, got %v", duration) } }) }) } func TestAuthorizationUnauthorized(t *testing.T) { // Missing Authorization header testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, ValidateToken: true, ClusterProviderStrategy: config.ClusterProviderKubeConfig}}, func(ctx *httpContext) { resp, err := http.Get(fmt.Sprintf("http://%s/mcp", ctx.HttpAddress)) if err != nil { t.Fatalf("Failed to get protected endpoint: %v", err) } t.Cleanup(func() { _ = resp.Body.Close }) t.Run("Protected resource with MISSING Authorization header returns 401 - Unauthorized", func(t *testing.T) { if resp.StatusCode != 401 { t.Errorf("Expected HTTP 401, got %d", resp.StatusCode) } }) t.Run("Protected resource with MISSING Authorization header returns WWW-Authenticate header", func(t *testing.T) { authHeader := resp.Header.Get("WWW-Authenticate") expected := `Bearer realm="Kubernetes MCP Server", error="missing_token"` if authHeader != expected { t.Errorf("Expected WWW-Authenticate header to be %q, got %q", expected, authHeader) } }) t.Run("Protected resource with MISSING Authorization header logs error", func(t *testing.T) { if !strings.Contains(ctx.LogBuffer.String(), "Authentication failed - missing or invalid bearer token") { t.Errorf("Expected log entry for missing or invalid bearer token, got: %s", ctx.LogBuffer.String()) } }) }) // Authorization header without Bearer prefix testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, ValidateToken: true, ClusterProviderStrategy: config.ClusterProviderKubeConfig}}, func(ctx *httpContext) { req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil) if err != nil { t.Fatalf("Failed to create request: %v", err) } req.Header.Set("Authorization", "Basic YWxhZGRpbjpvcGVuc2VzYW1l") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("Failed to get protected endpoint: %v", err) } t.Cleanup(func() { _ = resp.Body.Close }) t.Run("Protected resource with INCOMPATIBLE Authorization header returns WWW-Authenticate header", func(t *testing.T) { authHeader := resp.Header.Get("WWW-Authenticate") expected := `Bearer realm="Kubernetes MCP Server", error="missing_token"` if authHeader != expected { t.Errorf("Expected WWW-Authenticate header to be %q, got %q", expected, authHeader) } }) t.Run("Protected resource with INCOMPATIBLE Authorization header logs error", func(t *testing.T) { if !strings.Contains(ctx.LogBuffer.String(), "Authentication failed - missing or invalid bearer token") { t.Errorf("Expected log entry for missing or invalid bearer token, got: %s", ctx.LogBuffer.String()) } }) }) // Invalid Authorization header testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, ValidateToken: true, ClusterProviderStrategy: config.ClusterProviderKubeConfig}}, func(ctx *httpContext) { req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil) if err != nil { t.Fatalf("Failed to create request: %v", err) } req.Header.Set("Authorization", "Bearer "+strings.ReplaceAll(tokenBasicNotExpired, ".", ".invalid")) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("Failed to get protected endpoint: %v", err) } t.Cleanup(func() { _ = resp.Body.Close }) t.Run("Protected resource with INVALID Authorization header returns 401 - Unauthorized", func(t *testing.T) { if resp.StatusCode != 401 { t.Errorf("Expected HTTP 401, got %d", resp.StatusCode) } }) t.Run("Protected resource with INVALID Authorization header returns WWW-Authenticate header", func(t *testing.T) { authHeader := resp.Header.Get("WWW-Authenticate") expected := `Bearer realm="Kubernetes MCP Server", error="invalid_token"` if authHeader != expected { t.Errorf("Expected WWW-Authenticate header to be %q, got %q", expected, authHeader) } }) t.Run("Protected resource with INVALID Authorization header logs error", func(t *testing.T) { if !strings.Contains(ctx.LogBuffer.String(), "Authentication failed - JWT validation error") || !strings.Contains(ctx.LogBuffer.String(), "error: failed to parse JWT token: illegal base64 data") { t.Errorf("Expected log entry for JWT validation error, got: %s", ctx.LogBuffer.String()) } }) }) // Expired Authorization Bearer token testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, ValidateToken: true, ClusterProviderStrategy: config.ClusterProviderKubeConfig}}, func(ctx *httpContext) { req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil) if err != nil { t.Fatalf("Failed to create request: %v", err) } req.Header.Set("Authorization", "Bearer "+tokenBasicExpired) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("Failed to get protected endpoint: %v", err) } t.Cleanup(func() { _ = resp.Body.Close }) t.Run("Protected resource with EXPIRED Authorization header returns 401 - Unauthorized", func(t *testing.T) { if resp.StatusCode != 401 { t.Errorf("Expected HTTP 401, got %d", resp.StatusCode) } }) t.Run("Protected resource with EXPIRED Authorization header returns WWW-Authenticate header", func(t *testing.T) { authHeader := resp.Header.Get("WWW-Authenticate") expected := `Bearer realm="Kubernetes MCP Server", error="invalid_token"` if authHeader != expected { t.Errorf("Expected WWW-Authenticate header to be %q, got %q", expected, authHeader) } }) t.Run("Protected resource with EXPIRED Authorization header logs error", func(t *testing.T) { if !strings.Contains(ctx.LogBuffer.String(), "Authentication failed - JWT validation error") || !strings.Contains(ctx.LogBuffer.String(), "validation failed, token is expired (exp)") { t.Errorf("Expected log entry for JWT validation error, got: %s", ctx.LogBuffer.String()) } }) }) // Invalid audience claim Bearer token testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, OAuthAudience: "expected-audience", ValidateToken: true, ClusterProviderStrategy: config.ClusterProviderKubeConfig}}, func(ctx *httpContext) { req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil) if err != nil { t.Fatalf("Failed to create request: %v", err) } req.Header.Set("Authorization", "Bearer "+tokenBasicExpired) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("Failed to get protected endpoint: %v", err) } t.Cleanup(func() { _ = resp.Body.Close }) t.Run("Protected resource with INVALID AUDIENCE Authorization header returns 401 - Unauthorized", func(t *testing.T) { if resp.StatusCode != 401 { t.Errorf("Expected HTTP 401, got %d", resp.StatusCode) } }) t.Run("Protected resource with INVALID AUDIENCE Authorization header returns WWW-Authenticate header", func(t *testing.T) { authHeader := resp.Header.Get("WWW-Authenticate") expected := `Bearer realm="Kubernetes MCP Server", audience="expected-audience", error="invalid_token"` if authHeader != expected { t.Errorf("Expected WWW-Authenticate header to be %q, got %q", expected, authHeader) } }) t.Run("Protected resource with INVALID AUDIENCE Authorization header logs error", func(t *testing.T) { if !strings.Contains(ctx.LogBuffer.String(), "Authentication failed - JWT validation error") || !strings.Contains(ctx.LogBuffer.String(), "invalid audience claim (aud)") { t.Errorf("Expected log entry for JWT validation error, got: %s", ctx.LogBuffer.String()) } }) }) // Failed OIDC validation oidcTestServer := NewOidcTestServer(t) t.Cleanup(oidcTestServer.Close) testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, OAuthAudience: "mcp-server", ValidateToken: true, ClusterProviderStrategy: config.ClusterProviderKubeConfig}, OidcProvider: oidcTestServer.Provider}, func(ctx *httpContext) { req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil) if err != nil { t.Fatalf("Failed to create request: %v", err) } req.Header.Set("Authorization", "Bearer "+tokenBasicNotExpired) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("Failed to get protected endpoint: %v", err) } t.Cleanup(func() { _ = resp.Body.Close }) t.Run("Protected resource with INVALID OIDC Authorization header returns 401 - Unauthorized", func(t *testing.T) { if resp.StatusCode != 401 { t.Errorf("Expected HTTP 401, got %d", resp.StatusCode) } }) t.Run("Protected resource with INVALID OIDC Authorization header returns WWW-Authenticate header", func(t *testing.T) { authHeader := resp.Header.Get("WWW-Authenticate") expected := `Bearer realm="Kubernetes MCP Server", audience="mcp-server", error="invalid_token"` if authHeader != expected { t.Errorf("Expected WWW-Authenticate header to be %q, got %q", expected, authHeader) } }) t.Run("Protected resource with INVALID OIDC Authorization header logs error", func(t *testing.T) { if !strings.Contains(ctx.LogBuffer.String(), "Authentication failed - JWT validation error") || !strings.Contains(ctx.LogBuffer.String(), "OIDC token validation error: failed to verify signature") { t.Errorf("Expected log entry for OIDC validation error, got: %s", ctx.LogBuffer.String()) } }) }) // Failed Kubernetes TokenReview rawClaims := `{ "iss": "` + oidcTestServer.URL + `", "exp": ` + strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10) + `, "aud": "mcp-server" }` validOidcToken := oidctest.SignIDToken(oidcTestServer.PrivateKey, "test-oidc-key-id", oidc.RS256, rawClaims) testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, OAuthAudience: "mcp-server", ValidateToken: true, ClusterProviderStrategy: config.ClusterProviderKubeConfig}, OidcProvider: oidcTestServer.Provider}, func(ctx *httpContext) { req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil) if err != nil { t.Fatalf("Failed to create request: %v", err) } req.Header.Set("Authorization", "Bearer "+validOidcToken) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("Failed to get protected endpoint: %v", err) } t.Cleanup(func() { _ = resp.Body.Close }) t.Run("Protected resource with INVALID KUBERNETES Authorization header returns 401 - Unauthorized", func(t *testing.T) { if resp.StatusCode != 401 { t.Errorf("Expected HTTP 401, got %d", resp.StatusCode) } }) t.Run("Protected resource with INVALID KUBERNETES Authorization header returns WWW-Authenticate header", func(t *testing.T) { authHeader := resp.Header.Get("WWW-Authenticate") expected := `Bearer realm="Kubernetes MCP Server", audience="mcp-server", error="invalid_token"` if authHeader != expected { t.Errorf("Expected WWW-Authenticate header to be %q, got %q", expected, authHeader) } }) t.Run("Protected resource with INVALID KUBERNETES Authorization header logs error", func(t *testing.T) { if !strings.Contains(ctx.LogBuffer.String(), "Authentication failed - JWT validation error") || !strings.Contains(ctx.LogBuffer.String(), "kubernetes API token validation error: failed to create token review") { t.Errorf("Expected log entry for Kubernetes TokenReview error, got: %s", ctx.LogBuffer.String()) } }) }) } func TestAuthorizationRequireOAuthFalse(t *testing.T) { testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: false, ClusterProviderStrategy: config.ClusterProviderKubeConfig}}, func(ctx *httpContext) { resp, err := http.Get(fmt.Sprintf("http://%s/mcp", ctx.HttpAddress)) if err != nil { t.Fatalf("Failed to get protected endpoint: %v", err) } t.Cleanup(func() { _ = resp.Body.Close() }) t.Run("Protected resource with MISSING Authorization header returns 200 - OK)", func(t *testing.T) { if resp.StatusCode != http.StatusOK { t.Errorf("Expected HTTP 200 OK, got %d", resp.StatusCode) } }) }) } func TestAuthorizationRawToken(t *testing.T) { cases := []struct { audience string validateToken bool }{ {"", false}, // No audience, no validation {"", true}, // No audience, validation enabled {"mcp-server", false}, // Audience set, no validation {"mcp-server", true}, // Audience set, validation enabled } for _, c := range cases { testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, OAuthAudience: c.audience, ValidateToken: c.validateToken, ClusterProviderStrategy: config.ClusterProviderKubeConfig}}, func(ctx *httpContext) { tokenReviewed := false ctx.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { if req.URL.EscapedPath() == "/apis/authentication.k8s.io/v1/tokenreviews" { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(tokenReviewSuccessful)) tokenReviewed = true return } })) req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil) if err != nil { t.Fatalf("Failed to create request: %v", err) } req.Header.Set("Authorization", "Bearer "+tokenBasicNotExpired) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("Failed to get protected endpoint: %v", err) } t.Cleanup(func() { _ = resp.Body.Close() }) t.Run(fmt.Sprintf("Protected resource with audience = '%s' and validate-token = '%t', with VALID Authorization header returns 200 - OK", c.audience, c.validateToken), func(t *testing.T) { if resp.StatusCode != http.StatusOK { t.Errorf("Expected HTTP 200 OK, got %d", resp.StatusCode) } }) t.Run(fmt.Sprintf("Protected resource with audience = '%s' and validate-token = '%t', with VALID Authorization header performs token validation accordingly", c.audience, c.validateToken), func(t *testing.T) { if tokenReviewed == true && !c.validateToken { t.Errorf("Expected token review to be skipped when validate-token is false, but it was performed") } if tokenReviewed == false && c.validateToken { t.Errorf("Expected token review to be performed when validate-token is true, but it was skipped") } }) }) } } func TestAuthorizationOidcToken(t *testing.T) { oidcTestServer := NewOidcTestServer(t) t.Cleanup(oidcTestServer.Close) rawClaims := `{ "iss": "` + oidcTestServer.URL + `", "exp": ` + strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10) + `, "aud": "mcp-server" }` validOidcToken := oidctest.SignIDToken(oidcTestServer.PrivateKey, "test-oidc-key-id", oidc.RS256, rawClaims) cases := []bool{false, true} for _, validateToken := range cases { testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, OAuthAudience: "mcp-server", ValidateToken: validateToken, ClusterProviderStrategy: config.ClusterProviderKubeConfig}, OidcProvider: oidcTestServer.Provider}, func(ctx *httpContext) { tokenReviewed := false ctx.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { if req.URL.EscapedPath() == "/apis/authentication.k8s.io/v1/tokenreviews" { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(tokenReviewSuccessful)) tokenReviewed = true return } })) req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil) if err != nil { t.Fatalf("Failed to create request: %v", err) } req.Header.Set("Authorization", "Bearer "+validOidcToken) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("Failed to get protected endpoint: %v", err) } t.Cleanup(func() { _ = resp.Body.Close() }) t.Run(fmt.Sprintf("Protected resource with validate-token='%t' with VALID OIDC Authorization header returns 200 - OK", validateToken), func(t *testing.T) { if resp.StatusCode != http.StatusOK { t.Errorf("Expected HTTP 200 OK, got %d", resp.StatusCode) } }) t.Run(fmt.Sprintf("Protected resource with validate-token='%t' with VALID OIDC Authorization header performs token validation accordingly", validateToken), func(t *testing.T) { if tokenReviewed == true && !validateToken { t.Errorf("Expected token review to be skipped when validate-token is false, but it was performed") } if tokenReviewed == false && validateToken { t.Errorf("Expected token review to be performed when validate-token is true, but it was skipped") } }) }) } } func TestAuthorizationOidcTokenExchange(t *testing.T) { oidcTestServer := NewOidcTestServer(t) t.Cleanup(oidcTestServer.Close) rawClaims := `{ "iss": "` + oidcTestServer.URL + `", "exp": ` + strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10) + `, "aud": "%s" }` validOidcClientToken := oidctest.SignIDToken(oidcTestServer.PrivateKey, "test-oidc-key-id", oidc.RS256, fmt.Sprintf(rawClaims, "mcp-server")) validOidcBackendToken := oidctest.SignIDToken(oidcTestServer.PrivateKey, "test-oidc-key-id", oidc.RS256, fmt.Sprintf(rawClaims, "backend-audience")) oidcTestServer.TokenEndpointHandler = func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = fmt.Fprintf(w, `{"access_token":"%s","token_type":"Bearer","expires_in":253402297199}`, validOidcBackendToken) } cases := []bool{false, true} for _, validateToken := range cases { staticConfig := &config.StaticConfig{ RequireOAuth: true, OAuthAudience: "mcp-server", ValidateToken: validateToken, StsClientId: "test-sts-client-id", StsClientSecret: "test-sts-client-secret", StsAudience: "backend-audience", StsScopes: []string{"backend-scope"}, ClusterProviderStrategy: config.ClusterProviderKubeConfig, } testCaseWithContext(t, &httpContext{StaticConfig: staticConfig, OidcProvider: oidcTestServer.Provider}, func(ctx *httpContext) { tokenReviewed := false ctx.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { if req.URL.EscapedPath() == "/apis/authentication.k8s.io/v1/tokenreviews" { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(tokenReviewSuccessful)) tokenReviewed = true return } })) req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil) if err != nil { t.Fatalf("Failed to create request: %v", err) } req.Header.Set("Authorization", "Bearer "+validOidcClientToken) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("Failed to get protected endpoint: %v", err) } t.Cleanup(func() { _ = resp.Body.Close() }) t.Run(fmt.Sprintf("Protected resource with validate-token='%t' with VALID OIDC EXCHANGE Authorization header returns 200 - OK", validateToken), func(t *testing.T) { if resp.StatusCode != http.StatusOK { t.Errorf("Expected HTTP 200 OK, got %d", resp.StatusCode) } }) t.Run(fmt.Sprintf("Protected resource with validate-token='%t' with VALID OIDC EXCHANGE Authorization header performs token validation accordingly", validateToken), func(t *testing.T) { if tokenReviewed == true && !validateToken { t.Errorf("Expected token review to be skipped when validate-token is false, but it was performed") } if tokenReviewed == false && validateToken { t.Errorf("Expected token review to be performed when validate-token is true, but it was skipped") } }) }) } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/containers/kubernetes-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server